Skip to content

Commit 04665bf

Browse files
authored
Merge pull request #688 from code-yeongyu/fix/goal-resume-after-provider-error
fix(coding-agent): resume a provider-error-blocked goal on the next user message
2 parents aecb99d + c3f2997 commit 04665bf

6 files changed

Lines changed: 133 additions & 4 deletions

File tree

packages/coding-agent/src/core/extensions/builtin/goal/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ and a clean accepted user turn arms a visible 10-second grace countdown before t
5050
resumes; mechanically blocked Goals are reactivated on accepted input, including admitted
5151
steering. A `length` stop gets exactly one minimal truncation recovery before the goal
5252
blocks on repetition, terminal provider errors block the goal only when `AgentEndEvent.willRetry`
53-
is false, and resumed sessions with 8+ trailing historical continuation entries suppress
53+
is false and count as mechanical (a new user message resumes the goal, and the blocked notice
54+
says so), while intentional blocks — a user interrupt or a model-declared `update_goal` block —
55+
stay non-recoverable. Resumed sessions with 8+ trailing historical continuation entries suppress
5456
session-start auto-resume. `tokenBudget` remains inert compatibility metadata only; this
5557
policy is budget-free by design.
5658

packages/coding-agent/src/core/extensions/builtin/goal/changes.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# goal Extension Changes
22

3+
## A terminal provider error is a prompt-recoverable block (2026-08-04)
4+
5+
### What changed
6+
7+
- `continuation-recovery.ts` exports `PROVIDER_ERROR_BLOCKED_REASON` and adds it to
8+
`MECHANICAL_CONTINUATION_BLOCKS`, so `isMechanicalContinuationBlock` classifies a
9+
retries-exhausted provider error alongside the cap, repetition, and length guards.
10+
- `index.ts` writes that shared constant instead of repeating the literal reason and
11+
appends `continuationCapRecoveryHint(...)` to the blocked notice, so the TUI warning
12+
now ends with `Send any message to resume.` instead of only naming the failure.
13+
- `GoalDirectInputLifecycle.onDisposition` needed no change: reactivating a mechanically
14+
blocked goal on accepted direct input already existed, and the provider-error reason
15+
now flows through it.
16+
17+
### Why
18+
19+
- A terminal provider error is infrastructure, not a decision. The user's next message is
20+
exactly the retry signal, so leaving the goal blocked stranded a live run behind a state
21+
only `/goal resume` could clear, while the notice never said so.
22+
- Intentional blocks stay non-recoverable: `user interrupted the turn` and model-declared
23+
`update_goal` blocks are still excluded, because those encode a decision to stop.
24+
- This is the in-session counterpart to the restart resume prompt below: that entry recovers
25+
a stopped goal when a new session loads it, this one recovers it mid-session without a
26+
restart or a prompt.
27+
28+
### Why an extension couldn't do it
29+
30+
- Both the block-reason writer and the mechanical-block classifier live inside this builtin;
31+
the policy has no public extension hook.
32+
33+
### Expected merge-conflict zones
34+
35+
- `continuation-recovery.ts` `MECHANICAL_CONTINUATION_BLOCKS` and its exported reason constants.
36+
- `index.ts` `agent_end` terminal-provider-error branch and its import block.
37+
338
## Restart resume prompt covers every stopped-but-unfinished goal (2026-08-04)
439

540
### What changed

packages/coding-agent/src/core/extensions/builtin/goal/continuation-recovery.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
export const CONTINUATION_CAP_BLOCKED_REASON = "continuation cap reached";
22
export const REPETITION_BLOCKED_REASON = "repeated assistant output";
33
export const LENGTH_EXHAUSTED_BLOCKED_REASON = "output truncation repeated";
4+
export const PROVIDER_ERROR_BLOCKED_REASON = "provider error ended the turn (retries exhausted)";
45

6+
// Mechanical blocks are stops the runtime imposed on itself, not decisions the
7+
// user or the model made. A terminal provider error belongs here: the provider
8+
// failing is infrastructure, and the user's next message is exactly the retry
9+
// signal, so the goal resumes instead of stranding behind a block that only
10+
// `/goal resume` could clear.
511
const MECHANICAL_CONTINUATION_BLOCKS: readonly string[] = [
612
CONTINUATION_CAP_BLOCKED_REASON,
713
REPETITION_BLOCKED_REASON,
814
LENGTH_EXHAUSTED_BLOCKED_REASON,
15+
PROVIDER_ERROR_BLOCKED_REASON,
916
];
1017

1118
const RESUME_GUIDANCE = "Send any message to resume.";

