fix: 暴露 turn 活性与 steer 投递语义

This commit is contained in:
Codex
2026-06-09 23:56:39 +08:00
parent 63ae9e2543
commit 0247e42d70
7 changed files with 251 additions and 6 deletions
+167
View File
@@ -41,6 +41,8 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
const failureMessage = command ? messageFromEvents(scopedEvents) : run.failureMessage ?? messageFromEvents(scopedEvents);
const reply = assistantReply(scopedEvents);
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: failureMessage } : null;
const liveness = livenessSnapshot(run, command, events, scopedEvents, terminal);
const steerDelivery = command?.type === "steer" ? steerDeliverySummary(events, command.id) : null;
return {
runId: run.id,
commandId: command?.id ?? commandId ?? null,
@@ -71,6 +73,8 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
failureKind,
failureMessage,
blocker,
liveness,
...(steerDelivery ? { steerDelivery } : {}),
lastSeq: events.at(-1)?.seq ?? 0,
eventCount: events.length,
eventsCapped: eventPage.capped,
@@ -85,6 +89,169 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
};
}
function livenessSnapshot(run: RunRecord, command: CommandRecord | null, events: RunEvent[], scopedEvents: RunEvent[], terminal: TerminalStatus | null): JsonRecord {
const nowMs = Date.now();
const active = terminal === null && !runIsTerminal(run) && !commandIsTerminal(command);
const lastEvent = events.at(-1) ?? null;
const lastVisibleActivity = latestVisibleActivity(scopedEvents);
const lastCommandActivity = lastVisibleActivity ?? latestLivenessActivity(scopedEvents);
const lease = leaseSummary(run, nowMs);
const transportDisconnect = latestTransportDisconnect(scopedEvents);
const phase = livenessPhase({ active, command, lastVisibleActivity, leaseExpired: lease.leaseExpired, transportDisconnect });
return {
phase,
active,
observedAt: new Date(nowMs).toISOString(),
runStatus: run.status,
commandId: command?.id ?? null,
commandType: command?.type ?? null,
commandState: command?.state ?? null,
lastSeq: lastEvent?.seq ?? 0,
lastEventAt: lastEvent?.createdAt ?? null,
lastEventAgeMs: lastEvent ? ageMs(lastEvent.createdAt, nowMs) : null,
lastCommandActivity: livenessActivitySummary(lastCommandActivity, nowMs),
lease,
transportDisconnect: transportDisconnect ? livenessActivitySummary(transportDisconnect, nowMs) : null,
recoveryActions: active ? recoveryActions(run, command, lastEvent?.seq ?? 0) : [],
valuesPrinted: false,
};
}
function livenessPhase(input: { active: boolean; command: CommandRecord | null; lastVisibleActivity: RunEvent | null; leaseExpired: boolean | null; transportDisconnect: RunEvent | null }): string {
if (!input.active) return "terminal";
if (input.command?.state === "pending") return "waiting-runner";
if (input.leaseExpired === true) return "runner-heartbeat-stale";
if (input.transportDisconnect) return "transport-disconnected";
if (input.lastVisibleActivity?.type === "tool_call") {
const status = input.lastVisibleActivity.payload.status;
if (status === "inProgress" || status === "running") return "waiting-tool";
if (status === "completed") return "idle-after-tool";
}
return "waiting-model";
}
function leaseSummary(run: RunRecord, nowMs: number): JsonRecord & { leaseExpired: boolean | null } {
const leaseExpiresMs = run.leaseExpiresAt ? Date.parse(run.leaseExpiresAt) : NaN;
const hasLease = Boolean(run.claimedBy && run.leaseExpiresAt && Number.isFinite(leaseExpiresMs));
const leaseExpired = run.claimedBy ? (hasLease ? leaseExpiresMs <= nowMs : true) : null;
return {
claimedBy: run.claimedBy,
leaseExpiresAt: run.leaseExpiresAt,
leaseExpired,
leaseRemainingMs: hasLease ? Math.max(0, leaseExpiresMs - nowMs) : null,
valuesPrinted: false,
};
}
function latestLivenessActivity(events: RunEvent[]): RunEvent | null {
return [...events].reverse().find(isLivenessActivityEvent) ?? null;
}
function latestVisibleActivity(events: RunEvent[]): RunEvent | null {
return [...events].reverse().find((event) => event.type === "assistant_message" || event.type === "tool_call" || event.type === "command_output" || event.type === "diff" || event.type === "error" || event.type === "terminal_status") ?? null;
}
function isLivenessActivityEvent(event: RunEvent): boolean {
if (event.type === "assistant_message" || event.type === "tool_call" || event.type === "command_output" || event.type === "diff" || event.type === "error" || event.type === "terminal_status") return true;
if (event.type !== "backend_status") return false;
const phase = typeof event.payload.phase === "string" ? event.payload.phase : "";
return ["backend-turn-started", "thread/start:completed", "thread/resume:completed", "turn/start:completed", "backend-turn-finished", "codex-app-server-closed"].includes(phase);
}
function latestTransportDisconnect(events: RunEvent[]): RunEvent | null {
return [...events].reverse().find((event) => {
const phase = typeof event.payload.phase === "string" ? event.payload.phase : "";
return (event.type === "error" && phase.includes("transport")) || (event.type === "backend_status" && phase === "codex-app-server-closed");
}) ?? null;
}
function livenessActivitySummary(event: RunEvent | null, nowMs: number): JsonRecord | null {
if (!event) return null;
return {
seq: event.seq,
type: event.type,
phase: typeof event.payload.phase === "string" ? event.payload.phase : null,
status: typeof event.payload.status === "string" ? event.payload.status : null,
toolName: typeof event.payload.toolName === "string" ? event.payload.toolName : null,
exitCode: normalizedExitCode(event.payload.exitCode),
commandId: typeof event.payload.commandId === "string" ? event.payload.commandId : null,
createdAt: event.createdAt,
ageMs: ageMs(event.createdAt, nowMs),
valuesPrinted: false,
};
}
function ageMs(value: string, nowMs: number): number | null {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? Math.max(0, nowMs - parsed) : null;
}
function recoveryActions(run: RunRecord, command: CommandRecord | null, afterSeq: number): JsonRecord[] {
const actions: JsonRecord[] = [
{ action: "poll-trace", runId: run.id, afterSeq },
{ action: "poll-output", runId: run.id, afterSeq },
];
if (command) actions.push({ action: "cancel-command", runId: run.id, commandId: command.id });
else actions.push({ action: "cancel-run", runId: run.id });
return actions;
}
function steerDeliverySummary(events: RunEvent[], commandId: string): JsonRecord {
const related = events.filter((event) => event.payload.commandId === commandId);
const completed = latestPhaseEvent(related, "turn/steer:completed");
const acknowledged = latestPhaseEvent(related, "steer-command-acknowledged");
const failed = [...related].reverse().find((event) => event.type === "error") ?? null;
const deliverySeq = completed?.seq ?? failed?.seq ?? acknowledged?.seq ?? related.at(-1)?.seq ?? null;
const targetCommandId = targetCommandIdFromEvents(related);
const targetFollowUp = targetCommandId && deliverySeq !== null ? targetFollowUpAfterSeq(events, targetCommandId, deliverySeq) : null;
return {
commandId,
targetCommandId,
deliveryState: failed ? "failed" : completed ? "forwarded-to-backend" : acknowledged ? "acknowledged-by-runner" : "not-observed",
backendAccepted: Boolean(completed),
targetFollowUpObserved: targetFollowUp?.observed ?? null,
targetFollowUpSeq: targetFollowUp?.seq ?? null,
targetFollowUpType: targetFollowUp?.type ?? null,
targetFollowUpPhase: targetFollowUp?.phase ?? null,
semantics: "steer completion means the backend accepted the turn/steer RPC; target turn progress is reported by the target command liveness and is not implied by the steer command terminal state",
valuesPrinted: false,
};
}
function latestPhaseEvent(events: RunEvent[], phase: string): RunEvent | null {
return [...events].reverse().find((event) => event.payload.phase === phase) ?? null;
}
function targetCommandIdFromEvents(events: RunEvent[]): string | null {
for (const event of [...events].reverse()) {
const targetCommandId = event.payload.targetCommandId;
if (typeof targetCommandId === "string" && targetCommandId.length > 0) return targetCommandId;
}
return null;
}
function targetFollowUpAfterSeq(events: RunEvent[], targetCommandId: string, afterSeq: number): JsonRecord {
const event = events.find((item) => item.seq > afterSeq && item.payload.commandId === targetCommandId && isTargetFollowUpEvent(item));
return {
observed: Boolean(event),
seq: event?.seq ?? null,
type: event?.type ?? null,
phase: typeof event?.payload.phase === "string" ? event.payload.phase : null,
};
}
function isTargetFollowUpEvent(event: RunEvent): boolean {
return event.type === "assistant_message" || event.type === "tool_call" || event.type === "command_output" || event.type === "diff" || event.type === "error" || event.type === "terminal_status";
}
function runIsTerminal(run: RunRecord): boolean {
return run.status === "completed" || run.status === "failed" || run.status === "blocked" || run.status === "cancelled";
}
function commandIsTerminal(command: CommandRecord | null): boolean {
return command ? terminalFromCommand(command) !== null : false;
}
async function listResultEvents(store: AgentRunStore, runId: string): Promise<ResultEventPage> {
const events: RunEvent[] = [];
let afterSeq = 0;
+35 -1
View File
@@ -120,7 +120,41 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
return await store.listSessions(input) as unknown as JsonValue;
}
const sessionMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)$/u);
if (method === "GET" && sessionMatch) return await store.getSessionSummary(sessionMatch[1] ?? "", url.searchParams.get("readerId")) as unknown as JsonValue;
if (method === "GET" && sessionMatch) {
const summary = await store.getSessionSummary(sessionMatch[1] ?? "", url.searchParams.get("readerId"));
const runId = summary.activeRunId ?? summary.lastRunId;
if (!runId) return summary as unknown as JsonValue;
const commandId = summary.activeCommandId ?? summary.lastCommandId ?? undefined;
try {
const result = await buildRunResult(store, runId, commandId);
return {
...summary,
liveness: result.liveness ?? null,
supervisor: {
runId: result.runId,
commandId: result.commandId,
status: result.status,
terminalStatus: result.terminalStatus,
lastSeq: result.lastSeq,
liveness: result.liveness ?? null,
recoveryActions: typeof result.liveness === "object" && result.liveness !== null && !Array.isArray(result.liveness) ? (result.liveness as JsonRecord).recoveryActions ?? [] : [],
...(result.steerDelivery ? { steerDelivery: result.steerDelivery } : {}),
valuesPrinted: false,
},
} as unknown as JsonValue;
} catch (error) {
return {
...summary,
liveness: {
phase: "unavailable",
active: summary.active,
failureKind: "infra-failed",
message: error instanceof Error ? error.message : String(error),
valuesPrinted: false,
},
} as unknown as JsonValue;
}
}
const sessionTraceMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/trace$/u);
if (method === "GET" && sessionTraceMatch) {
const input: SessionEventPageInput = { afterSeq: integerQuery(url, "afterSeq", 0), limit: integerQuery(url, "limit", 100) };
+2 -2
View File
@@ -264,10 +264,10 @@ async function handleSteerCommand(api: RunnerManagerApi, runId: string, command:
await reportNonTerminalCommandFailure(api, runId, command.id, attemptId, runnerId, { terminalStatus: "blocked", failureKind: "schema-invalid", message: "steer command payload requires a non-empty prompt, message, or text" }, "runner:steer:payload", control);
return;
}
await appendBestEffort(api, runId, { type: "backend_status", payload: { phase: "steer-command-acknowledged", commandId: command.id, commandType: "steer", targetCommandId, attemptId, runnerId, threadId: control.threadId, turnId: control.turnId } });
await appendBestEffort(api, runId, { type: "backend_status", payload: { phase: "steer-command-acknowledged", commandId: command.id, commandType: "steer", targetCommandId, deliveryState: "acknowledged-by-runner", backendAccepted: false, targetEffect: "not-yet-observed", attemptId, runnerId, threadId: control.threadId, turnId: control.turnId } });
try {
await control.steer(prompt);
await appendBestEffort(api, runId, { type: "backend_status", payload: { phase: "turn/steer:completed", commandId: command.id, commandType: "steer", targetCommandId, attemptId, runnerId, threadId: control.threadId, turnId: control.turnId } });
await appendBestEffort(api, runId, { type: "backend_status", payload: { phase: "turn/steer:completed", commandId: command.id, commandType: "steer", targetCommandId, deliveryState: "forwarded-to-backend", backendAccepted: true, targetEffect: "not-guaranteed", semantics: "turn/steer RPC returned; target turn liveness must be read from the target command result", attemptId, runnerId, threadId: control.threadId, turnId: control.turnId } });
await api.reportCommandStatus(command.id, { terminalStatus: "completed", failureKind: null, failureMessage: null, threadId: control.threadId, turnId: control.turnId });
} catch (error) {
const failureKind = failureKindFromError(error);
+33 -2
View File
@@ -204,10 +204,31 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.match(String(steerTurnEnvelope.reply), /steered:STEER_MARK_SELFTEST/u);
const steerCommandEnvelope = await client.get(`/api/v1/runs/${steerRun.runId}/commands/${steerCommand.id}/result`) as JsonRecord;
assert.equal(steerCommandEnvelope.terminalStatus, "completed");
const steerDelivery = steerCommandEnvelope.steerDelivery as JsonRecord;
assert.equal(steerDelivery.deliveryState, "forwarded-to-backend");
assert.equal(steerDelivery.backendAccepted, true);
assert.equal(steerDelivery.targetCommandId, steerRun.commandId);
assert.match(String(steerDelivery.semantics), /target command liveness/u);
const steerEventsResponse = await client.get(`/api/v1/runs/${steerRun.runId}/events?afterSeq=0&limit=200`) as { items?: Array<{ type?: string; payload?: JsonRecord }> };
const steerEvents = steerEventsResponse.items ?? [];
assert.ok(steerEvents.some((event) => event.type === "backend_status" && event.payload?.phase === "steer-command-acknowledged" && event.payload?.commandId === steerCommand.id && event.payload?.targetCommandId === steerRun.commandId));
assert.ok(steerEvents.some((event) => event.type === "backend_status" && event.payload?.phase === "turn/steer:completed" && event.payload?.commandId === steerCommand.id && event.payload?.targetCommandId === steerRun.commandId));
assert.ok(steerEvents.some((event) => event.type === "backend_status" && event.payload?.phase === "turn/steer:completed" && event.payload?.commandId === steerCommand.id && event.payload?.targetCommandId === steerRun.commandId && event.payload.deliveryState === "forwarded-to-backend" && event.payload.targetEffect === "not-guaranteed"));
const idleAfterTool = await createHwlabRun(client, context, bundle, "hwlab-session-idle-after-tool", "complete a tool and then stay active", "hwlab-command-idle-after-tool", 10_000);
const idleAfterToolRunner = runOnce({ managerUrl: server.baseUrl, runId: idleAfterTool.runId, commandId: idleAfterTool.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "tool-completes-without-terminal", AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-idle-after-tool") }, oneShot: true, pollIntervalMs: 50 });
await waitForCommandState(client, idleAfterTool.runId, idleAfterTool.commandId, "acknowledged");
await waitForEvent(client, idleAfterTool.runId, (event) => event.type === "tool_call" && (event.payload as JsonRecord).status === "completed", "tool_call completed without terminal");
const idleEnvelope = await client.get(`/api/v1/runs/${idleAfterTool.runId}/commands/${idleAfterTool.commandId}/result`) as JsonRecord;
const idleLiveness = idleEnvelope.liveness as JsonRecord;
assert.equal(idleLiveness.phase, "idle-after-tool");
assert.equal(idleLiveness.active, true);
assert.equal(((idleLiveness.lastCommandActivity as JsonRecord).type), "tool_call");
const idleSession = await client.get("/api/v1/sessions/hwlab-session-idle-after-tool?readerId=cli") as JsonRecord;
assert.equal(((idleSession.liveness as JsonRecord).phase), "idle-after-tool");
assert.ok(Array.isArray(((idleSession.supervisor as JsonRecord).recoveryActions)), "session show must expose supervisor recovery actions");
await client.post(`/api/v1/commands/${idleAfterTool.commandId}/cancel`, { reason: "self-test idle-after-tool cleanup" });
const idleAfterToolResult = await idleAfterToolRunner as JsonRecord;
assert.equal(idleAfterToolResult.terminalStatus, "cancelled");
const runningCancel = await createHwlabRun(client, context, bundle, "hwlab-session-cancel-running", "cancel running", "hwlab-command-cancel-running", 10_000);
const running = runOnce({ managerUrl: server.baseUrl, runId: runningCancel.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "missing-terminal", AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-running-cancel") }, oneShot: true });
@@ -216,7 +237,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const runningResult = await running;
assert.equal(runningResult.terminalStatus, "cancelled");
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "idle-after-tool-liveness", "running-cancel"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -321,6 +342,16 @@ async function waitForCommandState(client: ManagerClient, runId: string, command
throw new Error(`command ${commandId} did not reach ${state}`);
}
async function waitForEvent(client: ManagerClient, runId: string, predicate: (event: { type?: string; payload?: JsonRecord }) => boolean, label: string): Promise<void> {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const events = await client.get(`/api/v1/runs/${runId}/events?afterSeq=0&limit=200`) as { items?: Array<{ type?: string; payload?: JsonRecord }> };
if ((events.items ?? []).some(predicate)) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`timed out waiting for ${label}`);
}
async function commandCreatedAt(client: ManagerClient, runId: string, commandId: string): Promise<string> {
const command = await client.get(`/api/v1/runs/${runId}/commands/${commandId}`) as { createdAt?: string };
return command.createdAt ?? "";
+9
View File
@@ -93,6 +93,15 @@ for await (const line of rl) {
respond(message.id, { turn });
continue;
}
if (mode === "tool-completes-without-terminal") {
turnCounter += 1;
const turn = { id: `turn_selftest_${turnCounter}`, status: "running" };
notify("turn/started", { turn });
notify("item/started", { item: { id: "tool_idle_after_tool", type: "commandExecution", command: "echo idle-after-tool" } });
notify("item/completed", { item: { id: "tool_idle_after_tool", type: "commandExecution", command: "echo idle-after-tool", status: "completed", exitCode: 0 } });
respond(message.id, { turn });
continue;
}
if (mode === "slow-progress-before-terminal") {
turnCounter += 1;
const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" };