Skip to content

Commit aecb99d

Browse files
authored
Merge pull request #687 from code-yeongyu/feat/goal-resume-blocked-on-restart
fix(goal): prompt to resume blocked goals on restart
2 parents f59be4f + 5d86a7f commit aecb99d

7 files changed

Lines changed: 302 additions & 9 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ is false, and resumed sessions with 8+ trailing historical continuation entries
5454
session-start auto-resume. `tokenBudget` remains inert compatibility metadata only; this
5555
policy is budget-free by design.
5656

57+
## RESTART RESUME PROMPT
58+
59+
On `session_start` with reason `resume`, an idle TUI session with no pending
60+
messages prompts before doing anything else when the stored goal is stopped but
61+
unfinished — `paused` or `blocked`. `isResumeOfStoppedGoal` (lifecycle-helpers.ts)
62+
owns that admission and `maybePromptResumeStoppedGoal` (index.ts) renders it;
63+
the title names the actual status (`Resume blocked goal?`). Accepting flips the
64+
goal to `active` as a `"user"` mutation and queues a continuation; declining
65+
leaves the status untouched. `active` and `complete` goals never prompt. This
66+
mirrors codex `maybe_prompt_resume_paused_goal_after_resume`, minus its
67+
`UsageLimited` arm, which senpi has no counterpart for.
68+
5769
## PERSISTENCE
5870

5971
`store.ts` writes `GoalFile{version:1, goal}` to

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

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

3+
## Restart resume prompt covers every stopped-but-unfinished goal (2026-08-04)
4+
5+
### What changed
6+
7+
- `lifecycle-helpers.ts` renames `isResumeOfPausedGoal` to `isResumeOfStoppedGoal`
8+
and admits the whole stopped-but-unfinished set (`paused` and `blocked`) instead
9+
of `paused` alone. The idle / has-UI / no-pending-messages guards and the
10+
`"resume"` session-start reason are unchanged.
11+
- `index.ts` renames `maybePromptResumePausedGoal` to
12+
`maybePromptResumeStoppedGoal`, renames the `LEAVE_GOAL_PAUSED_CHOICE` constant
13+
to `LEAVE_GOAL_STOPPED_CHOICE` (`"Leave stopped"`), and interpolates the goal's
14+
real status into the prompt title (`Resume blocked goal?` / `Resume paused
15+
goal?`) so the dialog names the state the user is actually resuming from.
16+
- Accepting the prompt is unchanged: the goal flips to `active` via a `"user"`
17+
mutation, accounting restarts, the footer refreshes, and a continuation is
18+
queued through the same admission path.
19+
20+
### Why
21+
22+
- Ports the upstream codex rule in
23+
`codex-rs/tui/src/app/thread_goal_actions.rs`
24+
(`maybe_prompt_resume_paused_goal_after_resume`), which prompts on resume for
25+
`Paused | Blocked | UsageLimited` — every status that stopped the goal without
26+
finishing it. senpi previously ported only the `paused` arm.
27+
- A `blocked` goal was unrecoverable on restart: no prompt fired, and the
28+
session-start auto-continuation denied it with `not-eligible` because the
29+
status is not `active`. The goal stayed blocked with no user-visible
30+
affordance, even though `blocked` is reached by ordinary events — a user
31+
interrupt, a terminal provider error, or a tripped continuation guard.
32+
- senpi stays budget-free, so codex's `UsageLimited` arm has no counterpart and
33+
no budget status is introduced. The senpi stopped set is exactly
34+
`paused | blocked`; `complete` and `active` are untouched.
35+
36+
### Expected merge conflict zones on the next sync
37+
38+
- LOW in `lifecycle-helpers.ts` around the renamed predicate and its status set;
39+
standalone `pi-goal` has no restart resume prompt.
40+
- LOW in `index.ts` around the `session_start` handler's resume-prompt call and
41+
the choice constants.
42+
343
## Legacy `pi-goal` state is imported once at session start (2026-07-31)
444

