diff --git a/docs/reference/spec-v01-agentrun-mgr.md b/docs/reference/spec-v01-agentrun-mgr.md index cfdfe95..76b1c63 100644 --- a/docs/reference/spec-v01-agentrun-mgr.md +++ b/docs/reference/spec-v01-agentrun-mgr.md @@ -167,9 +167,13 @@ Manager 只承接 HWLAB v0.2 Code Agent 的通用执行事实,不承接 HWLAB | `runId` / `commandId` / `attemptId` | 支持调用方持久关联和问题定位。 | | `artifactSummary` | 第一阶段只放有界摘要、字节数、截断标记和必要引用;不内嵌大 stdout/stderr。 | | `toolCallSummary` | 输出有界、脱敏的 tool call 状态摘要,至少包含 `count`、`statusCounts`、`exitCodeCounts` 和最近若干条 `items` 的 `method/toolName/type/status/exitCode/command`。消费侧必须用它区分 AgentRun command terminal、agent 内部工具执行和后置诊断,不得用单一 `hwpodExitCode` 覆盖 AgentRun 成功终态。 | +| `liveness` | 查询时派生的 supervisor 活性快照,不写入 durable event。必须暴露 `phase`、`active`、`lastSeq`、`lastEventAgeMs`、`lastCommandActivity`、lease/heartbeat 摘要和可执行恢复动作。`phase` 至少区分 `waiting-runner`、`waiting-model`、`waiting-tool`、`idle-after-tool`、`transport-disconnected`、`runner-heartbeat-stale` 和 `terminal`,避免调用方只能用外层超时猜测 backend 状态。 | +| `steerDelivery` | 仅在查询 `type=steer` command result 时出现。必须说明 steer 是否已被 runner ack、是否已转发并被 backend `turn/steer` RPC 接受、目标 `targetCommandId`、是否观察到 target command 后续事件,以及“steer command completed 不等于 target turn 已产生后续 assistant/tool 输出”的语义。 | `assistant_message` partial、`command_output` 存在、stdout 非空、backend transport close 或 idle timeout 都不能单独让 result 进入 `completed`。 +`GET /api/v1/sessions/:sessionId` 作为 session status 入口,必须在存在 active/last run 时透出同一套 `liveness` 和 `supervisor` 摘要;该摘要是观测辅助,不能替代 command terminal、run terminal 或 raw events 的事实来源。 + 当 `commandId` 已指定,result envelope 必须只聚合该 command 的 assistant/output/error/terminal 事件;同一 run 的其他 command reply 不能串入当前 command result。未指定 `commandId` 时可默认选择最新 command。 长 trace / steer 场景的验收标准是:raw events 已有 terminal seq 时,`commands result` 的 `lastSeq` 与 `eventCount` 必须覆盖同一终态事件范围,`finalAssistantSeq` 必须指向 terminal 前最后一条可用 assistant 或 authoritative final,且 silent first-page truncation 一律视为 result 合同失败。 diff --git a/docs/reference/spec-v01-agentrun-runner.md b/docs/reference/spec-v01-agentrun-runner.md index 4a07cd5..f090660 100644 --- a/docs/reference/spec-v01-agentrun-runner.md +++ b/docs/reference/spec-v01-agentrun-runner.md @@ -122,7 +122,7 @@ claimed -> lease_lost - lease heartbeat 必须通过 manager lease/status 可观察;不得把周期性心跳或 backend running tick 写成 durable trace event 刷屏。长 turn 只在 `backend-turn-finished` 中输出有界 progress 摘要;过期或冲突时写入 failure event 或明确退出原因。 - replacement runner 在 claim 时遇到 `runner-lease-conflict`,且 manager 响应显示旧 lease 未过期但可能来自已删除 pod 或失联 runner 时,不得立即把同一 HWLAB session 判为失败;应在 `AGENTRUN_RUNNER_CLAIM_RETRY_TIMEOUT_MS` 窗口内按 `AGENTRUN_RUNNER_CLAIM_RETRY_INTERVAL_MS` 等待旧 lease 过期并重试 claim。等待期间必须写入或输出 `runner-claim-waiting-for-stale-lease`,成功接管后写入 `runner-claim-recovered`;超过窗口才把 `runner-lease-conflict` 作为 terminal failureKind。并发双 runner 的正常冲突仍必须拒绝,不能抢占未 stale 的 active lease。 - command 只能从 manager poll;不得从本地文件或临时参数伪造正式 command。 -- runner 的普通 poll 只选择 pending `turn`;当 backend adapter 暴露 active turn control 后,runner 才在同 run 内轮询 pending `steer` command,ack 后调用 backend 的 steer 能力并单独终结该 steer command。active turn 结束后到达的 steer 必须结构化 blocked,不得启动新 turn,也不得把 run 标为 terminal。 +- runner 的普通 poll 只选择 pending `turn`;当 backend adapter 暴露 active turn control 后,runner 才在同 run 内轮询 pending `steer` command,ack 后调用 backend 的 steer 能力并单独终结该 steer command。`steer` completed 只表示 `turn/steer` RPC 已被 backend 接受,不表示 target turn 已产生后续 assistant/tool 事件;runner 事件必须携带 target command 和 delivery 语义,target 活性由 command result/session status 的 `liveness` 判断。active turn 结束后到达的 steer 必须结构化 blocked,不得启动新 turn,也不得把 run 标为 terminal。 - backend 产生的所有可见输出必须先经过 adapter normalization 和 redaction,再 append 到 manager;backend_status 至少包含 redacted profile/backendKind/protocol 摘要。 - 单个 command terminal 上报后 runner 不应立即退出,而应继续 poll 同一 run 的 pending command,直到 idle timeout、lease 冲突或 run terminal。退出码与 runner loop 终态必须一致或在日志中可解释。 diff --git a/src/mgr/result.ts b/src/mgr/result.ts index 1525f15..6d4a343 100644 --- a/src/mgr/result.ts +++ b/src/mgr/result.ts @@ -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 { const events: RunEvent[] = []; let afterSeq = 0; diff --git a/src/mgr/server.ts b/src/mgr/server.ts index f6f975c..bd633db 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -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) }; diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index 97ba845..c9b5303 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -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); diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index c99f639..5723b99 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -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((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 { + 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 { const command = await client.get(`/api/v1/runs/${runId}/commands/${commandId}`) as { createdAt?: string }; return command.createdAt ?? ""; diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index 8f4205a..5c82798 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -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" };