Skip to content

Commit 8a007c9

Browse files
fix(agents): fallback when generated media handoff locks
Generated-media completions now use the existing idempotent direct-media fallback when active requester wake has already failed and the requester-agent handoff hits a session write-lock-shaped no-response error. Generic requester-agent handoff errors still fail visibly instead of direct-sending after an unknown side effect. Release-note context: fixes a message-delivery loss path for generated images, music, and video where the artifact had been created but the final handoff could be reported as failed after a session write lock. Verification: - GitHub CI run 26601111985 passed at b0be994. - Blacksmith Testbox through Crabbox tbx_01ksr2jtt3fnz0zqvwmqq513h7 covered the exact lock fallback and qa-channel generated-media smoke. - git diff --check origin/main...refs/remotes/pull/87741/head passed before merge. Co-authored-by: Jason (Json) <[email protected]>
1 parent 51b6949 commit 8a007c9

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2890,6 +2890,109 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
28902890
);
28912891
});
28922892

2893+
it("directly delivers generated media after active wake failure when requester handoff locks", async () => {
2894+
const callGateway = vi.fn(async () => {
2895+
throw new Error(
2896+
"SessionWriteLockTimeoutError: session file locked (timeout 60000ms): pid=43",
2897+
);
2898+
}) as unknown as typeof runtimeCallGateway;
2899+
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([
2900+
"transcript_commit_wait_unsupported",
2901+
"no_active_run",
2902+
]);
2903+
const sendMessage = createSendMessageMock();
2904+
const result = await deliverSlackChannelAnnouncement({
2905+
callGateway,
2906+
sendMessage,
2907+
queueEmbeddedAgentMessageWithOutcome,
2908+
sessionId: "requester-session-channel",
2909+
isActive: true,
2910+
expectsCompletionMessage: true,
2911+
directIdempotencyKey: "announce-channel-media-handoff-locked",
2912+
sourceTool: "image_generate",
2913+
internalEvents: [
2914+
{
2915+
type: "task_completion",
2916+
source: "image_generation",
2917+
childSessionKey: "image_generate:task-locked",
2918+
childSessionId: "task-locked",
2919+
announceType: "image generation task",
2920+
taskLabel: "locked handoff image",
2921+
status: "ok",
2922+
statusLabel: "completed successfully",
2923+
result: "Generated 1 image.\nMEDIA:/tmp/generated-locked.png",
2924+
mediaUrls: ["/tmp/generated-locked.png"],
2925+
replyInstruction:
2926+
"Tell the user the image is ready and send it through the message tool.",
2927+
},
2928+
],
2929+
});
2930+
2931+
expectRecordFields(result, {
2932+
delivered: true,
2933+
path: "direct",
2934+
});
2935+
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(2);
2936+
expect(callGateway).toHaveBeenCalledTimes(1);
2937+
expect(sendMessage).toHaveBeenCalledWith(
2938+
expect.objectContaining({
2939+
channel: "slack",
2940+
accountId: "acct-1",
2941+
to: "channel:C123",
2942+
content: "The generated image is ready.",
2943+
mediaUrls: ["/tmp/generated-locked.png"],
2944+
idempotencyKey: "announce-channel-media-handoff-locked:generated-media-direct",
2945+
}),
2946+
);
2947+
});
2948+
2949+
it("keeps generic requester handoff errors visible after active wake failure", async () => {
2950+
const callGateway = vi.fn(async () => {
2951+
throw new Error("requester handoff exploded after dispatch");
2952+
}) as unknown as typeof runtimeCallGateway;
2953+
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([
2954+
"transcript_commit_wait_unsupported",
2955+
"no_active_run",
2956+
]);
2957+
const sendMessage = createSendMessageMock();
2958+
2959+
const result = await deliverSlackChannelAnnouncement({
2960+
callGateway,
2961+
sendMessage,
2962+
queueEmbeddedAgentMessageWithOutcome,
2963+
sessionId: "requester-session-channel",
2964+
isActive: true,
2965+
expectsCompletionMessage: true,
2966+
directIdempotencyKey: "announce-channel-media-handoff-error",
2967+
sourceTool: "image_generate",
2968+
internalEvents: [
2969+
{
2970+
type: "task_completion",
2971+
source: "image_generation",
2972+
childSessionKey: "image_generate:task-error",
2973+
childSessionId: "task-error",
2974+
announceType: "image generation task",
2975+
taskLabel: "errored handoff image",
2976+
status: "ok",
2977+
statusLabel: "completed successfully",
2978+
result: "Generated 1 image.\nMEDIA:/tmp/generated-error.png",
2979+
mediaUrls: ["/tmp/generated-error.png"],
2980+
replyInstruction:
2981+
"Tell the user the image is ready and send it through the message tool.",
2982+
},
2983+
],
2984+
});
2985+
2986+
expectRecordFields(result, {
2987+
delivered: false,
2988+
path: "direct",
2989+
error: "requester handoff exploded after dispatch",
2990+
});
2991+
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalled();
2992+
expect(callGateway).toHaveBeenCalledTimes(1);
2993+
expect(sendMessage).not.toHaveBeenCalled();
2994+
});
2995+
28932996
it("directly delivers stale isolated cron run media completions", async () => {
28942997
const callGateway = createGatewayMock();
28952998
const sendMessage = createSendMessageMock();

src/agents/subagent-announce-delivery.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type { EmbeddedAgentQueueMessageOptions } from "./embedded-agent-runner/r
3737
import type { EmbeddedAgentQueueMessageOutcome } from "./embedded-agent-runner/runs.js";
3838
import { mediaUrlsFromGeneratedAttachments } from "./generated-attachments.js";
3939
import type { AgentInternalEvent } from "./internal-events.js";
40+
import { isSessionWriteLockTimeoutError } from "./session-write-lock-error.js";
4041
import {
4142
callGateway,
4243
createBoundDeliveryRouter,
@@ -394,6 +395,16 @@ function isIncompleteAnnounceAgentResultError(error: unknown): boolean {
394395
return /(?:incomplete terminal response|code=incomplete_result)\b/i.test(message);
395396
}
396397

398+
function isSessionWriteLockAnnounceAgentError(error: unknown): boolean {
399+
if (isSessionWriteLockTimeoutError(error)) {
400+
return true;
401+
}
402+
const message = summarizeDeliveryError(error);
403+
return (
404+
/\bSessionWriteLockTimeoutError\b/.test(message) || /\bsession file locked\b/i.test(message)
405+
);
406+
}
407+
397408
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
398409
if (ms <= 0) {
399410
return;
@@ -1240,6 +1251,17 @@ async function sendSubagentAnnounceDirectly(params: {
12401251
return textDelivery;
12411252
}
12421253
}
1254+
if (
1255+
activeRequesterWakeFailed &&
1256+
agentMediatedCompletion &&
1257+
expectedMediaUrls.length > 0 &&
1258+
isSessionWriteLockAnnounceAgentError(err)
1259+
) {
1260+
const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
1261+
if (generatedMediaDelivery) {
1262+
return generatedMediaDelivery;
1263+
}
1264+
}
12431265
// The requester-agent handoff is the delivery contract for background
12441266
// completions. A failed handoff should retry/fail visibly instead
12451267
// of sending the child result directly to the external channel.

0 commit comments

Comments
 (0)