Skip to content

Commit aeba216

Browse files
committed
fix(auto-reply): stranded retry runs own no client queued-turn lifecycle
1 parent 11ac9f4 commit aeba216

4 files changed

Lines changed: 69 additions & 1 deletion

File tree

src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3487,6 +3487,7 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
34873487
sendPolicyDenied?: boolean;
34883488
isHeartbeat?: boolean;
34893489
replyOperation?: ReturnType<typeof createReplyOperation>;
3490+
queuedLifecycle?: FollowupRun["queuedLifecycle"];
34903491
}) {
34913492
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stranded-"));
34923493
const storePath = path.join(tmp, "sessions.json");
@@ -3543,6 +3544,7 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
35433544
...(params.strandedReplyRetry ? { strandedReplyRetry: true } : {}),
35443545
enqueuedAt: Date.now(),
35453546
...(params.transcriptPrompt ? { transcriptPrompt: params.transcriptPrompt } : {}),
3547+
...(params.queuedLifecycle ? { queuedLifecycle: params.queuedLifecycle } : {}),
35463548
run: {
35473549
agentId: "main",
35483550
agentDir: "/tmp/agent",
@@ -3603,7 +3605,11 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
36033605
});
36043606

36053607
it("enqueues a one-shot recovery retry by default for substantive stranded finals", async () => {
3606-
const { finalAssistantText } = await runPrivateFinalCase({});
3608+
const parentOnComplete = vi.fn();
3609+
const parentLifecycle = { onComplete: parentOnComplete };
3610+
const { finalAssistantText } = await runPrivateFinalCase({
3611+
queuedLifecycle: parentLifecycle,
3612+
});
36073613

36083614
expect(warnPrivateFinalSpy).toHaveBeenCalledTimes(1);
36093615
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1);
@@ -3614,6 +3620,10 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
36143620
expect(retryRun?.strandedReplyRetry).toBe(true);
36153621
expect(retryRun?.prompt).toContain("message(action=send)");
36163622
expect(retryRun?.prompt).toContain(finalAssistantText);
3623+
// System retry must not inherit the client turn's one-shot lifecycle identity.
3624+
expect(retryRun?.queuedLifecycle).toBeUndefined();
3625+
expect(parentLifecycle.onComplete).toBe(parentOnComplete);
3626+
expect(parentOnComplete).not.toHaveBeenCalled();
36173627
});
36183628

36193629
it("uses visible final text, not raw assistant text, in the recovery retry prompt", async () => {

src/auto-reply/reply/followup-runner.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5547,6 +5547,8 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
55475547
it("enqueues a one-shot recovery retry for substantive message-tool-only queued followup finals", async () => {
55485548
const finalText =
55495549
"Here is the answer the queued user asked for. It includes enough detail to be a visible response, and it has another sentence so the substantive-final detector treats it as a real reply.";
5550+
const parentOnComplete = vi.fn();
5551+
const parentLifecycle = { onComplete: parentOnComplete };
55505552
const queued = baseQueuedRun("discord");
55515553
const { onBlockReply } = await runMessagingCase({
55525554
agentResult: {
@@ -5557,6 +5559,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
55575559
...queued,
55585560
originatingChannel: "discord",
55595561
originatingTo: "channel:C1",
5562+
queuedLifecycle: parentLifecycle,
55605563
run: {
55615564
...queued.run,
55625565
sourceReplyDeliveryMode: "message_tool_only",
@@ -5578,6 +5581,9 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
55785581
expect(retry?.run.sourceReplyDeliveryMode).toBe("message_tool_only");
55795582
expect(retry?.prompt).toContain("message(action=send)");
55805583
expect(retry?.prompt).toContain(finalText);
5584+
// System retry detaches from the client turn lifecycle; parent completion owns onComplete once.
5585+
expect(retry?.queuedLifecycle).toBeUndefined();
5586+
expect(parentOnComplete).toHaveBeenCalledTimes(1);
55815587
});
55825588

55835589
it("excludes raw trace and status payloads from queued stranded recovery prompts", async () => {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { completeFollowupRunLifecycle, markFollowupRunEnqueued } from "./queue/types.js";
3+
import {
4+
buildStrandedReplyRetryFollowupRun,
5+
STRANDED_REPLY_RETRY_MARKER,
6+
} from "./stranded-reply-recovery.js";
7+
import { createMockFollowupRun } from "./test-helpers.js";
8+
9+
describe("buildStrandedReplyRetryFollowupRun lifecycle ownership", () => {
10+
it("does not share the client turn's queuedLifecycle with the system retry", () => {
11+
const onComplete = vi.fn();
12+
const onEnqueued = vi.fn(() => true);
13+
const parent = createMockFollowupRun({
14+
prompt: "user question",
15+
transcriptPrompt: "user question",
16+
queuedLifecycle: { onComplete, onEnqueued },
17+
admissionSessionId: "sess-rotated",
18+
onFollowupAdmissionWaitChange: vi.fn(),
19+
});
20+
21+
const retry = buildStrandedReplyRetryFollowupRun(parent, {
22+
finalText: "A substantive stranded final that must be re-delivered via message(action=send).",
23+
sourceReplyDeliveryMode: "message_tool_only",
24+
});
25+
26+
expect(retry.queuedLifecycle).toBeUndefined();
27+
expect(retry.strandedReplyRetry).toBe(true);
28+
expect(retry.summaryLine).toBe(STRANDED_REPLY_RETRY_MARKER);
29+
// Session routing stays; only the client-turn lifecycle identity is detached.
30+
expect(retry.admissionSessionId).toBe("sess-rotated");
31+
expect(retry.onFollowupAdmissionWaitChange).toBe(parent.onFollowupAdmissionWaitChange);
32+
expect(retry.run.sessionKey).toBe(parent.run.sessionKey);
33+
34+
// mark/complete no-op when lifecycle is absent (drop-policy onDrop path too).
35+
expect(markFollowupRunEnqueued(retry)).toBe(true);
36+
expect(onEnqueued).not.toHaveBeenCalled();
37+
completeFollowupRunLifecycle(retry);
38+
expect(onComplete).not.toHaveBeenCalled();
39+
40+
// Parent still owns the one-shot lifecycle; retry completion must not steal it.
41+
expect(markFollowupRunEnqueued(parent)).toBe(true);
42+
expect(onEnqueued).toHaveBeenCalledTimes(1);
43+
completeFollowupRunLifecycle(parent);
44+
expect(onComplete).toHaveBeenCalledTimes(1);
45+
completeFollowupRunLifecycle(parent);
46+
expect(onComplete).toHaveBeenCalledTimes(1);
47+
});
48+
});

src/auto-reply/reply/stranded-reply-recovery.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export function buildStrandedReplyRetryFollowupRun(
4242
transcriptPrompt: undefined,
4343
userTurnTranscriptRecorder: undefined,
4444
currentInboundContext: undefined,
45+
// Internally generated system turn: the client turn's lifecycle (gateway cancel
46+
// identity) completes with the parent run. queuedLifecycle is one-shot WeakSet-tracked,
47+
// so a shared object would be double-owned and free cancel while the retry still runs.
48+
queuedLifecycle: undefined,
4549
run: {
4650
...base.run,
4751
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,

0 commit comments

Comments
 (0)