fix: preserve yielded CLI lifecycle state

This commit is contained in:
张贵萍0668001030
2026-06-06 20:10:26 +08:00
committed by Vincent Koc
parent c8c94e15ad
commit eea350f2ff
3 changed files with 86 additions and 377 deletions
+20 -242
View File
@@ -2000,69 +2000,7 @@ describe("subagent registry seam flow", () => {
expect(replacement?.endedAt).toBeUndefined();
});
it("keeps yield terminals paused when the lifecycle event also signals abort (#92448)", async () => {
// sessions_yield ends the turn by aborting the run signal, so a depth-1
// subagent's yield terminal can arrive carrying yielded plus aborted (or
// stopReason="aborted"). The event handler must still pause the run, not
// settle it `cancelled` and deliver a false notice to the requester.
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
const cases = [
{ runId: "run-yield-stopreason-aborted", extra: { stopReason: "aborted" } },
{ runId: "run-yield-aborted-flag", extra: { aborted: true } },
];
for (const testCase of cases) {
mod.registerSubagentRun({
runId: testCase.runId,
childSessionKey: `agent:main:subagent:${testCase.runId}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
lifecycleHandler?.({
runId: testCase.runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 111,
endedAt: 222,
yielded: true,
...testCase.extra,
},
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === testCase.runId);
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("error");
});
}
// Paused, never killed → no farewell/cancellation notice reaches the requester.
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("cancels a pending grace timer when a yield follows an intermediate aborted terminal (#92448)", async () => {
// An earlier aborted terminal schedules a deferred kill grace timer; a
// following yield must clear it, or it fires and settles the now-paused run.
it("keeps CLI lifecycle-yielded subagent runs paused instead of completing cleanup", async () => {
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
@@ -2071,81 +2009,13 @@ describe("subagent registry seam flow", () => {
});
mod.registerSubagentRun({
runId: "run-yield-after-pending-timeout",
childSessionKey: "agent:main:subagent:pending-timeout",
runId: "run-cli-lifecycle-yield-paused",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation",
cleanup: "keep",
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
expect(lifecycleHandler).toBeTypeOf("function");
// Intermediate aborted terminal → schedules the deferred kill grace timer.
lifecycleHandler?.({
runId: "run-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
});
// Yield terminal → must pause and cancel the pending grace timer.
lifecycleHandler?.({
runId: "run-yield-after-pending-timeout",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 333, yielded: true },
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
});
// Advancing well past the 15s grace window must not undo the pause.
await vi.advanceTimersByTimeAsync(60_000);
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("error");
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("cancels a pending grace timer when agent.wait observes the yield after an aborted terminal (#92448)", async () => {
let resolveWait: (value: {
status: "ok";
startedAt: number;
endedAt: number;
yielded: true;
}) => void = () => {};
const waitResult = new Promise<{
status: "ok";
startedAt: number;
endedAt: number;
yielded: true;
}>((resolve) => {
resolveWait = resolve;
});
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return waitResult;
}
return {};
});
mod.registerSubagentRun({
runId: "run-wait-yield-after-pending-timeout",
childSessionKey: "agent:main:subagent:pending-wait-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "wait for child continuation through wait",
cleanup: "keep",
task: "pause after CLI lifecycle yield",
cleanup: "delete",
expectsCompletionMessage: true,
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
@@ -2157,26 +2027,29 @@ describe("subagent registry seam flow", () => {
expect(lifecycleHandler).toBeTypeOf("function");
lifecycleHandler?.({
runId: "run-wait-yield-after-pending-timeout",
runId: "run-cli-lifecycle-yield-paused",
stream: "lifecycle",
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
data: {
phase: "end",
startedAt: 333,
endedAt: 444,
yielded: true,
livenessState: "paused",
stopReason: "end_turn",
},
});
resolveWait({ status: "ok", startedAt: 111, endedAt: 333, yielded: true });
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
.find((entry) => entry.runId === "run-cli-lifecycle-yield-paused");
expect(run?.endedAt).toBe(444);
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome).toBeUndefined();
});
await vi.advanceTimersByTimeAsync(60_000);
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
expect(run?.pauseReason).toBe("sessions_yield");
expect(run?.outcome?.status).not.toBe("timeout");
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(mocks.cleanupBrowserSessionsForLifecycleEnd).not.toHaveBeenCalled();
expect(mod.countPendingDescendantRuns("agent:main:main")).toBe(1);
});
it("announces blocked agent.wait snapshots as errors instead of success", async () => {
@@ -2777,101 +2650,6 @@ describe("subagent registry seam flow", () => {
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
it("announces restart lifecycle end events as killed subagent failures", async () => {
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
mod.registerSubagentRun({
runId: "run-restart-end",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "restart task",
cleanup: "keep",
expectsCompletionMessage: true,
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
lifecycleHandler?.({
runId: "run-restart-end",
stream: "lifecycle",
data: {
phase: "end",
startedAt: 10,
endedAt: 20,
aborted: true,
stopReason: "restart",
},
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-restart-end");
expect(run?.endedReason).toBe("subagent-killed");
expect(run?.outcome?.status).toBe("error");
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
});
it("announces restart lifecycle error events as killed subagent failures", async () => {
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
return { status: "pending" };
}
return {};
});
mod.registerSubagentRun({
runId: "run-restart-error",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "restart error task",
cleanup: "keep",
expectsCompletionMessage: true,
});
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
mocks.onAgentEvent.mock.calls.length - 1
] as unknown as
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
| undefined;
const lifecycleHandler = lastOnAgentEventCall?.[0];
lifecycleHandler?.({
runId: "run-restart-error",
stream: "lifecycle",
data: {
phase: "error",
startedAt: 10,
endedAt: 20,
error: "ACP turn failed before completion",
aborted: true,
stopReason: "restart",
},
});
await waitForFast(() => {
const run = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-restart-error");
expect(run?.endedReason).toBe("subagent-killed");
expect(run?.outcome?.status).toBe("error");
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
});
it("resumes ended cleanup when lifecycle killed completion rejects before cleanup", async () => {
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
@@ -1,105 +1,75 @@
// Tests CLI dispatch arguments and runtime selection for agent runner turns.
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
const cliDispatchMocks = vi.hoisted(() => ({
emitAgentEvent: vi.fn(),
runCliAgent: vi.fn(),
}));
vi.mock("../../agents/cli-runner.js", () => ({
runCliAgent: (...args: unknown[]) => cliDispatchMocks.runCliAgent(...args),
}));
vi.mock("../../infra/agent-events.js", () => ({
emitAgentEvent: (...args: unknown[]) => cliDispatchMocks.emitAgentEvent(...args),
onAgentEvent: vi.fn(() => () => undefined),
withAgentRunLifecycleGeneration: (_generation: string, run: () => unknown) => run(),
}));
import {
createCliToolSummaryTracker,
keepCliSessionBindingOnlyWhenReused,
runCliAgentWithLifecycle,
} from "./agent-runner-cli-dispatch.js";
const cliDispatchMocks = vi.hoisted(() => ({
emitAgentEvent: vi.fn(),
onAgentEvent: vi.fn(() => () => undefined),
runCliAgent: vi.fn(),
}));
vi.mock("../../agents/cli-runner.js", () => ({
runCliAgent: cliDispatchMocks.runCliAgent,
}));
vi.mock("../../infra/agent-events.js", () => ({
emitAgentEvent: cliDispatchMocks.emitAgentEvent,
onAgentEvent: cliDispatchMocks.onAgentEvent,
}));
beforeEach(() => {
vi.clearAllMocks();
cliDispatchMocks.onAgentEvent.mockReturnValue(() => undefined);
});
describe("runCliAgentWithLifecycle", () => {
it("keeps the captured lifecycle generation on start and terminal events", async () => {
cliDispatchMocks.emitAgentEvent.mockClear();
cliDispatchMocks.runCliAgent.mockResolvedValueOnce({
it("propagates yielded CLI result state on lifecycle end events", async () => {
cliDispatchMocks.runCliAgent.mockResolvedValue({
payloads: [],
meta: { durationMs: 1 },
meta: {
durationMs: 12,
yielded: true,
livenessState: "paused",
stopReason: "end_turn",
},
} satisfies EmbeddedAgentRunResult);
await runCliAgentWithLifecycle({
runId: "run-before-restart",
lifecycleGeneration: "pre-restart-generation",
runId: "run-yielded-cli",
provider: "claude-cli",
startedAt: 1_000,
runParams: {
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "hello",
sessionId: "session-yielded-cli",
sessionFile: "/tmp/session-yielded-cli.jsonl",
workspaceDir: "/tmp",
prompt: "yield now",
provider: "claude-cli",
model: "claude",
thinkLevel: "off",
timeoutMs: 1_000,
runId: "run-before-restart",
runId: "run-yielded-cli",
},
});
const lifecycleEvents = cliDispatchMocks.emitAgentEvent.mock.calls
.map(([event]) => event as { stream?: string; lifecycleGeneration?: string })
.filter((event) => event.stream === "lifecycle");
expect(lifecycleEvents).toHaveLength(2);
expect(
lifecycleEvents.every((event) => event.lifecycleGeneration === "pre-restart-generation"),
).toBe(true);
});
const lifecycleEndEvent = cliDispatchMocks.emitAgentEvent.mock.calls
.map(([event]) => event as { runId: string; stream: string; data: Record<string, unknown> })
.find(
(event) =>
event.runId === "run-yielded-cli" &&
event.stream === "lifecycle" &&
event.data.phase === "end",
);
it("preserves restart ownership when the CLI resolves after cancellation", async () => {
cliDispatchMocks.emitAgentEvent.mockClear();
const controller = new AbortController();
cliDispatchMocks.runCliAgent.mockImplementationOnce(async () => {
controller.abort(createAgentRunRestartAbortError());
return {
payloads: [{ text: "stale result" }],
meta: { durationMs: 1 },
} satisfies EmbeddedAgentRunResult;
expect(lifecycleEndEvent?.data).toMatchObject({
phase: "end",
startedAt: 1_000,
yielded: true,
livenessState: "paused",
stopReason: "end_turn",
});
await expect(
runCliAgentWithLifecycle({
runId: "run-restart",
provider: "claude-cli",
runParams: {
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "hello",
provider: "claude-cli",
model: "claude",
thinkLevel: "off",
timeoutMs: 1_000,
runId: "run-restart",
abortSignal: controller.signal,
},
}),
).rejects.toThrow("agent run aborted for restart");
const terminal = cliDispatchMocks.emitAgentEvent.mock.calls
.map(([event]) => event as { stream?: string; data?: Record<string, unknown> })
.find((event) => event.stream === "lifecycle" && event.data?.phase === "error");
expect(terminal?.data).toMatchObject({
aborted: true,
stopReason: "restart",
});
expect(
cliDispatchMocks.emitAgentEvent.mock.calls.some(
([event]) => (event as { stream?: string }).stream === "assistant",
),
).toBe(false);
});
});
@@ -10,18 +10,9 @@ import { clearCliSession } from "../../agents/cli-session.js";
import { extractToolResultText } from "../../agents/embedded-agent-subscribe.tools.js";
import { inferToolMetaFromArgs } from "../../agents/embedded-agent-utils.js";
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent.js";
import {
isAgentRunRestartAbortReason,
resolveAgentRunAbortLifecycleFields,
} from "../../agents/run-termination.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import type { AgentEventPayload } from "../../infra/agent-events.js";
import {
emitAgentEvent,
onAgentEvent,
withAgentRunLifecycleGeneration,
} from "../../infra/agent-events.js";
import { emitAgentEvent, onAgentEvent } from "../../infra/agent-events.js";
import { formatToolAggregate } from "../tool-meta.js";
function isClaudeCliProvider(provider: string): boolean {
@@ -190,13 +181,9 @@ export async function clearDroppedCliSessionBinding(params: {
if (!params.storePath || !params.sessionKey) {
return;
}
await updateSessionEntry(
{ storePath: params.storePath, sessionKey: params.sessionKey },
(entry) => {
clearEntry(entry);
return entry;
},
);
await updateSessionStore(params.storePath, (store) => {
clearEntry(store[params.sessionKey!]);
});
}
function createToolEventBridge(params: {
@@ -301,9 +288,8 @@ function createCommentaryEventBridge(params: {
});
}
type RunCliAgentWithLifecycleParams = {
export async function runCliAgentWithLifecycle(params: {
runId: string;
lifecycleGeneration?: string;
provider: string;
runParams: RunCliAgentParams;
startedAt?: number;
@@ -317,22 +303,7 @@ type RunCliAgentWithLifecycleParams = {
onCommentaryText?: (payload: { text: string; itemId?: string }) => Promise<void>;
onErrorBeforeLifecycle?: (err: unknown) => Promise<void>;
transformResult?: (result: EmbeddedAgentRunResult) => EmbeddedAgentRunResult;
};
export function runCliAgentWithLifecycle(
params: RunCliAgentWithLifecycleParams,
): Promise<EmbeddedAgentRunResult> {
if (!params.lifecycleGeneration) {
return runCliAgentWithLifecycleInternal(params);
}
return withAgentRunLifecycleGeneration(params.lifecycleGeneration, () =>
runCliAgentWithLifecycleInternal(params),
);
}
async function runCliAgentWithLifecycleInternal(
params: RunCliAgentWithLifecycleParams,
): Promise<EmbeddedAgentRunResult> {
}): Promise<EmbeddedAgentRunResult> {
const startedAt = params.startedAt ?? Date.now();
const emitLifecycleStart = params.emitLifecycleStart ?? true;
const emitLifecycleTerminal = params.emitLifecycleTerminal ?? true;
@@ -340,9 +311,6 @@ async function runCliAgentWithLifecycleInternal(
if (emitLifecycleStart) {
emitAgentEvent({
runId: params.runId,
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
stream: "lifecycle",
data: {
phase: "start",
@@ -381,10 +349,6 @@ async function runCliAgentWithLifecycleInternal(
...params.runParams,
emitCommentaryText: Boolean(params.onCommentaryText),
});
const restartAbortReason = params.runParams.abortSignal?.reason;
if (isAgentRunRestartAbortReason(restartAbortReason)) {
throw restartAbortReason;
}
const result = params.transformResult?.(rawResult) ?? rawResult;
await stopAgentEventBridges(bridges);
@@ -398,17 +362,22 @@ async function runCliAgentWithLifecycleInternal(
}
if (emitLifecycleTerminal) {
const yieldedLifecycleData =
result.meta.yielded === true
? {
yielded: true,
livenessState: result.meta.livenessState,
stopReason: result.meta.stopReason,
}
: {};
emitAgentEvent({
runId: params.runId,
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
...yieldedLifecycleData,
},
});
lifecycleTerminalEmitted = true;
@@ -420,16 +389,12 @@ async function runCliAgentWithLifecycleInternal(
if (emitLifecycleTerminal) {
emitAgentEvent({
runId: params.runId,
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: String(err),
...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
},
});
lifecycleTerminalEmitted = true;
@@ -442,16 +407,12 @@ async function runCliAgentWithLifecycleInternal(
if (emitLifecycleTerminal && !lifecycleTerminalEmitted) {
emitAgentEvent({
runId: params.runId,
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: "CLI run completed without lifecycle terminal event",
...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
},
});
}