Skip to content

Commit 3de7817

Browse files
evan-YMclaude
andcommitted
[AI] fix(cron): classify recovered agentTurn as non-fatal when finalAssistantVisibleText exists
When an isolated cron agentTurn hits a transient tool error mid-run and the agent recovers with a successful finalAssistantVisibleText, the run was misclassified as fatal because no existing recovery gate could fire. The exec error lacked the required metadata markers, text prefixes, or deliverable post-error payloads. Add hasRecoveredByFinalAnswer gate that treats a non-empty finalAssistantVisibleText as recovery proof when the session completed without a run-level error or fatal failure signal. Also couple the recovery gate with delivery-payload selection so the final report is dispatched on all channels — including Feishu/Slack where preferFinalAssistantVisibleText is false — rather than falling back to the recovered error text. Related to #96255 Co-Authored-By: Claude <[email protected]>
1 parent 03ca096 commit 3de7817

2 files changed

Lines changed: 94 additions & 14 deletions

File tree

src/cron/isolated-agent.helpers.test.ts

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,19 +143,17 @@ describe("resolveCronPayloadOutcome", () => {
143143
expect(result.deliveryPayloads).toEqual([{ text: "⚠️ ✉️ Message failed", isError: true }]);
144144
});
145145

146-
it("keeps real trailing errors fatal even when earlier assistant output exists", () => {
146+
it("treats trailing errors as non-fatal when final assistant visible text proves recovery", () => {
147147
const result = resolveCronPayloadOutcome({
148148
payloads: [{ text: "Partial result" }, { text: "model provider unreachable", isError: true }],
149149
finalAssistantVisibleText: "Partial result",
150150
preferFinalAssistantVisibleText: true,
151151
});
152152

153-
expect(result.hasFatalErrorPayload).toBe(true);
154-
expect(result.embeddedRunError).toBe("model provider unreachable");
155-
expect(result.outputText).toBe("model provider unreachable");
156-
expect(result.deliveryPayloads).toEqual([
157-
{ text: "model provider unreachable", isError: true },
158-
]);
153+
expect(result.hasFatalErrorPayload).toBe(false);
154+
expect(result.embeddedRunError).toBeUndefined();
155+
expect(result.outputText).toBe("Partial result");
156+
expect(result.deliveryPayloads).toEqual([{ text: "Partial result" }]);
159157
});
160158

161159
it("keeps error payloads fatal when the run also reported a run-level error", () => {
@@ -323,7 +321,7 @@ describe("resolveCronPayloadOutcome", () => {
323321
expect(result.deliveryPayloadHasStructuredContent).toBe(true);
324322
});
325323

326-
it("returns only the last error payload when all payloads are errors", () => {
324+
it("recovers via finalAssistantVisibleText when all payloads are errors", () => {
327325
const result = resolveCronPayloadOutcome({
328326
payloads: [
329327
{ text: "first error", isError: true },
@@ -333,9 +331,10 @@ describe("resolveCronPayloadOutcome", () => {
333331
preferFinalAssistantVisibleText: true,
334332
});
335333

336-
expect(result.outputText).toBe("last error");
337-
expect(result.deliveryPayloads).toEqual([{ text: "last error", isError: true }]);
338-
expect(result.deliveryPayload).toEqual({ text: "last error", isError: true });
334+
expect(result.hasFatalErrorPayload).toBe(false);
335+
expect(result.embeddedRunError).toBeUndefined();
336+
expect(result.outputText).toBe("Recovered final answer");
337+
expect(result.deliveryPayloads).toEqual([{ text: "Recovered final answer" }]);
339338
});
340339

341340
it("keeps multi-payload direct delivery when finalAssistantVisibleText is not preferred", () => {
@@ -437,4 +436,72 @@ describe("resolveCronPayloadOutcome", () => {
437436
expect(result.hasFatalErrorPayload).toBe(true);
438437
expect(result.embeddedRunError).toBe("Exec failed before SYSTEM_RUN_DENIED could be retried");
439438
});
439+
440+
it("recovers when finalAssistantVisibleText exists despite unmarked exec error", () => {
441+
// Reproduces #96255: exec tool rejects a shell redirect ("unknown arg: >"),
442+
// agent retries without the redirect and succeeds, then produces a full
443+
// final report. The exec error payload has no ⚠️ 🛠️ prefix and no
444+
// nonTerminalToolErrorWarning metadata, so no existing gate catches it.
445+
const result = resolveCronPayloadOutcome({
446+
payloads: [
447+
{ text: "Working on the query..." },
448+
{ text: "unknown arg: >", isError: true },
449+
// Intermediate tool-output payloads may exist but aren't "deliverable".
450+
],
451+
finalAssistantVisibleText: "Here is the full daily report with all sections.",
452+
preferFinalAssistantVisibleText: true,
453+
});
454+
455+
expect(result.hasFatalErrorPayload).toBe(false);
456+
expect(result.hasFatalStructuredErrorPayload).toBe(false);
457+
expect(result.embeddedRunError).toBeUndefined();
458+
expect(result.outputText).toBe("Here is the full daily report with all sections.");
459+
expect(result.deliveryPayloads).toEqual([
460+
{ text: "Here is the full daily report with all sections." },
461+
]);
462+
});
463+
464+
it("keeps errors fatal when runLevelError is set despite finalAssistantVisibleText", () => {
465+
const result = resolveCronPayloadOutcome({
466+
payloads: [{ text: "unknown arg: >", isError: true }],
467+
runLevelError: { kind: "context_overflow", message: "exceeded context window" },
468+
finalAssistantVisibleText: "Partial report before context overflow.",
469+
});
470+
471+
expect(result.hasFatalErrorPayload).toBe(true);
472+
expect(result.embeddedRunError).toContain("unknown arg: >");
473+
});
474+
475+
it("keeps errors fatal when failureSignal is fatalForCron despite finalAssistantVisibleText", () => {
476+
const result = resolveCronPayloadOutcome({
477+
payloads: [{ text: "unknown arg: >", isError: true }],
478+
failureSignal: {
479+
kind: "execution_denied",
480+
message: "approval required",
481+
fatalForCron: true,
482+
},
483+
finalAssistantVisibleText: "I tried to run the command.",
484+
});
485+
486+
expect(result.hasFatalErrorPayload).toBe(true);
487+
expect(result.embeddedRunError).toContain("unknown arg: >");
488+
});
489+
490+
it("delivers finalAssistantVisibleText on false-policy channel when recovery gate fires", () => {
491+
// Regression for ClawSweeper P1: Feishu/Slack channels that do NOT set
492+
// preferFinalAssistantVisibleText should still receive the final report
493+
// when hasRecoveredByFinalAnswer cleared fatal state.
494+
const result = resolveCronPayloadOutcome({
495+
payloads: [{ text: "Working on it..." }, { text: "exec failed: unknown arg", isError: true }],
496+
finalAssistantVisibleText: "Daily report:\n- Item 1\n- Item 2",
497+
// Explicitly false: simulates Feishu/Slack channel policy
498+
preferFinalAssistantVisibleText: false,
499+
});
500+
501+
expect(result.hasFatalErrorPayload).toBe(false);
502+
expect(result.hasFatalStructuredErrorPayload).toBe(false);
503+
expect(result.embeddedRunError).toBeUndefined();
504+
expect(result.outputText).toBe("Daily report:\n- Item 1\n- Item 2");
505+
expect(result.deliveryPayloads).toEqual([{ text: "Daily report:\n- Item 1\n- Item 2" }]);
506+
});
440507
});

src/cron/isolated-agent/helpers.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,25 +294,38 @@ export function resolveCronPayloadOutcome(params: {
294294
!hasStructuredDeliveryPayloads &&
295295
errorPayloads.length > 0 &&
296296
errorPayloads.every((payload) => isCronToolWarning(payload?.text));
297+
// When the agent produced a final visible answer and the session itself
298+
// didn't error, the run recovered — even when the last error payload lacks
299+
// a recovery marker and no later deliverable payloads exist.
300+
const hasRecoveredByFinalAnswer =
301+
!params.runLevelError &&
302+
params.failureSignal?.fatalForCron !== true &&
303+
normalizedFinalAssistantVisibleText !== undefined;
297304
// Structured error payloads are fatal unless later successful output or a
298305
// known non-terminal warning proves the agent recovered.
299306
const hasFatalStructuredErrorPayload =
300307
hasErrorPayload &&
301308
!hasSuccessfulPayloadAfterLastError &&
302309
!hasPendingPresentationWarning &&
303310
!hasNonTerminalToolErrorWarning &&
304-
!hasRecoveredToolWarning;
311+
!hasRecoveredToolWarning &&
312+
!hasRecoveredByFinalAnswer;
305313
// Fatal structured errors own the final delivery payload unless later output
306314
// proves recovery; otherwise cron would announce stale partial success text.
307315
// Keep structured/media announce payloads intact. Only collapse purely textual
308316
// cron announce output to the final assistant-visible answer.
309317
// A final assistant answer can replace textual warning payloads, but never
310318
// structured/media payloads that carry the actual delivery content.
319+
// When hasRecoveredByFinalAnswer cleared fatal state, the final text is also
320+
// the recovery proof — deliver it regardless of channel policy so Feishu,
321+
// Slack, and other channels without preferFinalAssistantVisibleText don't
322+
// dispatch the recovered error payload as a success announce.
311323
const shouldUseFinalAssistantVisibleText =
312-
params.preferFinalAssistantVisibleText === true &&
313324
normalizedFinalAssistantVisibleText !== undefined &&
314325
!hasFatalStructuredErrorPayload &&
315-
!hasStructuredDeliveryPayloads;
326+
!hasStructuredDeliveryPayloads &&
327+
(params.preferFinalAssistantVisibleText === true ||
328+
(hasRecoveredByFinalAnswer && hasErrorPayload));
316329
const summary = shouldUseFinalAssistantVisibleText
317330
? (pickSummaryFromOutput(normalizedFinalAssistantVisibleText) ?? fallbackSummary)
318331
: fallbackSummary;

0 commit comments

Comments
 (0)