545
### What changed

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { GOAL_CONTINUATION_CAP } from "./continuation.ts";
88
import { GoalDirectInputLifecycle } from "./direct-input-lifecycle.ts";
99
import { GoalElapsedTicker } from "./elapsed-ticker.ts";
1010
import { formatGoalForTool, goalStatusLabel } from "./format.ts";
11-
import { isResumeOfPausedGoal, queueGoalContinuation } from "./lifecycle-helpers.ts";
11+
import { isResumeOfStoppedGoal, queueGoalContinuation } from "./lifecycle-helpers.ts";
1212
import { MonitorAwareGoalContinuation } from "./monitor-continuation.ts";
1313
import { migrateLegacyGoalFile } from "./persistence.ts";
1414
import { accountGoalUsage, readGoal, updateGoal } from "./store.ts";
@@ -21,7 +21,7 @@ import { updateGoalUi } from "./ui.ts";
2121
import { GOAL_WAIT_STATUS_KEY, GoalWaitTicker } from "./wait-ticker.ts";
2222

2323
const RESUME_GOAL_CHOICE = "Resume goal";
24-
const LEAVE_GOAL_PAUSED_CHOICE = "Leave paused";
24+
const LEAVE_GOAL_STOPPED_CHOICE = "Leave stopped";
2525
const STALE_EXTENSION_CONTEXT_ERROR_PREFIX = "This extension ctx is stale after session replacement or reload.";
2626

2727
type AgentGoalAccounting = {
@@ -109,7 +109,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
109109
clearAgentGoalAccounting();
110110
}
111111
refreshGoalUi(ctx, goal);
112-
if (await maybePromptResumePausedGoal(pi, ctx, event.reason, goal)) {
112+
if (await maybePromptResumeStoppedGoal(pi, ctx, event.reason, goal)) {
113113
return;
114114
}
115115
// A config reload must not auto-start an agent that was stopped. Only a fresh
@@ -241,19 +241,19 @@ export default function goalExtension(pi: ExtensionAPI): void {
241241
monitorContinuation.dispose();
242242
});
243243

