mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): centralize terminal lifecycle outcomes (#118254)
* refactor(agents): centralize terminal lifecycle outcomes * fix(agents): preserve terminal projection contracts * refactor(agents): split terminal error boundary * test(gateway): align stale lifecycle projection
This commit is contained in:
committed by
GitHub
parent
347f16d556
commit
3a555d5956
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<object>();
|
||||
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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<object>();
|
||||
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<AgentRunTerminalOutcome, "reason">) {
|
||||
return AGENT_RUN_TERMINAL_CLASSIFICATION[outcome.reason];
|
||||
}
|
||||
|
||||
/** Raw terminal input collected from run wait/liveness/timeout paths. */
|
||||
@@ -468,6 +451,11 @@ type AgentRunTerminalWaitInput = Omit<AgentRunTerminalInput, "status"> & {
|
||||
status?: unknown;
|
||||
};
|
||||
|
||||
type AgentRunLifecycleTerminalData = Omit<AgentRunTerminalWaitInput, "status"> & {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
+18
-22
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<LifecycleEventLike, "data">): 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<ReturnType<typeof classifyAgentRunTerminalOutcome>, 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,
|
||||
|
||||
@@ -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<TaskStatus, "succeeded" | "failed" | "timed_out" | "cancelled"> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
+24
-25
@@ -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<Parameters<typeof buildAgentRunTerminalOutcome>[0]> & {
|
||||
metadata: NonNullable<
|
||||
Parameters<typeof buildAgentRunTerminalOutcomeFromLifecycleEvent>[0]["data"]
|
||||
> & {
|
||||
aborted?: unknown;
|
||||
phase?: unknown;
|
||||
toolErrorSummary?: unknown;
|
||||
},
|
||||
options: {
|
||||
visibleText?: string;
|
||||
terminalOutcome?: ReturnType<typeof buildAgentRunTerminalOutcome>;
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user