From 617bc7ebdd8159940f72ec2c19268b8311e82c3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 00:31:47 -0700 Subject: [PATCH] fix(agents): stale sub-agent failure warning shown after successful spawn retry (#126218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): clear sub-agent failure warning after successful spawn retry A failed sessions_spawn followed by a successful retry in the same run kept appending the durable "Sub-agent failed" warning to channel replies. The recovery seam (lastToolRecovery) already existed, but sessions_spawn has no stable-target arg key, so its recovery fingerprint fell back to display meta built from label/task/model. Retries adjust those args (drop a rejected cwd, reword the task), the fingerprints never matched, and recordSuccess could not clear the failure. Collapse the sessions_spawn recovery identity to tool level: any later successful spawn in the same run is recovery proof. A lone failed spawn still warns, and a failure after recovery still invalidates the receipt. Observed 2026-08-19 on team.openclaw.ai: Roboclaw's first nested spawn returned forbidden (visible-session cwd outside workspace), the retry without cwd succeeded, the reply said "Started Investigate…", and Discord still showed "⚠️ 🧑‍🔧 Sub-agent failed". * ci: register run-attempt-tools test in the attempt-light lane #126189 added extensions/codex/src/app-server/run-attempt-tools.test.ts without assigning it to a full-suite lane, so the Vitest ownership audit (test/vitest-projects-config.test.ts) fails on main and every PR head. Register it in the codex app-server attempt-light shard next to its run-attempt siblings. --- src/agents/tool-mutation.test.ts | 18 +++++++ src/agents/tool-mutation.ts | 7 +++ src/agents/tool-terminal-outcome.test.ts | 60 ++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index d6042dd65b36..eee033d026ca 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -50,6 +50,24 @@ describe("tool mutation helpers", () => { expect(readFingerprint).toBeUndefined(); }); + it("keeps sessions_spawn recovery identity at tool level across adjusted retries", () => { + // Spawn retries adjust args (drop a rejected cwd, reword the task); a + // per-args identity would never let the successful retry clear the failure. + const failed = buildToolMutationState( + "sessions_spawn", + { task: "Investigate", label: "Investigate", cwd: "/outside" }, + "label Investigate, task Investigate", + ); + const retried = buildToolMutationState( + "sessions_spawn", + { task: "Investigate in repo scope" }, + "Investigate in repo scope", + ); + expect(failed.mutatingAction).toBe(true); + expect(failed.actionFingerprint).toBe("tool=sessions_spawn"); + expect(retried.actionFingerprint).toBe(failed.actionFingerprint); + }); + it("binds reordered exact arguments to one owner but separates changed facts and owners", () => { const ownerKey = '["memory-lancedb","memory_store"]'; const metric = buildToolMutationState( diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index bb1e6dd278b6..b43c914b5835 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -439,6 +439,13 @@ function buildToolActionFingerprint( return undefined; } const normalizedTool = normalizeLowercaseStringOrEmpty(toolName); + // sessions_spawn has no stable target: retries adjust args (drop a rejected + // cwd, reword the task), so arg/meta identity never matches and a recovered + // failure keeps warning "Sub-agent failed". A later successful spawn in the + // same run is the recovery proof; keep the identity at tool level. + if (normalizedTool === "sessions_spawn") { + return `tool=${normalizedTool}`; + } const record = asRecord(args); const action = normalizeActionName(record?.action); const parts = [`tool=${normalizedTool}`]; diff --git a/src/agents/tool-terminal-outcome.test.ts b/src/agents/tool-terminal-outcome.test.ts index ef73d0b249cf..0a5f119c73e2 100644 --- a/src/agents/tool-terminal-outcome.test.ts +++ b/src/agents/tool-terminal-outcome.test.ts @@ -8,6 +8,7 @@ import { resetAdjustedParamsByToolCallIdForTests, } from "./agent-tools.before-tool-call.state.js"; import { buildPayloads } from "./embedded-agent-runner/run/payloads.test-helpers.js"; +import { inferToolMetaFromArgsCore } from "./tool-display.js"; import { createToolTerminalObserver } from "./tool-terminal-outcome.js"; describe("tool terminal outcome observer", () => { @@ -167,6 +168,65 @@ describe("tool terminal outcome observer", () => { }); }); + it("clears a failed sessions_spawn once a retry with adjusted arguments succeeds", () => { + const observe = createToolTerminalObserver("run-spawn-retry"); + const failedArgs = { + task: "Investigate the flaky gateway test", + label: "Investigate", + cwd: "/outside/workspace", + }; + // The retry the model actually issues: drops the rejected cwd and rewords the task. + const retryArgs = { task: "Investigate the flaky gateway test in repo scope" }; + + observe({ + toolName: "sessions_spawn", + arguments: failedArgs, + meta: inferToolMetaFromArgsCore("sessions_spawn", failedArgs), + outcome: "failure", + failure: { error: "cwd is outside the workspace" }, + }); + const afterRetry = observe({ + toolName: "sessions_spawn", + arguments: retryArgs, + meta: inferToolMetaFromArgsCore("sessions_spawn", retryArgs), + outcome: "success", + }); + + expect(afterRetry.lastToolError).toBeUndefined(); + expect(afterRetry.lastToolRecovery).toEqual({ toolName: "sessions_spawn" }); + + const payloads = buildPayloads({ + assistantTexts: ["Started Investigate in a new session."], + lastToolError: afterRetry.lastToolError, + lastToolRecovery: afterRetry.lastToolRecovery, + }); + expect(payloads.map((payload) => payload.text)).toEqual([ + "Started Investigate in a new session.", + "✅ 🧑‍🔧 Sub-agent succeeded after retry.", + ]); + }); + + it("keeps the sessions_spawn failure warning when no later spawn succeeds", () => { + const observe = createToolTerminalObserver("run-spawn-failed"); + const failedArgs = { task: "Investigate the flaky gateway test", label: "Investigate" }; + + const terminal = observe({ + toolName: "sessions_spawn", + arguments: failedArgs, + meta: inferToolMetaFromArgsCore("sessions_spawn", failedArgs), + outcome: "failure", + failure: { error: "cwd is outside the workspace" }, + }); + + const payloads = buildPayloads({ + assistantTexts: ["Started Investigate in a new session."], + lastToolError: terminal.lastToolError, + lastToolRecovery: terminal.lastToolRecovery, + }); + expect(payloads.at(-1)?.isError).toBe(true); + expect(payloads.at(-1)?.text).toContain("Sub-agent failed"); + }); + it("preserves durable memory recall side-effect evidence", () => { const observe = createToolTerminalObserver("run-memory");