diff --git a/src/acp/control-plane/manager.background-task.test.ts b/src/acp/control-plane/manager.background-task.test.ts index 7731163f7413..0f1c5e8ca9c7 100644 --- a/src/acp/control-plane/manager.background-task.test.ts +++ b/src/acp/control-plane/manager.background-task.test.ts @@ -1,10 +1,13 @@ /** Regression coverage for ACP background-task summary truncation boundaries. */ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { AcpRuntimeError } from "../runtime/errors.js"; import { appendBackgroundTaskProgressSummary, resolveBackgroundTaskContext, + resolveBackgroundTaskFailureStatus, } from "./manager.background-task.js"; +import { ACP_TURN_TIMEOUT_DETAIL_CODE } from "./manager.turn-timeout.js"; import type { AcpSessionManagerDeps } from "./manager.types.js"; // U+1F99E (🦞) is a surrogate pair in UTF-16; a raw .slice() boundary can split it. @@ -65,3 +68,20 @@ describe("resolveBackgroundTaskContext", () => { expect(context?.task).toBe(`summarize ${LOBSTER} feedback`); }); }); + +describe("resolveBackgroundTaskFailureStatus", () => { + it("uses the structured timeout detail instead of message text", () => { + expect( + resolveBackgroundTaskFailureStatus( + new AcpRuntimeError("ACP_TURN_FAILED", "turn deadline reached", { + detailCode: ACP_TURN_TIMEOUT_DETAIL_CODE, + }), + ), + ).toBe("timed_out"); + expect( + resolveBackgroundTaskFailureStatus( + new AcpRuntimeError("ACP_TURN_FAILED", "backend said the request timed out"), + ), + ).toBe("failed"); + }); +}); diff --git a/src/acp/control-plane/manager.background-task.ts b/src/acp/control-plane/manager.background-task.ts index e11579ead44f..5eda1ff771ac 100644 --- a/src/acp/control-plane/manager.background-task.ts +++ b/src/acp/control-plane/manager.background-task.ts @@ -11,6 +11,7 @@ import { import { resolveRequiredCompletionTerminalResult } from "../../tasks/task-completion-contract.js"; import { deliveryContextFromSession, type DeliveryContext } from "../../utils/delivery-context.js"; import { AcpRuntimeError } from "../runtime/errors.js"; +import { ACP_TURN_TIMEOUT_DETAIL_CODE } from "./manager.turn-timeout.js"; import type { AcpSessionManagerDeps } from "./manager.types.js"; import { normalizeText } from "./runtime-options.js"; @@ -55,7 +56,7 @@ export function appendBackgroundTaskProgressSummary(current: string, chunk: stri /** Maps ACP runtime failures to detached-task terminal states. */ export function resolveBackgroundTaskFailureStatus(error: AcpRuntimeError): "failed" | "timed_out" { - return /\btimed out\b/i.test(error.message) ? "timed_out" : "failed"; + return error.detailCode === ACP_TURN_TIMEOUT_DETAIL_CODE ? "timed_out" : "failed"; } /** Infers blocked terminal outcomes from final progress text when the child turn reports one. */ diff --git a/src/acp/control-plane/manager.turn-runner.ts b/src/acp/control-plane/manager.turn-runner.ts index 9552a3b82412..3f7461a2cbf9 100644 --- a/src/acp/control-plane/manager.turn-runner.ts +++ b/src/acp/control-plane/manager.turn-runner.ts @@ -121,6 +121,7 @@ export async function runManagerTurn(params: { ? new AcpRuntimeError( error.code, `All ACP backends failed (${backendAttempts.length}): ${failedBackends}`, + { detailCode: error.detailCode }, ) : error; params.recordTurnCompletion({ diff --git a/src/agents/agent-run-terminal-error.ts b/src/agents/agent-run-terminal-error.ts new file mode 100644 index 000000000000..43877619615b --- /dev/null +++ b/src/agents/agent-run-terminal-error.ts @@ -0,0 +1,27 @@ +/** Carries and discovers canonical terminal outcomes through thrown-error boundaries. */ +import type { AgentRunTerminalOutcome } from "./agent-run-terminal-outcome.js"; + +/** Carries a canonical terminal outcome when an embedded attempt exits by throwing. */ +export class AgentRunTerminalOutcomeError extends Error { + readonly terminalOutcome: AgentRunTerminalOutcome; + + constructor(error: unknown, terminalOutcome: AgentRunTerminalOutcome) { + super(error instanceof Error ? error.message : String(error), { cause: error }); + this.name = "AgentRunTerminalOutcomeError"; + this.terminalOutcome = terminalOutcome; + } +} + +/** Finds a canonical terminal outcome through ordinary error wrapper boundaries. */ +export function findAgentRunTerminalOutcome(error: unknown): AgentRunTerminalOutcome | undefined { + let candidate = error; + const seen = new Set(); + while (candidate && typeof candidate === "object" && !seen.has(candidate)) { + seen.add(candidate); + if (candidate instanceof AgentRunTerminalOutcomeError) { + return candidate.terminalOutcome; + } + candidate = (candidate as { cause?: unknown }).cause; + } + return undefined; +} diff --git a/src/agents/agent-run-terminal-outcome.test.ts b/src/agents/agent-run-terminal-outcome.test.ts index e70ee591484d..be918fb8f57b 100644 --- a/src/agents/agent-run-terminal-outcome.test.ts +++ b/src/agents/agent-run-terminal-outcome.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { buildAgentRunTerminalOutcome, + buildAgentRunTerminalOutcomeFromLifecycleEvent, + classifyAgentRunTerminalOutcome, mergeAgentRunAttemptTerminal, mergeAgentRunTerminalOutcome, normalizeAgentRunAttemptTerminal, @@ -11,6 +13,46 @@ import { } from "./agent-run-terminal-outcome.js"; describe("agent run terminal outcome", () => { + it.each([ + ["completed", "success"], + ["hard_timeout", "timeout"], + ["timed_out", "timeout"], + ["cancelled", "cancellation"], + ["aborted", "cancellation"], + ["blocked", "failure"], + ["abandoned", "failure"], + ["failed", "failure"], + ] as const)("classifies %s as %s", (reason, classification) => { + expect(classifyAgentRunTerminalOutcome({ reason })).toBe(classification); + }); + + it("normalizes lifecycle signals with timeout, cancellation, failure precedence", () => { + expect( + buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: "end", + data: { aborted: true }, + }), + ).toMatchObject({ reason: "aborted", status: "error", stopReason: "aborted" }); + expect( + buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: "end", + data: { aborted: true, stopReason: "timeout", timeoutPhase: "provider" }, + }), + ).toMatchObject({ reason: "hard_timeout", status: "timeout" }); + expect( + buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: "error", + data: { error: "provider failed" }, + }), + ).toMatchObject({ reason: "failed", status: "error", error: "provider failed" }); + expect( + buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: "end", + data: { status: "cancelled", stopReason: "relay-closed" }, + }), + ).toMatchObject({ reason: "cancelled", status: "error", stopReason: "relay-closed" }); + }); + it("treats provider/preflight/post-turn timeout phases as hard run timeouts", () => { expect( ["preflight", "provider", "post_turn", "queue", "gateway_draining"].map( diff --git a/src/agents/agent-run-terminal-outcome.ts b/src/agents/agent-run-terminal-outcome.ts index efbc66fa2f3a..5f5450bc7424 100644 --- a/src/agents/agent-run-terminal-outcome.ts +++ b/src/agents/agent-run-terminal-outcome.ts @@ -402,16 +402,19 @@ export function projectAgentRunAttemptTerminal(terminal: AgentRunAttemptTerminal }; } +const AGENT_RUN_TERMINAL_CLASSIFICATION = { + completed: "success", + hard_timeout: "timeout", + timed_out: "timeout", + cancelled: "cancellation", + aborted: "cancellation", + blocked: "failure", + abandoned: "failure", + failed: "failure", +} as const; + /** Normalized terminal reason for an agent run. */ -type AgentRunTerminalReason = - | "completed" - | "hard_timeout" - | "timed_out" - | "cancelled" - | "aborted" - | "blocked" - | "abandoned" - | "failed"; +type AgentRunTerminalReason = keyof typeof AGENT_RUN_TERMINAL_CLASSIFICATION; /** Normalized terminal outcome for an agent run. */ export type AgentRunTerminalOutcome = { @@ -426,29 +429,9 @@ export type AgentRunTerminalOutcome = { endedAt?: number; }; -/** Carries a canonical terminal outcome when an embedded attempt exits by throwing. */ -export class AgentRunTerminalOutcomeError extends Error { - readonly terminalOutcome: AgentRunTerminalOutcome; - - constructor(error: unknown, terminalOutcome: AgentRunTerminalOutcome) { - super(error instanceof Error ? error.message : String(error), { cause: error }); - this.name = "AgentRunTerminalOutcomeError"; - this.terminalOutcome = terminalOutcome; - } -} - -/** Finds a canonical terminal outcome through ordinary error wrapper boundaries. */ -export function findAgentRunTerminalOutcome(error: unknown): AgentRunTerminalOutcome | undefined { - let candidate = error; - const seen = new Set(); - while (candidate && typeof candidate === "object" && !seen.has(candidate)) { - seen.add(candidate); - if (candidate instanceof AgentRunTerminalOutcomeError) { - return candidate.terminalOutcome; - } - candidate = (candidate as { cause?: unknown }).cause; - } - return undefined; +/** Collapses terminal reasons into the four projections shared by run consumers. */ +export function classifyAgentRunTerminalOutcome(outcome: Pick) { + return AGENT_RUN_TERMINAL_CLASSIFICATION[outcome.reason]; } /** Raw terminal input collected from run wait/liveness/timeout paths. */ @@ -468,6 +451,11 @@ type AgentRunTerminalWaitInput = Omit & { status?: unknown; }; +type AgentRunLifecycleTerminalData = Omit & { + aborted?: unknown; + status?: unknown; +}; + /** Shared grace window for terminal observations that may still be followed by a retry. */ export const AGENT_RUN_TERMINAL_RETRY_GRACE_MS = 15_000; @@ -574,6 +562,64 @@ export function buildAgentRunTerminalOutcome( }; } +/** Builds the canonical outcome directly from a terminal lifecycle event. */ +export function buildAgentRunTerminalOutcomeFromLifecycleEvent(input: { + phase: "end" | "error"; + data?: AgentRunLifecycleTerminalData; + abortSignal?: AbortSignal; + startedAt?: unknown; + endedAt?: unknown; +}): AgentRunTerminalOutcome { + const data = input.data; + const abortFields = + typeof data?.aborted === "boolean" + ? {} + : resolveAgentRunAbortLifecycleFields(input.abortSignal); + const stopReason = asNonEmptyString(data?.stopReason) ?? abortFields.stopReason; + const timeoutPhase = normalizeAgentRunTimeoutPhase(data?.timeoutPhase); + const lifecycleStatus = asNonEmptyString(data?.status)?.toLowerCase(); + // Bare `aborted` is cancellation; timeout needs a structured status, stop + // reason, or phase so legacy lifecycle gaps cannot turn user stops into timeouts. + const timedOut = + stopReason === "timeout" || + timeoutPhase !== undefined || + lifecycleStatus === "timeout" || + lifecycleStatus === "timed_out"; + const aborted = + data?.aborted === true || abortFields.aborted === true || lifecycleStatus === "aborted"; + const cancellationStatus = + lifecycleStatus === "cancelled" || + lifecycleStatus === "canceled" || + lifecycleStatus === "aborted"; + const cancelled = cancellationStatus || aborted; + const failed = + input.phase === "error" || + lifecycleStatus === "error" || + lifecycleStatus === "failed" || + stopReason === "error"; + const normalizedStopReason = + !timedOut && + cancelled && + !isAbortedAgentStopReason(stopReason) && + !isCancellationStopReason(stopReason) && + (stopReason === undefined || cancellationStatus) + ? aborted + ? "aborted" + : "stop" + : stopReason; + const outcome = buildAgentRunTerminalOutcome({ + status: timedOut ? "timeout" : cancelled || failed ? "error" : "ok", + error: data?.error, + stopReason: normalizedStopReason, + livenessState: data?.livenessState, + timeoutPhase, + providerStarted: data?.providerStarted, + startedAt: input.startedAt ?? data?.startedAt, + endedAt: input.endedAt ?? data?.endedAt, + }); + return stopReason && outcome.stopReason !== stopReason ? { ...outcome, stopReason } : outcome; +} + function hasRestartAbortReason(value: unknown): boolean { let candidate = value; for (let depth = 0; depth < 3; depth += 1) { diff --git a/src/agents/command/lifecycle.ts b/src/agents/command/lifecycle.ts index 438008e2dca9..9bdee7103ac4 100644 --- a/src/agents/command/lifecycle.ts +++ b/src/agents/command/lifecycle.ts @@ -2,7 +2,7 @@ import { emitAgentEvent } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { - buildAgentRunTerminalOutcome, + buildAgentRunTerminalOutcomeFromLifecycleEvent, type AgentRunTerminalOutcome, } from "../agent-run-terminal-outcome.js"; import type { EmbeddedAgentRunEntryTerminal } from "../embedded-agent-runner/run-entry.js"; @@ -35,20 +35,7 @@ export function resolveAgentRunLifecycleEndLogLevel(meta: { timeoutPhase?: unknown; providerStarted?: unknown; }): "info" | "warn" | "error" | undefined { - const status = - meta.stopReason === "timeout" || meta.timeoutPhase - ? "timeout" - : meta.aborted === true || meta.error || meta.stopReason === "error" - ? "error" - : "ok"; - const outcome = buildAgentRunTerminalOutcome({ - status, - error: meta.error, - stopReason: meta.stopReason, - livenessState: meta.livenessState, - timeoutPhase: meta.timeoutPhase, - providerStarted: meta.providerStarted, - }); + const outcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ phase: "end", data: meta }); return resolveTerminalLogLevel(outcome); } diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index f9094f6b4388..917a44b5e695 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -6,8 +6,8 @@ import { import { resolveContextEngineOwnerPluginId } from "../../../context-engine/registry.js"; import { createBundleLspToolRuntime } from "../../agent-bundle-lsp-runtime.js"; import { materializeBundleMcpToolsForRun } from "../../agent-bundle-mcp-tools.js"; +import { AgentRunTerminalOutcomeError } from "../../agent-run-terminal-error.js"; import { - AgentRunTerminalOutcomeError, buildAgentRunTerminalOutcomeFromAttempt, mergeAgentRunAttemptTerminal, projectAgentRunAttemptTerminal, diff --git a/src/agents/model-fallback-attempt.ts b/src/agents/model-fallback-attempt.ts index 9ca48715f471..16135009e747 100644 --- a/src/agents/model-fallback-attempt.ts +++ b/src/agents/model-fallback-attempt.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isCronTerminalAbortReasonText } from "../cron/service/execution-errors.js"; import { formatErrorMessage, toErrorObject } from "../infra/errors.js"; import { isCommandLaneTaskTimeoutError } from "../process/command-queue.js"; -import { findAgentRunTerminalOutcome } from "./agent-run-terminal-outcome.js"; +import { findAgentRunTerminalOutcome } from "./agent-run-terminal-error.js"; import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-discovery.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index b9250a25b426..5d6dd54ad9d8 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -17,7 +17,7 @@ import { setCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-meta import { clearCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-state.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { GatewayDrainingError } from "../process/gateway-work-admission.js"; -import { AgentRunTerminalOutcomeError } from "./agent-run-terminal-outcome.js"; +import { AgentRunTerminalOutcomeError } from "./agent-run-terminal-error.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { testing as cliBackendsTesting } from "./cli-backends.test-support.js"; diff --git a/src/agents/session-activity-notes.test.ts b/src/agents/session-activity-notes.test.ts index 472cd70964ea..edec5532a52e 100644 --- a/src/agents/session-activity-notes.test.ts +++ b/src/agents/session-activity-notes.test.ts @@ -60,4 +60,17 @@ describe("session activity assistant buffering", () => { expect(state.assistantBuffer.endsWith("tail")).toBe(true); expect(state.assistantBufferDirty).toBe(false); }); + + it("does not report a signal-only aborted lifecycle end as done", () => { + const state = createSessionActivityNoteState(); + noteSessionActivityEvent(state, { + runId: "run-aborted", + seq: 1, + stream: "lifecycle", + ts: 1_000, + data: { phase: "end", aborted: true }, + }); + + expect(state.notes.at(-1)?.text).toBe("Run failed"); + }); }); diff --git a/src/agents/session-activity-notes.ts b/src/agents/session-activity-notes.ts index ec63ba3c29f2..d97a123884e6 100644 --- a/src/agents/session-activity-notes.ts +++ b/src/agents/session-activity-notes.ts @@ -4,7 +4,10 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import { normalizeAgentPlanSteps } from "../channels/streaming.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; import { redactToolPayloadText } from "../logging/redact.js"; -import { buildAgentRunTerminalOutcome } from "./agent-run-terminal-outcome.js"; +import { + buildAgentRunTerminalOutcomeFromLifecycleEvent, + classifyAgentRunTerminalOutcome, +} from "./agent-run-terminal-outcome.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END, @@ -329,15 +332,9 @@ export function readFiniteNumber(value: unknown): number | undefined { export function terminalHealthFor(event: AgentEventPayload): "done" | "failed" { const phase = event.data.phase; - const outcome = buildAgentRunTerminalOutcome({ - status: phase === "end" ? "ok" : "error", - error: event.data.error, - stopReason: event.data.stopReason, - livenessState: event.data.livenessState, - timeoutPhase: event.data.timeoutPhase, - providerStarted: event.data.providerStarted, - startedAt: event.data.startedAt, - endedAt: event.data.endedAt, + const outcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: phase === "end" ? "end" : "error", + data: event.data, }); - return outcome.reason === "completed" ? "done" : "failed"; + return classifyAgentRunTerminalOutcome(outcome) === "success" ? "done" : "failed"; } diff --git a/src/audit/agent-event-audit.ts b/src/audit/agent-event-audit.ts index b91e3e2fd540..6722446f6234 100644 --- a/src/audit/agent-event-audit.ts +++ b/src/audit/agent-event-audit.ts @@ -4,11 +4,11 @@ import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion" import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { AGENT_RUN_TERMINAL_RETRY_GRACE_MS, - buildAgentRunTerminalOutcome, + buildAgentRunTerminalOutcomeFromLifecycleEvent, + classifyAgentRunTerminalOutcome, mergeAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../agents/agent-run-terminal-outcome.js"; -import { normalizeAgentRunTimeoutPhase } from "../agents/run-timeout-attribution.js"; import { isAllowedToolCallName } from "../agents/tool-call-shared.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; import type { TrustedToolExecutionEvent } from "../infra/diagnostic-events.js"; @@ -122,57 +122,25 @@ function resolveToolProvenance( }; } +const AUDIT_TERMINAL_BY_CLASSIFICATION = { + success: { status: "succeeded" as const }, + timeout: { status: "timed_out" as const, errorCode: "run_timed_out" as const }, + cancellation: { status: "cancelled" as const, errorCode: "run_cancelled" as const }, + failure: { status: "failed" as const, errorCode: "run_failed" as const }, +}; + function classifyRunTerminal( data: Record, phase: "end" | "error", ): { outcome: AgentRunTerminalOutcome; } & AgentRunFinishedAuditTerminal { - const stopReason = nonEmptyString(data.stopReason); - const timeoutPhase = normalizeAgentRunTimeoutPhase(data.timeoutPhase); - const terminalStatus = normalizeOptionalLowercaseString(data.status); - const explicitlyTimedOut = - stopReason === "timeout" || - timeoutPhase !== undefined || - terminalStatus === "timeout" || - terminalStatus === "timed_out"; - const explicitlyCancelled = - !explicitlyTimedOut && - (data.aborted === true || - stopReason === "aborted" || - terminalStatus === "cancelled" || - terminalStatus === "canceled" || - terminalStatus === "aborted"); - // The terminal helper accepts wait statuses, so normalize explicit lifecycle - // cancellation to its canonical stop signal without persisting the raw reason. - const outcomeStopReason = explicitlyCancelled && !explicitlyTimedOut ? "stop" : stopReason; - const outcome = buildAgentRunTerminalOutcome({ - status: explicitlyTimedOut - ? "timeout" - : phase === "error" - ? "error" - : explicitlyCancelled - ? "error" - : "ok", - stopReason: outcomeStopReason, - livenessState: data.livenessState, - timeoutPhase, - providerStarted: data.providerStarted, - startedAt: data.startedAt, - endedAt: data.endedAt, - }); - if (outcome.reason === "cancelled" || outcome.reason === "aborted") { - return { outcome, status: "cancelled", errorCode: "run_cancelled" }; - } - if (outcome.reason === "hard_timeout" || outcome.reason === "timed_out") { - return { outcome, status: "timed_out", errorCode: "run_timed_out" }; - } + const outcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ phase, data }); if (outcome.reason === "blocked") { return { outcome, status: "blocked", errorCode: "run_blocked" }; } - return outcome.reason === "completed" - ? { outcome, status: "succeeded" } - : { outcome, status: "failed", errorCode: "run_failed" }; + const terminal = AUDIT_TERMINAL_BY_CLASSIFICATION[classifyAgentRunTerminalOutcome(outcome)]; + return { outcome, ...terminal }; } type AgentAuditProjection = { diff --git a/src/commands/agent-exec.test.ts b/src/commands/agent-exec.test.ts index ee0b94316ca6..a7d932f5ec99 100644 --- a/src/commands/agent-exec.test.ts +++ b/src/commands/agent-exec.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { AgentRunTerminalOutcomeError } from "../agents/agent-run-terminal-outcome.js"; +import { AgentRunTerminalOutcomeError } from "../agents/agent-run-terminal-error.js"; import { ensureAuthProfileStore, findPersistedAuthProfileCredential, diff --git a/src/commands/agent-exec.ts b/src/commands/agent-exec.ts index 328ebe6b1d2a..47d32e48140b 100644 --- a/src/commands/agent-exec.ts +++ b/src/commands/agent-exec.ts @@ -5,7 +5,7 @@ import os from "node:os"; import path from "node:path"; import { TextDecoder } from "node:util"; import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit"; -import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-outcome.js"; +import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-error.js"; import type { EmbeddedAgentRunMeta } from "../agents/embedded-agent.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { mergeDeep } from "../infra/deep-merge.js"; diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 9d3166ed8e7e..f7301636c4b1 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -6,7 +6,10 @@ import type { ChatRunStartupPhase, } from "../../packages/gateway-protocol/src/schema/logs-chat.js"; import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js"; -import { buildAgentRunTerminalOutcome } from "../agents/agent-run-terminal-outcome.js"; +import { + buildAgentRunTerminalOutcomeFromLifecycleEvent, + classifyAgentRunTerminalOutcome, +} from "../agents/agent-run-terminal-outcome.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isTimeoutError, resolveFailoverReasonFromError } from "../agents/failover-error.js"; import { resolveToolSearchCodeDisplayTarget } from "../agents/tool-display-common.js"; @@ -79,6 +82,13 @@ export type { ToolEventRecipientRegistry, } from "./server-chat-state.js"; +const CHAT_STATE_BY_TERMINAL_CLASSIFICATION = { + success: "done", + timeout: "error", + cancellation: "aborted", + failure: "error", +} as const; + function readChatRunStartupPhase(value: unknown): ChatRunStartupPhase | undefined { switch (value) { case "preparing_workspace": @@ -725,8 +735,6 @@ export function createAgentEventHandler({ (deliverySessionKey ? sessionMessageSubscribers.get(deliverySessionKey).size > 0 : false)) ) { if (!isAborted) { - const evtStopReason = - typeof evt.data?.stopReason === "string" ? evt.data.stopReason : undefined; const finished = chatLink ? chatRunState.registry.shift(evt.runId) : undefined; if (chatLink && !finished) { clearRunContextForEvent(evt); @@ -736,35 +744,23 @@ export function createAgentEventHandler({ const terminalSessionKey = finished?.sessionKey ?? sessionKey; const terminalRunId = finished?.clientRunId ?? eventRunId; const terminalAgentId = finished?.agentId ?? sessionAgentId; - // Some local lifecycle sources only carry the aborted flag. Preserve - // that terminal state instead of misclassifying the run as a timeout. - const terminalStopReason = evtStopReason ?? (lifecycleAborted ? "aborted" : undefined); + const terminalOutcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: lifecyclePhase, + data: evt.data, + endedAt: evt.data?.endedAt ?? evt.ts, + }); const yieldedWaiting = isAgentLifecycleYieldedWaiting({ phase: lifecyclePhase, yielded: evt.data?.yielded, livenessState: evt.data?.livenessState, - stopReason: terminalStopReason, + stopReason: terminalOutcome.stopReason, aborted: lifecycleAborted, status: evt.data?.status, timeoutPhase: evt.data?.timeoutPhase, error: evt.data?.error, }); - const terminalOutcome = buildAgentRunTerminalOutcome({ - status: lifecyclePhase === "error" ? "error" : lifecycleAborted ? "timeout" : "ok", - error: evt.data?.error, - stopReason: terminalStopReason, - livenessState: evt.data?.livenessState, - timeoutPhase: evt.data?.timeoutPhase, - providerStarted: evt.data?.providerStarted, - startedAt: evt.data?.startedAt, - endedAt: evt.data?.endedAt ?? evt.ts, - }); const terminalState = - terminalOutcome.reason === "completed" - ? "done" - : terminalOutcome.reason === "cancelled" || terminalOutcome.reason === "aborted" - ? "aborted" - : "error"; + CHAT_STATE_BY_TERMINAL_CLASSIFICATION[classifyAgentRunTerminalOutcome(terminalOutcome)]; if (!(opts?.skipChatErrorFinal && terminalState === "error")) { emitChatTerminal( terminalSessionKey, diff --git a/src/gateway/server-methods/agent-job.ts b/src/gateway/server-methods/agent-job.ts index 133e17b847fe..0a4b52fb9545 100644 --- a/src/gateway/server-methods/agent-job.ts +++ b/src/gateway/server-methods/agent-job.ts @@ -5,6 +5,7 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { AGENT_RUN_TERMINAL_RETRY_GRACE_MS, buildAgentRunTerminalOutcome, + buildAgentRunTerminalOutcomeFromLifecycleEvent, isStickyAgentRunTerminalOutcome, mergeAgentRunTerminalOutcome, type AgentRunTerminalOutcome, @@ -269,29 +270,26 @@ function createSnapshotFromLifecycleEvent(params: { const startedAt = typeof data?.startedAt === "number" ? data.startedAt : agentRunStarts.get(runId); const endedAt = typeof data?.endedAt === "number" ? data.endedAt : undefined; - const error = typeof data?.error === "string" ? data.error : undefined; - const stopReason = typeof data?.stopReason === "string" ? data.stopReason : undefined; - const livenessState = typeof data?.livenessState === "string" ? data.livenessState : undefined; - const terminalOutcome = buildAgentRunTerminalOutcome({ - status: phase === "error" ? "error" : data?.aborted ? "timeout" : "ok", - error, - stopReason, - livenessState, - timeoutPhase: data?.timeoutPhase, - providerStarted: data?.providerStarted, + const terminalOutcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase, + data, startedAt, endedAt, }); + // agent.wait historically treats a bare abort flag as a retryable timeout. + // Modern explicit stop reasons keep the canonical cancellation projection. + const legacyBareAbort = + terminalOutcome.reason === "aborted" && data?.stopReason == null && data?.status == null; return { runId, source: "lifecycle", recordedAt: Date.now(), - status: terminalOutcome.status, + status: legacyBareAbort ? "timeout" : terminalOutcome.status, startedAt, endedAt, - error: terminalOutcome.error, - stopReason, - livenessState, + error: legacyBareAbort ? undefined : terminalOutcome.error, + stopReason: legacyBareAbort ? undefined : terminalOutcome.stopReason, + livenessState: terminalOutcome.livenessState, ...(data?.yielded === true ? { yielded: true } : {}), ...(terminalOutcome.timeoutPhase ? { timeoutPhase: terminalOutcome.timeoutPhase } : {}), ...(terminalOutcome.providerStarted !== undefined diff --git a/src/gateway/server-methods/agent-run-dispatch.ts b/src/gateway/server-methods/agent-run-dispatch.ts index ba749de9e0a0..5ebe0ea17db0 100644 --- a/src/gateway/server-methods/agent-run-dispatch.ts +++ b/src/gateway/server-methods/agent-run-dispatch.ts @@ -1,8 +1,10 @@ import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { buildAgentRunTerminalOutcome, + classifyAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../../agents/agent-run-terminal-outcome.js"; +import { isTimeoutError } from "../../agents/failover-error.js"; import type { MainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery-store.js"; import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js"; import { normalizeAgentRunTimeoutPhase } from "../../agents/run-timeout-attribution.js"; @@ -12,12 +14,12 @@ import { clearAgentRunContext } from "../../infra/agent-run-registry.js"; import { readErrorName } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { createRunningTaskRun } from "../../tasks/detached-task-runtime.js"; +import { mapAgentRunTerminalOutcomeToTaskStatus } from "../../tasks/task-registry-common.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; import { formatForLog } from "../ws-log.js"; import { setGatewayDedupeEntries } from "./agent-dedupe.js"; import { - resolveFailedTrackedAgentTaskStatus, tryFinalizeTrackedAgentTask, type GatewayAgentTaskTrackingMode, } from "./agent-task-tracking.js"; @@ -67,8 +69,19 @@ function resolveGatewayAgentAbortStopReason(signal: AbortSignal): "restart" | "r return readErrorName(signal.reason) === "TimeoutError" ? "timeout" : "rpc"; } -function resolveAbortedAgentTaskStatus(stopReason: string | undefined): "cancelled" | "timed_out" { - return stopReason === "timeout" ? "timed_out" : "cancelled"; +// `agent` clients already consume cancellation as timeout; keep that wire +// contract while task/session projections use the canonical cancellation class. +const RESOLVED_GATEWAY_STATUS_BY_TERMINAL_CLASSIFICATION = { + success: "ok", + timeout: "timeout", + cancellation: "timeout", + failure: "error", +} as const; + +function projectRejectedGatewayStatus(outcome: AgentRunTerminalOutcome): "error" | "timeout" { + // The shipped wire keeps raw provider/AbortError rejections as errors. Only + // signal-owned cancellation/timeout metadata promotes a rejection to timeout. + return outcome.reason === "cancelled" || outcome.stopReason === "timeout" ? "timeout" : "error"; } export function resolveAbortedAgentStopReason(entry?: ChatAbortControllerEntry): string { @@ -176,17 +189,14 @@ export function dispatchAgentRunFromGateway(params: { timeoutPhase, providerStarted: result?.meta?.providerStarted, }); - const responseStatus = aborted ? "timeout" : terminalOutcome.status; + const responseStatus = + RESOLVED_GATEWAY_STATUS_BY_TERMINAL_CLASSIFICATION[ + classifyAgentRunTerminalOutcome(terminalOutcome) + ]; if (taskTracked) { tryFinalizeTrackedAgentTask({ runId: params.runId, - status: aborted - ? resolveAbortedAgentTaskStatus(stopReason) - : responseStatus === "timeout" - ? "timed_out" - : responseStatus === "error" - ? "failed" - : "succeeded", + status: mapAgentRunTerminalOutcomeToTaskStatus(terminalOutcome), terminalSummary: responseStatus === "timeout" ? "aborted" @@ -250,19 +260,20 @@ export function dispatchAgentRunFromGateway(params: { const renderedErr = formatForLog(err); const stopReason = aborted ? resolveGatewayAgentAbortStopReason(params.abortController.signal) - : undefined; + : isAbortError(err) + ? "aborted" + : undefined; const terminalOutcome = buildAgentRunTerminalOutcome({ - status: aborted ? "timeout" : "error", + status: aborted || isTimeoutError(err) ? "timeout" : "error", error: renderedErr, stopReason, timeoutPhase: stopReason === "restart" ? "gateway_draining" : undefined, }); + const responseStatus = projectRejectedGatewayStatus(terminalOutcome); if (taskTracked) { tryFinalizeTrackedAgentTask({ runId: params.runId, - status: aborted - ? resolveAbortedAgentTaskStatus(stopReason) - : resolveFailedTrackedAgentTaskStatus(err), + status: mapAgentRunTerminalOutcomeToTaskStatus(terminalOutcome), error: renderedErr, terminalSummary: renderedErr, log: params.context.logGateway, @@ -271,7 +282,7 @@ export function dispatchAgentRunFromGateway(params: { const error = errorShape(ErrorCodes.UNAVAILABLE, renderedErr); const payload = { runId: params.runId, - status: aborted ? ("timeout" as const) : ("error" as const), + status: responseStatus, summary: aborted ? "aborted" : renderedErr, ...(aborted ? { diff --git a/src/gateway/server-methods/agent-task-tracking.ts b/src/gateway/server-methods/agent-task-tracking.ts index e6d0cd455ccb..302963f0cbf0 100644 --- a/src/gateway/server-methods/agent-task-tracking.ts +++ b/src/gateway/server-methods/agent-task-tracking.ts @@ -4,10 +4,8 @@ import { GATEWAY_CLIENT_NAMES, } from "../../../packages/gateway-protocol/src/client-info.js"; import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js"; -import { isTimeoutError } from "../../agents/failover-error.js"; import { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { isAbortError } from "../../infra/abort-signal.js"; import type { PluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js"; import { isAcpSessionKey } from "../../routing/session-key.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; @@ -186,12 +184,6 @@ export async function registerPluginSubagentRunFromGateway(params: { }); } -export function resolveFailedTrackedAgentTaskStatus( - error: unknown, -): GatewayAgentTaskTerminalStatus { - return isAbortError(error) || isTimeoutError(error) ? "timed_out" : "failed"; -} - export function tryFinalizeTrackedAgentTask(params: { runId: string; status: GatewayAgentTaskTerminalStatus; diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index 8a06c9323a83..29df51f74668 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -824,6 +824,43 @@ describe("gateway agent handler", () => { }); }); + it("classifies an unsignaled AbortError task as cancelled without changing the error wire", async () => { + await withTempDir({ prefix: "openclaw-gateway-agent-task-plain-abort-" }, async (root) => { + useTestStateDir(root); + resetAgentTaskRegistryForTests(); + primeMainAgentRun(); + const abortError = new Error("This operation was aborted"); + abortError.name = "AbortError"; + const context = makeContext(); + const runId = "task-registry-agent-run-plain-abort"; + mocks.agentCommand.mockRejectedValueOnce(abortError); + + await invokeAgent( + { + message: "background cli task", + sessionKey: "agent:main:main", + idempotencyKey: runId, + }, + { context, reqId: runId }, + ); + + await waitForAssertion(() => { + expectRecordFields(findTaskByRunId(runId), { + runtime: "cli", + childSessionKey: "agent:main:main", + status: "cancelled", + error: "AbortError: This operation was aborted", + }); + expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { + runId, + status: "error", + summary: "AbortError: This operation was aborted", + }); + expect(context.dedupe.get(`agent:${runId}`)?.ok).toBe(false); + }); + }); + }); + it("preserves restart ownership for aborted async gateway agent rejections", async () => { await withTempDir({ prefix: "openclaw-gateway-agent-task-restart-abort-" }, async (root) => { useTestStateDir(root); diff --git a/src/gateway/session-lifecycle-state.test.ts b/src/gateway/session-lifecycle-state.test.ts index cbcf7d68b8d2..adbbea6f964c 100644 --- a/src/gateway/session-lifecycle-state.test.ts +++ b/src/gateway/session-lifecycle-state.test.ts @@ -198,10 +198,10 @@ describe("session lifecycle state", () => { abortedLastRun: true, }, { - name: "timeout", + name: "signal-only cancellation", data: { phase: "end", endedAt: 1_800, aborted: true }, - status: "timeout", - abortedLastRun: false, + status: "killed", + abortedLastRun: true, }, { name: "provider timeout", @@ -244,7 +244,7 @@ describe("session lifecycle state", () => { livenessState: "paused", stopReason: "end_turn", }, - status: "timeout", + status: "failed", abortedLastRun: false, }, ] as const)("persists $name terminal state", async ({ data, status, abortedLastRun }) => { diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index 92b1a50611c2..bd15445a2b39 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -3,7 +3,8 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js"; import { - buildAgentRunTerminalOutcome, + buildAgentRunTerminalOutcomeFromLifecycleEvent, + classifyAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../agents/agent-run-terminal-outcome.js"; import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; @@ -69,37 +70,18 @@ function resolveLifecyclePhase(event: Pick): Lifecyc return phase === "start" || phase === "end" || phase === "error" ? phase : null; } -function mapAgentRunTerminalOutcomeToSessionStatus( - outcome: AgentRunTerminalOutcome, -): SessionRunStatus { - switch (outcome.reason) { - case "completed": - return "done"; - case "hard_timeout": - case "timed_out": - return "timeout"; - case "cancelled": - case "aborted": - return "killed"; - case "blocked": - case "abandoned": - case "failed": - return "failed"; - default: - return outcome.reason satisfies never; - } -} +const SESSION_STATUS_BY_TERMINAL_CLASSIFICATION = { + success: "done", + timeout: "timeout", + cancellation: "killed", + failure: "failed", +} as const satisfies Record, SessionRunStatus>; function resolveTerminalOutcome(event: LifecycleEventLike): AgentRunTerminalOutcome { const phase = resolveLifecyclePhase(event); - return buildAgentRunTerminalOutcome({ - status: phase === "error" ? "error" : event.data?.aborted === true ? "timeout" : "ok", - error: event.data?.error, - stopReason: event.data?.stopReason, - livenessState: event.data?.livenessState, - timeoutPhase: event.data?.timeoutPhase, - providerStarted: event.data?.providerStarted, - startedAt: event.data?.startedAt, + return buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: phase === "error" ? "error" : "end", + data: event.data, endedAt: event.data?.endedAt ?? event.ts, }); } @@ -196,7 +178,9 @@ function deriveGatewaySessionLifecycleSnapshot(params: { error: params.event.data?.error, }); const terminal = yieldedWaiting ? undefined : resolveTerminalOutcome(params.event); - const status = terminal ? mapAgentRunTerminalOutcomeToSessionStatus(terminal) : "running"; + const status = terminal + ? SESSION_STATUS_BY_TERMINAL_CLASSIFICATION[classifyAgentRunTerminalOutcome(terminal)] + : "running"; return { updatedAt, status, diff --git a/src/tasks/task-registry-common.ts b/src/tasks/task-registry-common.ts index 4cf1b952c6de..714ce492ad5c 100644 --- a/src/tasks/task-registry-common.ts +++ b/src/tasks/task-registry-common.ts @@ -1,6 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { - buildAgentRunTerminalOutcome, + classifyAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../agents/agent-run-terminal-outcome.js"; import { SUBAGENT_KILL_TASK_ERROR } from "./detached-task-runtime-contract.js"; @@ -265,25 +265,17 @@ export function resolveTaskTerminalOutcome(params: { return params.status === "succeeded" ? "succeeded" : undefined; } +const TASK_STATUS_BY_TERMINAL_CLASSIFICATION = { + success: "succeeded", + timeout: "timed_out", + cancellation: "cancelled", + failure: "failed", +} as const; + export function mapAgentRunTerminalOutcomeToTaskStatus( outcome: AgentRunTerminalOutcome, ): Extract { - switch (outcome.reason) { - case "completed": - return "succeeded"; - case "hard_timeout": - case "timed_out": - return "timed_out"; - case "cancelled": - case "aborted": - return "cancelled"; - case "blocked": - case "abandoned": - case "failed": - return "failed"; - default: - return outcome.reason satisfies never; - } + return TASK_STATUS_BY_TERMINAL_CLASSIFICATION[classifyAgentRunTerminalOutcome(outcome)]; } export function resolveTaskLifecycleTerminalError(params: { @@ -298,28 +290,6 @@ export function resolveTaskLifecycleTerminalError(params: { : params.error; } -export function buildTaskLifecycleTerminalOutcome(params: { - phase: "end" | "error"; - data?: Record; - startedAt?: number; - endedAt?: number; -}): AgentRunTerminalOutcome { - const status = - params.phase === "error" ? "error" : params.data?.aborted === true ? "timeout" : "ok"; - // Lifecycle events carry runner/provider terminal facts. Keep the precedence - // centralized so task projections match agent.wait and gateway snapshots. - return buildAgentRunTerminalOutcome({ - status, - error: params.data?.error, - stopReason: params.data?.stopReason, - livenessState: params.data?.livenessState, - timeoutPhase: params.data?.timeoutPhase, - providerStarted: params.data?.providerStarted, - startedAt: params.startedAt, - endedAt: params.endedAt, - }); -} - export function appendTaskEvent(event: { at: number; kind: TaskEventKind; diff --git a/src/tasks/task-registry-lifecycle.ts b/src/tasks/task-registry-lifecycle.ts index 265db13cc31c..13b2274764a0 100644 --- a/src/tasks/task-registry-lifecycle.ts +++ b/src/tasks/task-registry-lifecycle.ts @@ -1,8 +1,8 @@ +import { buildAgentRunTerminalOutcomeFromLifecycleEvent } from "../agents/agent-run-terminal-outcome.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { isTerminalTaskStatus } from "./task-executor-policy.js"; import { appendTaskEvent, - buildTaskLifecycleTerminalOutcome, mapAgentRunTerminalOutcomeToTaskStatus, resolveTaskLifecycleTerminalError, } from "./task-registry-common.js"; @@ -55,7 +55,7 @@ function ensureListener() { if (phase === "start") { patch.status = "running"; } else if (phase === "end") { - const terminal = buildTaskLifecycleTerminalOutcome({ + const terminal = buildAgentRunTerminalOutcomeFromLifecycleEvent({ phase, data: evt.data, startedAt, @@ -72,7 +72,7 @@ function ensureListener() { patch.error = error; } } else if (phase === "error") { - const terminal = buildTaskLifecycleTerminalOutcome({ + const terminal = buildAgentRunTerminalOutcomeFromLifecycleEvent({ phase, data: evt.data, startedAt, diff --git a/src/tasks/task-registry.test.ts b/src/tasks/task-registry.test.ts index de69120ef084..c326f3826a45 100644 --- a/src/tasks/task-registry.test.ts +++ b/src/tasks/task-registry.test.ts @@ -959,7 +959,7 @@ describe("task-registry", () => { }); }); - it("keeps stronger run-scoped terminal states when a late success arrives", async () => { + it("keeps signal-only cancellation when a late success arrives", async () => { await withTaskRegistryTempDir(async () => { resetTaskRegistryMemoryForTest(); @@ -988,7 +988,7 @@ describe("task-registry", () => { }); expectRecordFields(requireTaskByRunId("run-timeout-then-success"), { - status: "timed_out", + status: "cancelled", endedAt: 200, }); }); diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 4fd9da3baa86..80787ecc443e 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -2289,8 +2289,7 @@ describe("EmbeddedTuiBackend", () => { }); it("surfaces canonical error-only thrown outcomes without exposing the wrapped cause", async () => { - const { AgentRunTerminalOutcomeError } = - await import("../agents/agent-run-terminal-outcome.js"); + const { AgentRunTerminalOutcomeError } = await import("../agents/agent-run-terminal-error.js"); const secret = ["sk", "abcdefghijklmnopqrstuv"].join("-"); agentCommandFromIngressMock.mockRejectedValueOnce( new AgentRunTerminalOutcomeError(new Error(`hidden provider credential ${secret}`), { @@ -2325,8 +2324,7 @@ describe("EmbeddedTuiBackend", () => { }); it("preserves a wrapped canonical cancellation without redundant abort metadata", async () => { - const { AgentRunTerminalOutcomeError } = - await import("../agents/agent-run-terminal-outcome.js"); + const { AgentRunTerminalOutcomeError } = await import("../agents/agent-run-terminal-error.js"); agentCommandFromIngressMock.mockRejectedValueOnce( new AgentRunTerminalOutcomeError(new Error("underlying cancellation"), { reason: "cancelled", diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index aac0536ec528..14826699978f 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -4,10 +4,12 @@ import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/in import { CHAT_HISTORY_MAX_ENTRIES } from "../../packages/gateway-protocol/src/schema/chat-history-constants.js"; import { agentCommandFromIngress } from "../agents/agent-command.js"; import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js"; +import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-error.js"; import { AGENT_RUN_TERMINAL_RETRY_GRACE_MS, - buildAgentRunTerminalOutcome, - findAgentRunTerminalOutcome, + buildAgentRunTerminalOutcomeFromLifecycleEvent, + classifyAgentRunTerminalOutcome, + type AgentRunTerminalOutcome, } from "../agents/agent-run-terminal-outcome.js"; import { listAgentEntries } from "../agents/agent-scope-config.js"; import { @@ -112,6 +114,13 @@ import type { } from "./tui-backend.js"; import { formatTuiErrorMessage } from "./tui-formatters.js"; +const TUI_STATE_BY_TERMINAL_CLASSIFICATION = { + success: undefined, + timeout: "error", + cancellation: "aborted", + failure: "error", +} as const; + type LocalRunState = { sessionKey: string; agentId: string; @@ -1176,46 +1185,36 @@ export class EmbeddedTuiBackend implements TuiBackend { private projectTerminalOutcome( runId: string, run: LocalRunState, - metadata: Partial[0]> & { + metadata: NonNullable< + Parameters[0]["data"] + > & { aborted?: unknown; phase?: unknown; toolErrorSummary?: unknown; }, options: { visibleText?: string; - terminalOutcome?: ReturnType; + terminalOutcome?: AgentRunTerminalOutcome; } = {}, ): boolean { - const aborted = - typeof metadata.aborted === "boolean" ? metadata.aborted : run.controller.signal.aborted; - const stopReason = metadata.stopReason ?? (aborted ? "aborted" : undefined); const terminalError = metadata.error && typeof metadata.error === "object" && "message" in metadata.error ? metadata.error.message : metadata.error; const outcome = options.terminalOutcome ?? - buildAgentRunTerminalOutcome({ - status: - stopReason === "timeout" || metadata.status === "timeout" || metadata.timeoutPhase - ? "timeout" - : aborted || - metadata.error || - metadata.phase === "error" || - [metadata.status, stopReason].includes("error") - ? "error" - : "ok", - error: terminalError ? formatTuiErrorMessage(terminalError) : undefined, - stopReason, - livenessState: metadata.livenessState, - timeoutPhase: metadata.timeoutPhase, - providerStarted: metadata.providerStarted, + buildAgentRunTerminalOutcomeFromLifecycleEvent({ + phase: metadata.phase === "error" || terminalError ? "error" : "end", + data: { + ...metadata, + error: terminalError ? formatTuiErrorMessage(terminalError) : undefined, + }, + abortSignal: run.controller.signal, }); - if (outcome.reason === "completed") { + const state = TUI_STATE_BY_TERMINAL_CLASSIFICATION[classifyAgentRunTerminalOutcome(outcome)]; + if (!state) { return false; } - const state = - outcome.reason === "aborted" || outcome.reason === "cancelled" ? "aborted" : "error"; const diagnostic = state === "aborted" ? readToolValidationErrorSummary(metadata.toolErrorSummary)