Skip to content

Commit 006d0a3

Browse files
committed
fix(announce): durable in-process queue fallback for direct-pending handoffs
When subagent direct-announce dispatch returns a non-terminal status ("accepted"/"started"/"in_flight") and the requester parent is not streaming (yielded, queued, compaction-prep, or tool-result truncated), the previous behaviour returned {delivered:false, error:"completion agent handoff is still pending"}, surfacing as the dominant `delivery_failed` audit-finding signature. Route those cases into the requester's in-process durable system-event inbox instead, keyed by the announce idempotency key. The inbox is idempotent on (text, contextKey, deliveryContext), bounded at 20 events per session, and drained on the parent's next turn-start (the same path already used by jobs, hooks, restart sentinels, ACP child spawns, monitor events, and the group/channel completion path in task-registry). The `SubagentDeliveryPath` union gains a new `"durable_queue""` literal so consumers can route on it. The harness-task-runtime durability check (`isDurableAgentHarnessCompletionDelivery`) is extended to recognise the new path, preventing Codex native-subagent monitor retry from churning duplicates against an already-committed inbox enqueue. If `enqueueSystemEvent` itself throws, the original give-up payload (structured `reason: "completion_handoff_pending"` + error string) is returned as a safety net so existing retry/give-up bookkeeping remains the floor. Tests: - 5 unit tests in subagent-announce-delivery.test.ts covering the new branch, idempotency on duplicate fires, no-fallback-on-active-stream, no-fallback-with-media (preserve existing path), and result-envelope shape. - 1 integration test in embedded-agent-runner/sessions-yield.orchestration reproducing the full failure class end-to-end: parent yield -> isEmbeddedAgentRunActive(false) -> announce with stubbed callGateway returning `accepted` -> assert path:"durable_queue" -> drain returns the trigger. - 1 unit test in agent-harness-task-runtime.test.ts asserting durable_queue is treated as durable and that delivered:false still fails fast. Targeted suite (5 files): 150/150 pass. Full unit lane: 10071 pass; the 5 pre-existing failures on main (`status.summary.runtime`, `subagents-tool` config-isolation issues) reproduce with no patch applied and are not regressions from this change.
1 parent 0df6292 commit 006d0a3

6 files changed

Lines changed: 365 additions & 9 deletions