packages/coding-agent/src/core/extensions/builtin/goal/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GOAL_CACHE_WARMUP_ENTRY_TYPE } from "./cache-warm.ts";
55
import { renderGoalCacheWarmupEntry } from "./cache-warm-renderer.ts";
66
import { registerGoalCommand } from "./command-registration.ts";
77
import { GOAL_CONTINUATION_CAP } from "./continuation.ts";
8+
import { continuationCapRecoveryHint, PROVIDER_ERROR_BLOCKED_REASON } from "./continuation-recovery.ts";
89
import { GoalDirectInputLifecycle } from "./direct-input-lifecycle.ts";
910
import { GoalElapsedTicker } from "./elapsed-ticker.ts";
1011
import { formatGoalForTool, goalStatusLabel } from "./format.ts";
@@ -194,10 +195,15 @@ export default function goalExtension(pi: ExtensionAPI): void {
194195
} else if (didTerminalProviderErrorEndTurn(event) && goal?.status === "active") {
195196
goal = await updateGoal(
196197
goalStoreRef(ctx),
197-
{ status: "blocked", reason: "provider error ended the turn (retries exhausted)" },
198+
{ status: "blocked", reason: PROVIDER_ERROR_BLOCKED_REASON },
198199
"model",
199200
);
200-
if (ctx.hasUI) ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "warning");
201+
if (ctx.hasUI) {
202+
ctx.ui.notify(
203+
`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}\n${continuationCapRecoveryHint(PROVIDER_ERROR_BLOCKED_REASON)}`,
204+
"warning",
205+
);
206+
}
201207
}
202208
if (goal?.status === "active") {
203209
beginAgentGoalAccounting(goal);

packages/coding-agent/test/suite/goal-extension.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,76 @@ describe("goal extension contract (budget-free)", () => {
364364
expect(sent).toHaveLength(0);
365365
});
366366

367+
it("resumes a provider-error-blocked goal when the user sends a new message", async () => {
368+
const { tools, handlers } = createGoalHarness();
369+
const ctx = await makeCtx("thread-provider-error-resume");
370+
await tools
371+
.get("create_goal")
372+
?.execute("c1", { objective: "Survive a provider outage" }, undefined, undefined, ctx);
373+
374+
await runHandlers(handlers, "agent_start", { type: "agent_start" }, ctx);
375+
await runHandlers(
376+
handlers,
377+
"agent_end",
378+
{ type: "agent_end", messages: [assistantMessageWithStopReason("error")], willRetry: false },
379+
ctx,
380+
);
381+
expect((await readGoal(storeRefFor(ctx)))?.status).toBe("blocked");
382+
383+
await runHandlers(
384+
handlers,
385+
"input",
386+
{ type: "input", inputId: "provider-error-resume", text: "keep going", source: "interactive" },
387+
ctx,
388+
);
389+
await runHandlers(
390+
handlers,
391+
"input_disposition",
392+
{ type: "input_disposition", inputId: "provider-error-resume", disposition: "started" },
393+
ctx,
394+
);
395+
396+
expect(await readGoal(storeRefFor(ctx))).toMatchObject({ status: "active" });
397+
});
398+
399+
it("leaves a user-interrupted goal blocked when the user sends a new message", async () => {
400+
const { tools, handlers } = createGoalHarness();
401+
const ctx = await makeCtx("thread-user-abort-no-resume");
402+
await tools.get("create_goal")?.execute("c1", { objective: "Stay stopped" }, undefined, undefined, ctx);
403+
404+
await runHandlers(handlers, "agent_start", { type: "agent_start" }, ctx);
405+
await runHandlers(
406+
handlers,
407+
"agent_end",
408+
{
409+
type: "agent_end",
410+
messages: [assistantMessageWithStopReason("aborted")],
411+
aborted: true,
412+
abortSource: "user",
413+
willRetry: false,
414+
},
415+
ctx,
416+
);
417+
418+
await runHandlers(
419+
handlers,
420+
"input",
421+
{ type: "input", inputId: "user-abort-no-resume", text: "hello", source: "interactive" },
422+
ctx,
423+
);
424+
await runHandlers(
425+
handlers,
426+
"input_disposition",
427+
{ type: "input_disposition", inputId: "user-abort-no-resume", disposition: "started" },
428+
ctx,
429+
);
430+
431+
expect(await readGoal(storeRefFor(ctx))).toMatchObject({
432+
status: "blocked",
433+
blockedReason: "user interrupted the turn",
434+
});
435+
});
436+
367437
it("preserves the user-abort block reason", async () => {
368438
const { tools, handlers } = createGoalHarness();
369439
const ctx = await makeCtx("thread-user-abort-provider-guard");

packages/coding-agent/test/suite/regressions/goal-cap-recovery-guidance.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
continuationCapRecoveryHint,
44
isMechanicalContinuationBlock,
5+
PROVIDER_ERROR_BLOCKED_REASON,
56
} from "../../../src/core/extensions/builtin/goal/continuation-recovery.ts";
67

78
// The cap stays as a runaway backstop; tripping it must not strand the user.
@@ -12,9 +13,17 @@ describe("goal continuation cap recovery guidance", () => {
1213
expect(isMechanicalContinuationBlock("output truncation repeated")).toBe(true);
1314
});
1415

16+
// A terminal provider error is infrastructure, not a decision: the next user
17+
// message is exactly the retry signal, so the goal must resume instead of
18+
// stranding the run behind a block only /goal resume could clear.
19+
it("classifies a terminal provider error as prompt-recoverable", () => {
20+
expect(PROVIDER_ERROR_BLOCKED_REASON).toBe("provider error ended the turn (retries exhausted)");
21+
expect(isMechanicalContinuationBlock(PROVIDER_ERROR_BLOCKED_REASON)).toBe(true);
22+
expect(continuationCapRecoveryHint(PROVIDER_ERROR_BLOCKED_REASON)).toMatch(/send any message to resume/i);
23+
});
24+
1525
it("does not classify intentional blocks as mechanical", () => {
1626
expect(isMechanicalContinuationBlock("user interrupted the turn")).toBe(false);
17-
expect(isMechanicalContinuationBlock("provider error ended the turn (retries exhausted)")).toBe(false);
1827
expect(isMechanicalContinuationBlock("Waiting on a user decision")).toBe(false);
1928
expect(isMechanicalContinuationBlock(undefined)).toBe(false);
2029
});

0 commit comments

Comments
 (0)