244-
async function maybePromptResumePausedGoal(
244+
async function maybePromptResumeStoppedGoal(
245245
pi: ExtensionAPI,
246246
ctx: ExtensionContext,
247247
sessionStartReason: string,
248248
goal: Goal | null,
249249
): Promise<boolean> {
250-
if (!isResumeOfPausedGoal(ctx, sessionStartReason, goal)) {
250+
if (!isResumeOfStoppedGoal(ctx, sessionStartReason, goal)) {
251251
return false;
252252
}
253253

254-
const choice = await ctx.ui.select(`Resume paused goal?\nGoal: ${goal.objective}`, [
254+
const choice = await ctx.ui.select(`Resume ${goal.status} goal?\nGoal: ${goal.objective}`, [
255255
RESUME_GOAL_CHOICE,
256-
LEAVE_GOAL_PAUSED_CHOICE,
256+
LEAVE_GOAL_STOPPED_CHOICE,
257257
]);
258258
if (choice !== RESUME_GOAL_CHOICE) return true;
259259

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,27 @@ export type SessionStartContinuationOptions = {
3535
readonly markContinuationPending: () => void;
3636
};
3737

38-
export function isResumeOfPausedGoal(
38+
/**
39+
* Statuses that leave a goal stopped without finishing it. A restart is the only
40+
* moment the user can act on them, so both earn the resume prompt.
41+
*/
42+
const STOPPED_UNFINISHED_GOAL_STATUSES: readonly Goal["status"][] = ["paused", "blocked"];
43+
44+
/**
45+
* Mirrors codex `maybe_prompt_resume_paused_goal_after_resume`
46+
* (codex-rs/tui/src/app/thread_goal_actions.rs), which prompts on resume for every
47+
* stopped-but-unfinished status. senpi is budget-free, so codex's `UsageLimited`
48+
* has no counterpart here and the stopped set is `paused | blocked`.
49+
*/
50+
export function isResumeOfStoppedGoal(
3951
ctx: ExtensionContext,
4052
sessionStartReason: string,
4153
goal: Goal | null,
4254
): goal is Goal {
4355
return (
4456
sessionStartReason === "resume" &&
45-
goal?.status === "paused" &&
57+
goal !== null &&
58+
STOPPED_UNFINISHED_GOAL_STATUSES.includes(goal.status) &&
4659
ctx.hasUI &&
4760
ctx.isIdle() &&
4861
!ctx.hasPendingMessages()
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Real-surface QA driver (manual-qa; not part of the default suite).
3+
*
4+
* Drives the REAL builtin goal extension across a simulated process restart to
5+
* prove that a `blocked` goal produces the restart resume prompt, that accepting
6+
* reactivates it and queues a continuation, and that declining leaves it blocked.
7+
*
8+
* Run: npx vitest run test/manual-qa/goal-blocked-resume-restart.test.ts
9+
*/
10+
import { mkdtemp, rm } from "node:fs/promises";
11+
import { tmpdir } from "node:os";
12+
import { join } from "node:path";
13+
import { expect, it } from "vitest";
14+
import goalExtension from "../../src/core/extensions/builtin/goal/index.ts";
15+
import { readGoal } from "../../src/core/extensions/builtin/goal/store.ts";
16+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "../../src/core/extensions/types.ts";
17+
18+
type AnyTool = ToolDefinition<any, any, any>;
19+
type Handler = (event: unknown, ctx: ExtensionContext) => Promise<unknown> | unknown;
20+
21+
const THREAD = "qa-thread-blocked";
22+
const transcript: string[] = [];
23+
const say = (line: string): void => {
24+
transcript.push(line);
25+
console.log(line);
26+
};
27+
28+
function makeSession(dir: string, onSelect: (options: string[]) => string | undefined) {
29+
const tools = new Map<string, AnyTool>();
30+
const handlers = new Map<string, Handler[]>();
31+
const sent: Array<{ customType: string }> = [];
32+
const prompts: Array<{ prompt: string; options: string[] }> = [];
33+
const pi = {
34+
registerTool: (tool: AnyTool) => tools.set(tool.name, tool),
35+
registerCommand: () => {},
36+
on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
37+
sendMessage: (message: { customType: string }) => sent.push(message),
38+
registerEntryRenderer: () => {},
39+
appendEntry: () => {},
40+
} as unknown as ExtensionAPI;
41+
goalExtension(pi);
42+
const ctx = {
43+
hasUI: true,
44+
cwd: dir,
45+
isIdle: () => true,
46+
hasPendingMessages: () => false,
47+
ui: {
48+
notify: () => {},
49+
setStatus: () => {},
50+
select: async (prompt: string, options: string[]) => {
51+
prompts.push({ prompt, options });
52+
return onSelect(options);
53+
},
54+
},
55+
sessionManager: {
56+
getSessionFile: () => join(dir, "session.jsonl"),
57+
getSessionDir: () => dir,
58+
getSessionId: () => THREAD,
59+
getBranch: () => [],
60+
},
61+
} as unknown as ExtensionContext;
62+
const fire = async (event: string, payload: unknown): Promise<void> => {
63+
for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
64+
};
65+
return { tools, ctx, sent, prompts, fire };
66+
}
67+
68+
it("prompts to resume a blocked goal after a process restart", async () => {
69+
const dir = await mkdtemp(join(tmpdir(), "senpi-qa-goal-resume-"));
70+
const storeRef = { baseDir: join(dir, "extensions", "goal"), threadId: THREAD };
71+
72+
// Session A: the goal gets blocked, then the process goes away.
73+
const a = makeSession(dir, () => undefined);
74+
await a.tools
75+
.get("create_goal")
76+
?.execute("c1", { objective: "Finish the release checklist" }, undefined, undefined, a.ctx);
77+
await a.tools
78+
.get("update_goal")
79+
?.execute("u1", { status: "blocked", reason: "user interrupted the turn" }, undefined, undefined, a.ctx);
80+
await a.fire("session_shutdown", { type: "session_shutdown" });
81+
const afterA = await readGoal(storeRef);
82+
say(`[session A] persisted status after shutdown: ${afterA?.status} (reason: ${afterA?.blockedReason})`);
83+
expect(afterA?.status).toBe("blocked");
84+
85+
// Session B: fresh process over the same store, resumed -> must prompt.
86+
const b = makeSession(dir, (options) => options[0]);
87+
await b.fire("session_start", { type: "session_start", reason: "resume" });
88+
say(`[session B] resume prompts shown: ${b.prompts.length}`);
89+
for (const entry of b.prompts) {
90+
say(`[session B] prompt body: ${JSON.stringify(entry.prompt)}`);
91+
say(`[session B] prompt options: ${JSON.stringify(entry.options)}`);
92+
}
93+
const afterB = await readGoal(storeRef);
94+
say(`[session B] status after accepting "Resume goal": ${afterB?.status}`);
95+
say(`[session B] continuation queued: ${JSON.stringify(b.sent.map((message) => message.customType))}`);
96+
97+
expect(b.prompts).toHaveLength(1);
98+
expect(b.prompts[0]?.prompt).toContain("Resume blocked goal?");
99+
expect(b.prompts[0]?.prompt).toContain("Finish the release checklist");
100+
expect(b.prompts[0]?.options).toEqual(["Resume goal", "Leave stopped"]);
101+
expect(afterB?.status).toBe("active");
102+
expect(b.sent.map((message) => message.customType)).toEqual(["goal-continuation"]);
103+
104+
// Session C: blocked again, resumed, declined -> stays blocked.
105+
await b.tools
106+
.get("update_goal")
107+
?.execute("u2", { status: "blocked", reason: "user interrupted the turn" }, undefined, undefined, b.ctx);
108+
const c = makeSession(dir, (options) => options[1]);
109+
await c.fire("session_start", { type: "session_start", reason: "resume" });
110+
const afterC = await readGoal(storeRef);
111+
say(`[session C] declined -> status stays: ${afterC?.status}; continuations queued: ${c.sent.length}`);
112+
expect(afterC?.status).toBe("blocked");
113+
expect(c.sent).toHaveLength(0);
114+
115+
await rm(dir, { recursive: true, force: true });
116+
say(`cleanup: rm -rf ${dir}`);
117+
});

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,74 @@ describe("goal extension reload does not auto-start a stopped agent", () => {
546546
});
547547
});
548548

549+
describe("goal extension resume-on-restart prompt (codex parity)", () => {
550+
async function makeSelectingCtx(
551+
prompts: string[],
552+
choice: (options: string[]) => string | undefined,
553+
threadId: string,
554+
): Promise<ExtensionContext> {
555+
const base = await makeCtx(threadId);
556+
return {
557+
...base,
558+
hasUI: true,
559+
ui: {
560+
notify: () => {},
561+
select: async (prompt: string, options: string[]) => {
562+
prompts.push(prompt);
563+
return choice(options);
564+
},
565+
setStatus: () => {},
566+
},
567+
} as unknown as ExtensionContext;
568+
}
569+
570+
it("prompts to resume a blocked goal on session_start reason 'resume'", async () => {
571+
const { tools, handlers, sent } = createGoalHarness();
572+
const prompts: string[] = [];
573+
const ctx = await makeSelectingCtx(prompts, (options) => options[0], "thread-blocked-resume");
574+
await tools.get("create_goal")?.execute("c1", { objective: "Finish the migration" }, undefined, undefined, ctx);
575+
await tools
576+
.get("update_goal")
577+
?.execute("u1", { status: "blocked", reason: "provider error" }, undefined, undefined, ctx);
578+
579+
await runHandlers(handlers, "session_start", { type: "session_start", reason: "resume" }, ctx);
580+
581+
expect(prompts).toHaveLength(1);
582+
expect(prompts[0]).toContain("Finish the migration");
583+
expect((await readGoal(storeRefFor(ctx)))?.status).toBe("active");
584+
expect(sent.map((entry) => entry.message.customType)).toEqual(["goal-continuation"]);
585+
});
586+
587+
it("leaves a blocked goal stopped when the user declines the resume prompt", async () => {
588+
const { tools, handlers, sent } = createGoalHarness();
589+
const prompts: string[] = [];
590+
const ctx = await makeSelectingCtx(prompts, (options) => options[1], "thread-blocked-declined");
591+
await tools.get("create_goal")?.execute("c1", { objective: "Finish the migration" }, undefined, undefined, ctx);
592+
await tools
593+
.get("update_goal")
594+
?.execute("u1", { status: "blocked", reason: "provider error" }, undefined, undefined, ctx);
595+
596+
await runHandlers(handlers, "session_start", { type: "session_start", reason: "resume" }, ctx);
597+
598+
expect(prompts).toHaveLength(1);
599+
expect((await readGoal(storeRefFor(ctx)))?.status).toBe("blocked");
600+
expect(sent).toHaveLength(0);
601+
});
602+
603+
it("never prompts for a completed goal on resume", async () => {
604+
const { tools, handlers } = createGoalHarness();
605+
const prompts: string[] = [];
606+
const ctx = await makeSelectingCtx(prompts, (options) => options[0], "thread-complete-resume");
607+
await tools.get("create_goal")?.execute("c1", { objective: "Finish the migration" }, undefined, undefined, ctx);
608+
await tools.get("update_goal")?.execute("u1", { status: "complete" }, undefined, undefined, ctx);
609+
610+
await runHandlers(handlers, "session_start", { type: "session_start", reason: "resume" }, ctx);
611+
612+
expect(prompts).toHaveLength(0);
613+
expect((await readGoal(storeRefFor(ctx)))?.status).toBe("complete");
614+
});
615+
});
616+
549617
describe("goal extension session_start migration-lite admission", () => {
550618
it("suppresses auto-continuation and notifies when a resumed session ends in a continuation flood", async () => {
551619
const { tools, handlers, sent } = createGoalHarness();

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
formatTokensCompact,
1414
goalToolResponse,
1515
} from "../../src/core/extensions/builtin/goal/format.ts";
16+
import { isResumeOfStoppedGoal } from "../../src/core/extensions/builtin/goal/lifecycle-helpers.ts";
1617
import {
1718
buildContinuationPrompt,
1819
buildGoalStallNotice,
@@ -41,6 +42,48 @@ function makeGoal(overrides: Partial<Goal> = {}): Goal {
4142
};
4243
}
4344

45+
function resumeCtx(overrides: { hasUI?: boolean; isIdle?: boolean; hasPendingMessages?: boolean } = {}) {
46+
return {
47+
hasUI: overrides.hasUI ?? true,
48+
isIdle: () => overrides.isIdle ?? true,
49+
hasPendingMessages: () => overrides.hasPendingMessages ?? false,
50+
} as unknown as Parameters<typeof isResumeOfStoppedGoal>[0];
51+
}
52+
53+
/**
54+
* Mirrors codex `maybe_prompt_resume_paused_goal_after_resume`
55+
* (codex-rs/tui/src/app/thread_goal_actions.rs), which prompts on resume for every
56+
* stopped-but-unfinished status. senpi is budget-free, so its stopped set is
57+
* `paused | blocked` — codex's `UsageLimited` has no senpi counterpart.
58+
*/
59+
describe("goal resume-on-restart admission (codex parity)", () => {
60+
it("prompts on resume for every stopped-but-unfinished status", () => {
61+
for (const status of ["paused", "blocked"] as const) {
62+
expect(isResumeOfStoppedGoal(resumeCtx(), "resume", makeGoal({ status }))).toBe(true);
63+
}
64+
});
65+
66+
it("never prompts for goals that are not stopped-but-unfinished", () => {
67+
for (const status of ["active", "complete"] as const) {
68+
expect(isResumeOfStoppedGoal(resumeCtx(), "resume", makeGoal({ status }))).toBe(false);
69+
}
70+
expect(isResumeOfStoppedGoal(resumeCtx(), "resume", null)).toBe(false);
71+
});
72+
73+
it("only prompts on the resume session-start reason", () => {
74+
for (const reason of ["startup", "reload"]) {
75+
expect(isResumeOfStoppedGoal(resumeCtx(), reason, makeGoal({ status: "blocked" }))).toBe(false);
76+
}
77+
});
78+
79+
it("requires an idle UI session with no pending messages", () => {
80+
const blocked = makeGoal({ status: "blocked" });
81+
expect(isResumeOfStoppedGoal(resumeCtx({ hasUI: false }), "resume", blocked)).toBe(false);
82+
expect(isResumeOfStoppedGoal(resumeCtx({ isIdle: false }), "resume", blocked)).toBe(false);
83+
expect(isResumeOfStoppedGoal(resumeCtx({ hasPendingMessages: true }), "resume", blocked)).toBe(false);
84+
});
85+
});
86+
4487
function assistantMessageWithStopReason(stopReason: "aborted" | "error" | "stop" | "toolUse"): AgentMessage {
4588
return {
4689
role: "assistant",

0 commit comments

Comments
 (0)