src/agents/embedded-agent-runner/sessions-yield.orchestration.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@
33
* with no pending tool calls, so the parent session is idle when subagent
44
* results arrive.
55
*/
6-
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
6+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7+
import {
8+
drainSystemEvents,
9+
peekSystemEvents,
10+
resetSystemEventsForTest,
11+
} from "../../infra/system-events.js";
12+
import {
13+
deliverSubagentAnnouncement,
14+
testing as subagentAnnounceTesting,
15+
} from "../subagent-announce-delivery.js";
16+
import { callGateway as runtimeCallGateway } from "../subagent-announce-delivery.runtime.js";
717
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
818
import {
919
loadRunOverflowCompactionHarness,
@@ -126,4 +136,117 @@ describe("sessions_yield orchestration", () => {
126136
expect(result.meta.stopReason).toBeUndefined();
127137
expect(result.meta.pendingToolCalls).toBeUndefined();
128138
});
139+
140+
// ──────────────────────────────────────────────────────────────────────
141+
// Integration repro: parent yield → child completion → durable inbox →
142+
// next-turn drain. End-to-end coverage of the failure class that
143+
// produced today's `delivery_failed` audit findings: a yielded parent
144+
// session has no active embedded run, so the gateway agent-call dispatch
145+
// returns a non-terminal `accepted/started/in_flight` status. The patch
146+
// routes through the durable in-process system-event inbox keyed by the
147+
// announce idempotency key; the parent drains the inbox at next
148+
// turn-start. This test verifies the loop closes — the trigger message
149+
// is in the inbox after the announce, and `drainSystemEvents` returns it
150+
// for the next prompt build.
151+
// ──────────────────────────────────────────────────────────────────────
152+
it("end-to-end: yielded parent + child completion via direct-pending → durable inbox → next-turn drain", async () => {
153+
// Use a canonical sessionKey (starts with 'agent:') so
154+
// resolveRequesterStoreKey leaves it untouched.
155+
const parentSessionKey = "agent:main:session:yield-integration-parent-key";
156+
const parentSessionId = "yield-integration-parent-session";
157+
158+
// Stage 1: parent yields. After this attempt resolves with
159+
// yieldDetected=true, the parent is removed from ACTIVE_EMBEDDED_RUNS
160+
// and isStreaming() returns false — exactly the `not_streaming` state
161+
// that makes embedded steer fail and triggers the patched fallback.
162+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
163+
makeAttemptResult({
164+
promptError: null,
165+
sessionIdUsed: parentSessionId,
166+
yieldDetected: true,
167+
}),
168+
);
169+
170+
const yieldResult = await runEmbeddedAgent({
171+
...overflowBaseRunParams,
172+
sessionId: parentSessionId,
173+
sessionKey: parentSessionKey,
174+
runId: "run-yield-integration",
175+
});
176+
177+
expect(yieldResult.meta.stopReason).toBe("end_turn");
178+
expect(yieldResult.meta.pendingToolCalls).toBeUndefined();
179+
expect(isEmbeddedAgentRunActive(parentSessionId)).toBe(false);
180+
181+
// Sanity: a steer attempt now would fail with no_active_run — same
182+
// condition that drives the announce flow's direct-dispatch branch.
183+
const steerProbe = queueEmbeddedAgentMessageWithOutcome(parentSessionId, "would-be-steer");
184+
expect(steerProbe.queued).toBe(false);
185+
186+
// Stage 2: child completion fires via deliverSubagentAnnouncement.
187+
// Stub callGateway to mimic the `not_streaming` path: the gateway
188+
// accepts the run but returns a non-terminal status, which is the
189+
// exact bug-class signature in today's audit drops (4 of 4 had error
190+
// "completion agent handoff is still pending").
191+
resetSystemEventsForTest();
192+
const callGatewayStub = vi.fn(async () => ({
193+
runId: "agent:main:run:integration-pending",
194+
status: "accepted" as const,
195+
acceptedAt: Date.now(),
196+
})) as unknown as typeof runtimeCallGateway;
197+
subagentAnnounceTesting.setDepsForTest({
198+
callGateway: callGatewayStub,
199+
getRequesterSessionActivity: () => ({
200+
sessionId: parentSessionId,
201+
isActive: false, // mirrors yielded/non-streaming reality
202+
}),
203+
});
204+
205+
const triggerText = "child cipher:integration-test done: APPROVE — durable-queue integration";
206+
const announceOrigin = {
207+
channel: "discord",
208+
to: "channel:integration-test",
209+
accountId: "acct-integration",
210+
} as const;
211+
212+
const announceResult = await deliverSubagentAnnouncement({
213+
requesterSessionKey: parentSessionKey,
214+
targetRequesterSessionKey: parentSessionKey,
215+
triggerMessage: triggerText,
216+
steerMessage: triggerText,
217+
requesterOrigin: announceOrigin,
218+
requesterSessionOrigin: announceOrigin,
219+
completionDirectOrigin: announceOrigin,
220+
directOrigin: announceOrigin,
221+
requesterIsSubagent: false,
222+
expectsCompletionMessage: true,
223+
bestEffortDeliver: true,
224+
directIdempotencyKey: "integration-durable-queue",
225+
sourceTool: "sessions_spawn",
226+
});
227+
228+
// Stage 3: announce was redirected to durable_queue (not failed).
229+
expect(announceResult.delivered).toBe(true);
230+
expect(announceResult.path).toBe("durable_queue");
231+
expect(announceResult.error).toBeUndefined();
232+
233+
// The trigger message is in the parent's durable inbox before drain.
234+
const queuedBeforeDrain = peekSystemEvents(parentSessionKey);
235+
expect(queuedBeforeDrain).toEqual([triggerText]);
236+
237+
// Stage 4: simulate next-turn drain. drainSystemEvents is the
238+
// primitive that the auto-reply pipeline calls when building the
239+
// next prompt for an idle parent.
240+
const drained = drainSystemEvents(parentSessionKey);
241+
expect(drained).toEqual([triggerText]);
242+
243+
// Inbox is empty after drain; the parent's next prompt would carry
244+
// the child completion as a System: line.
245+
expect(peekSystemEvents(parentSessionKey)).toEqual([]);
246+
247+
// Cleanup: leaving stubbed deps in place would leak into any
248+
// subsequent suite that imports subagent-announce-delivery.
249+
subagentAnnounceTesting.setDepsForTest();
250+
resetSystemEventsForTest();
251+
}, 20_000);
129252
});

