fix: 暴露 turn 活性与 steer 投递语义
This commit is contained in:
@@ -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 合同失败。
|
||||
|
||||
@@ -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 终态必须一致或在日志中可解释。
|
||||
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user