fix(agents): stale sub-agent failure warning shown after successful spawn retry (#126218)

* 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.
This commit is contained in:
Peter Steinberger
2026-08-19 00:31:47 -07:00
committed by GitHub
parent a4b265aa9b
commit 617bc7ebdd
3 changed files with 85 additions and 0 deletions
+18
View File
@@ -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(
+7
View File
@@ -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}`];
+60
View File
@@ -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");