src/agents/subagent-announce-delivery.test.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { afterEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { OutboundDeliveryError } from "../infra/outbound/deliver-types.js";
33
import {
44
testing as sessionBindingServiceTesting,
55
registerSessionBindingAdapter,
66
} from "../infra/outbound/session-binding-service.js";
7+
import {
8+
drainSystemEvents,
9+
peekSystemEvents,
10+
resetSystemEventsForTest,
11+
} from "../infra/system-events.js";
712
import { setActivePluginRegistry } from "../plugins/runtime.js";
813
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
914
import type {
@@ -4490,3 +4495,153 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
44904495
});
44914496
});
44924497
});
4498+
4499+
describe("durable-queue fallback when direct handoff is pending", () => {
4500+
beforeEach(() => {
4501+
resetSystemEventsForTest();
4502+
});
4503+
4504+
afterEach(() => {
4505+
resetSystemEventsForTest();
4506+
});
4507+
4508+
// The canonical requester store key (resolveRequesterStoreKey) leaves keys
4509+
// that already start with "agent:" untouched. Using a canonical key in the
4510+
// test avoids the "agent:<inferred>:<raw>" remap and keeps the assertions
4511+
// clean.
4512+
const parentSessionKey = "agent:main:session:durable-queue-test-parent";
4513+
4514+
function pendingHandoffArgs(
4515+
overrides: Partial<Parameters<typeof deliverSubagentAnnouncement>[0]> = {},
4516+
): Parameters<typeof deliverSubagentAnnouncement>[0] {
4517+
const callGateway = vi.fn(async () => ({
4518+
runId: "agent:main:run:announce-pending",
4519+
status: "accepted" as const,
4520+
acceptedAt: 4_000,
4521+
})) as unknown as typeof runtimeCallGateway;
4522+
testing.setDepsForTest({
4523+
callGateway,
4524+
getRequesterSessionActivity: () => ({
4525+
sessionId: "durable-queue-test-parent-session",
4526+
isActive: false,
4527+
}),
4528+
queueEmbeddedAgentMessageWithOutcome: createQueueOutcomeMock(false),
4529+
});
4530+
4531+
return {
4532+
requesterSessionKey: parentSessionKey,
4533+
targetRequesterSessionKey: parentSessionKey,
4534+
triggerMessage: "child cipher:abc done: APPROVE",
4535+
steerMessage: "child cipher:abc done: APPROVE",
4536+
requesterOrigin: slackThreadOrigin,
4537+
requesterSessionOrigin: slackThreadOrigin,
4538+
completionDirectOrigin: slackThreadOrigin,
4539+
directOrigin: slackThreadOrigin,
4540+
requesterIsSubagent: false,
4541+
expectsCompletionMessage: true,
4542+
bestEffortDeliver: true,
4543+
directIdempotencyKey: "durable-queue-test-idem",
4544+
sourceTool: "sessions_spawn",
4545+
...overrides,
4546+
} as Parameters<typeof deliverSubagentAnnouncement>[0];
4547+
}
4548+
4549+
it("enqueues into the durable inbox when direct handoff is pending and parent is non-streaming", async () => {
4550+
const result = await deliverSubagentAnnouncement(pendingHandoffArgs());
4551+
4552+
expect(result.delivered).toBe(true);
4553+
expect(result.path).toBe("durable_queue");
4554+
expect(result.error).toBeUndefined();
4555+
expect(peekSystemEvents(parentSessionKey)).toEqual(["child cipher:abc done: APPROVE"]);
4556+
});
4557+
4558+
it("is idempotent on duplicate fallback fires for the same idempotency key", async () => {
4559+
const args = pendingHandoffArgs({
4560+
directIdempotencyKey: "durable-queue-test-dup",
4561+
});
4562+
const r1 = await deliverSubagentAnnouncement(args);
4563+
const r2 = await deliverSubagentAnnouncement(args);
4564+
4565+
expect(r1.path).toBe("durable_queue");
4566+
expect(r2.path).toBe("durable_queue");
4567+
// Inbox dedupes on (text, contextKey, deliveryContext); duplicate fires
4568+
// collapse to one entry.
4569+
expect(peekSystemEvents(parentSessionKey)).toEqual(["child cipher:abc done: APPROVE"]);
4570+
});
4571+
4572+
it("does not invoke durable fallback when parent is active-streaming (steered path wins)", async () => {
4573+
const callGateway = vi.fn(async () => ({
4574+
runId: "agent:main:run:announce-pending",
4575+
status: "accepted" as const,
4576+
acceptedAt: 4_000,
4577+
})) as unknown as typeof runtimeCallGateway;
4578+
testing.setDepsForTest({
4579+
callGateway,
4580+
getRequesterSessionActivity: () => ({
4581+
sessionId: "durable-queue-test-parent-session",
4582+
isActive: true,
4583+
}),
4584+
queueEmbeddedAgentMessageWithOutcome: createQueueOutcomeMock(true),
4585+
});
4586+
4587+
const result = await deliverSubagentAnnouncement({
4588+
requesterSessionKey: parentSessionKey,
4589+
targetRequesterSessionKey: parentSessionKey,
4590+
triggerMessage: "child active",
4591+
steerMessage: "child active",
4592+
requesterOrigin: slackThreadOrigin,
4593+
requesterIsSubagent: false,
4594+
expectsCompletionMessage: true,
4595+
directIdempotencyKey: "durable-queue-test-active",
4596+
});
4597+
4598+
expect(result.path).toBe("steered");
4599+
expect(peekSystemEvents(parentSessionKey)).toEqual([]);
4600+
});
4601+
4602+
it("does NOT redirect to durable_queue when expectedMediaUrls is non-empty (existing direct/media path preserved)", async () => {
4603+
const args = pendingHandoffArgs({
4604+
directIdempotencyKey: "durable-queue-test-media",
4605+
internalEvents: [
4606+
{
4607+
type: "task_completion",
4608+
source: "image_generation",
4609+
childSessionKey: "image_generate:task-test",
4610+
childSessionId: "task-test",
4611+
announceType: "image generation task",
4612+
taskLabel: "test image",
4613+
status: "ok",
4614+
statusLabel: "completed successfully",
4615+
result: "Generated.\nMEDIA:/tmp/generated-test.png",
4616+
mediaUrls: ["/tmp/generated-test.png"],
4617+
replyInstruction: "Deliver the generated image.",
4618+
},
4619+
],
4620+
});
4621+
const result = await deliverSubagentAnnouncement(args);
4622+
4623+
// Either the existing direct/media path returns delivered:true
4624+
// (downstream of the patched branch) or the existing pending give-up
4625+
// returns delivered:false; the contract this test enforces is simply
4626+
// that the durable_queue branch is NOT taken when media is in play.
4627+
expect(result.path).not.toBe("durable_queue");
4628+
expect(peekSystemEvents(parentSessionKey)).toEqual([]);
4629+
});
4630+
4631+
it("records the result envelope shape consumers can route on", async () => {
4632+
const result = await deliverSubagentAnnouncement(
4633+
pendingHandoffArgs({
4634+
directIdempotencyKey: "durable-queue-test-shape",
4635+
}),
4636+
);
4637+
4638+
expect(result).toEqual({
4639+
delivered: true,
4640+
path: "durable_queue",
4641+
phases: expect.any(Array),
4642+
});
4643+
// Drain confirms the next-turn prompt would receive the trigger.
4644+
expect(drainSystemEvents(parentSessionKey)).toEqual(["child cipher:abc done: APPROVE"]);
4645+
expect(peekSystemEvents(parentSessionKey)).toEqual([]);
4646+
});
4647+
});

src/agents/subagent-announce-delivery.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
1313
import { isOutboundDeliveryError } from "../infra/outbound/deliver-types.js";
1414
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
1515
import { sourceDeliveryTargetsMatch } from "../infra/outbound/source-delivery-plan.js";
16+
import { enqueueSystemEvent } from "../infra/system-events.js";
1617
import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
1718
import { normalizeAccountId } from "../routing/session-key.js";
1819
import { defaultRuntime } from "../runtime.js";
@@ -1474,12 +1475,46 @@ async function sendSubagentAnnounceDirectly(params: {
14741475
expectedMediaUrls.length === 0 &&
14751476
!requiresMessageToolDelivery
14761477
) {
1477-
return {
1478-
delivered: false,
1479-
path: "direct",
1480-
reason: "completion_handoff_pending",
1481-
error: "completion agent handoff is still pending",
1482-
};
1478+
// The gateway accepted the agent run but it has not yet reached a
1479+
// streaming/terminal state. This commonly happens when the
1480+
// requester parent is registered in ACTIVE_EMBEDDED_RUNS but its
1481+
// handle.isStreaming() is false (yielded, queued behind work,
1482+
// compaction-prep, or tool-result truncation). Returning
1483+
// {delivered:false} here is what produces today's `delivery_failed`
1484+
// audit findings: the requester is stuck waiting for an event that
1485+
// already arrived in the gateway but cannot deliver back.
1486+
//
1487+
// Instead, enqueue the trigger message into the requester's
1488+
// in-process durable system-event inbox keyed by the announce's
1489+
// direct idempotency key (which uniquely identifies this child
1490+
// announce). The inbox is drained on the requester's next
1491+
// turn-start (the same path used by jobs, hooks, restart
1492+
// sentinels, ACP child spawns, monitor events, and the
1493+
// group/channel completion path in task-registry). Idempotent on
1494+
// (text, contextKey, deliveryContext); MAX_EVENTS=20 per session.
1495+
try {
1496+
const durableContextKey = `subagent-announce:${params.directIdempotencyKey}`;
1497+
const durableDeliveryContext =
1498+
requesterSessionOrigin ?? completionExternalFallbackOrigin ?? directOrigin;
1499+
enqueueSystemEvent(params.triggerMessage, {
1500+
sessionKey: canonicalRequesterSessionKey,
1501+
contextKey: durableContextKey,
1502+
deliveryContext: durableDeliveryContext,
1503+
});
1504+
return {
1505+
delivered: true,
1506+
path: "durable_queue",
1507+
};
1508+
} catch {
1509+
// Fall through to the original give-up shape so existing
1510+
// retry/give-up bookkeeping stays as the safety net.
1511+
return {
1512+
delivered: false,
1513+
path: "direct",
1514+
reason: "completion_handoff_pending",
1515+
error: "completion agent handoff is still pending",
1516+
};
1517+
}
14831518
}
14841519
return {
14851520
delivered: true,

src/agents/subagent-announce-dispatch.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
type SubagentDeliveryPath = "steered" | "direct" | "none";
1+
type SubagentDeliveryPath =
2+
| "steered"
3+
| "direct"
4+
| "none"
5+
/**
6+
* The trigger message was committed to the requester's in-process
7+
* durable system-event inbox because the direct agent-call dispatch
8+
* returned a non-terminal pending status (e.g., gateway accepted but
9+
* parent is yielded/queued/compaction-prep). The parent drains the
10+
* inbox at next turn-start. Treated as a delivered path; downstream
11+
* consumers that previously short-circuited on `direct`/`steered`
12+
* should treat `durable_queue` as durable as well.
13+
*/
14+
| "durable_queue";
215
export type SubagentAnnounceDeliveryFailureReason =
316
| "completion_handoff_pending"
417
| "generated_media_missing"

0 commit comments

Comments
 (0)