pi-plans 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/index.ts +100 -22
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +28 -1
- package/references/plan-artifact-template.md +4 -0
- package/references/state-and-config.md +19 -0
- package/src/autocomplete.ts +2 -1
- package/src/code-graph/commands.ts +21 -6
- package/src/compaction.ts +10 -0
- package/src/config-command.ts +2 -0
- package/src/exec.ts +394 -61
- package/src/guard.ts +7 -1
- package/src/resume-command.ts +450 -0
- package/src/resume.ts +205 -0
- package/src/run-context.ts +97 -0
- package/src/run-ownership.ts +310 -0
- package/src/state.ts +24 -1
- package/src/termination-prompt.ts +8 -0
- package/src/workflow-state.ts +1159 -0
- package/tests/ask-choice.test.ts +113 -0
- package/tests/compaction.test.ts +39 -0
- package/tests/exec-lifecycle.test.ts +137 -0
- package/tests/exec.test.ts +237 -117
- package/tests/goal-wait.test.ts +269 -0
- package/tests/guard.test.ts +46 -0
- package/tests/plans.test.ts +71 -0
- package/tests/refine-resume.test.ts +324 -0
- package/tests/resume-lifecycle.test.ts +385 -0
- package/tests/resume.test.ts +384 -0
- package/tests/run-context.test.ts +119 -0
- package/tests/run-ownership.test.ts +170 -0
- package/tests/state.test.ts +16 -0
- package/tests/workflow-state.test.ts +432 -0
- package/tools/analyze-refs.ts +2 -1
- package/tools/ask-choice.ts +61 -3
- package/tools/code-graph.ts +5 -1
- package/tools/execute-plan.ts +20 -1
- package/tools/plans.ts +119 -0
- package/tools/refine.ts +91 -7
package/src/exec.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
11
13
|
import type {
|
|
12
14
|
CompactionResult,
|
|
13
15
|
ExtensionAPI,
|
|
@@ -24,6 +26,7 @@ import {
|
|
|
24
26
|
entryCurrentIMarkers,
|
|
25
27
|
formatVccCompactionStats,
|
|
26
28
|
loadVccSettings,
|
|
29
|
+
PLANNING_PREPLAN_COMPACT_HINT,
|
|
27
30
|
scaffoldVccSettings,
|
|
28
31
|
shouldScheduleAutoContinue,
|
|
29
32
|
type CompactionEntryLike,
|
|
@@ -33,13 +36,35 @@ import {
|
|
|
33
36
|
type VccCompactionBuildResult,
|
|
34
37
|
type VccCompactionStats,
|
|
35
38
|
} from "./compaction.ts";
|
|
36
|
-
import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
|
|
39
|
+
import { getRun, readActive, resolveStateRootOrNull, setRunStatus, StateError, utcNow } from "./state.ts";
|
|
40
|
+
import { bindRun, resolveActiveRun } from "./run-context.ts";
|
|
41
|
+
import { OwnershipError } from "./run-ownership.ts";
|
|
42
|
+
import {
|
|
43
|
+
applyExecutionApproved,
|
|
44
|
+
applyExecutionCompleted,
|
|
45
|
+
applyExecutionHeadChanged,
|
|
46
|
+
applyExecutionProgress,
|
|
47
|
+
applyExecutionStopped,
|
|
48
|
+
applyQuestionAsked,
|
|
49
|
+
applyReviewRoundStarted,
|
|
50
|
+
createCheckpoint,
|
|
51
|
+
loadCheckpoint,
|
|
52
|
+
mutateCheckpoint,
|
|
53
|
+
StaleCheckpointError,
|
|
54
|
+
planIdentityOf,
|
|
55
|
+
resolveHeadAt,
|
|
56
|
+
resolveWorktreeRoot,
|
|
57
|
+
sha256File,
|
|
58
|
+
type ExecutionApproval,
|
|
59
|
+
} from "./workflow-state.ts";
|
|
37
60
|
import { graphBlockForExecutor } from "./code-graph/prompts.ts";
|
|
38
61
|
import { resolveGraphMode } from "./code-graph/mode.ts";
|
|
39
|
-
import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
|
|
62
|
+
import { TERMINATION_QUESTION, TERMINATION_OPTIONS, TERMINATION_RECORDING_INSTRUCTIONS, renderTerminationOptions } from "./termination-prompt.ts";
|
|
40
63
|
import {
|
|
41
64
|
extractCoverage,
|
|
42
65
|
latestPlanVersion,
|
|
66
|
+
parseChecklist,
|
|
67
|
+
parseImplItems,
|
|
43
68
|
resolveImplStatuses,
|
|
44
69
|
scanDoneMarkers,
|
|
45
70
|
scanImplMarkers,
|
|
@@ -76,6 +101,32 @@ const GOAL_WAIT_MAX_WAITING = 6;
|
|
|
76
101
|
|
|
77
102
|
let execution: ExecState | null = null;
|
|
78
103
|
|
|
104
|
+
export const GOAL_WAIT_CUSTOM_TYPE = "pi-plans-goal-wait";
|
|
105
|
+
|
|
106
|
+
interface GoalWaitRuntime {
|
|
107
|
+
owner: ExecState;
|
|
108
|
+
session: ExtensionContext["sessionManager"];
|
|
109
|
+
handled: boolean;
|
|
110
|
+
stopReason?: string;
|
|
111
|
+
text: string;
|
|
112
|
+
wakeId?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Dispatch identity belongs to a live session, never to a persisted checklist.
|
|
116
|
+
let goalWaitRuntime: GoalWaitRuntime | null = null;
|
|
117
|
+
|
|
118
|
+
function resetGoalWaitRuntime(ctx: ExtensionContext): void {
|
|
119
|
+
goalWaitRuntime = execution
|
|
120
|
+
? { owner: execution, session: ctx.sessionManager, handled: false, text: "" }
|
|
121
|
+
: null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function currentGoalWaitRuntime(ctx: ExtensionContext): GoalWaitRuntime | null {
|
|
125
|
+
return goalWaitRuntime?.owner === execution && goalWaitRuntime.session === ctx.sessionManager
|
|
126
|
+
? goalWaitRuntime
|
|
127
|
+
: null;
|
|
128
|
+
}
|
|
129
|
+
|
|
79
130
|
// Execution-loop persistence is deferred until the agent settles so turn_end
|
|
80
131
|
// never causes session writes during a streaming run.
|
|
81
132
|
let pendingExecutionFlush = false;
|
|
@@ -104,6 +155,105 @@ export function getExecution(): ExecState | null {
|
|
|
104
155
|
return execution;
|
|
105
156
|
}
|
|
106
157
|
|
|
158
|
+
export interface CheckpointExecutionLoad {
|
|
159
|
+
status: "loaded" | "no-execution" | "plan-missing" | "plan-mismatch" | "no-checkpoint" | "corrupt";
|
|
160
|
+
planPath?: string;
|
|
161
|
+
doneVcIds?: string[];
|
|
162
|
+
reverifyAll?: boolean;
|
|
163
|
+
pausedReason?: string;
|
|
164
|
+
error?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Shared restore primitive (I-005/I-006): load the executing state from a run
|
|
169
|
+
* checkpoint into THIS session. Authorization is kept only when the recorded
|
|
170
|
+
* approval matches the current plan digest; a HEAD change keeps the
|
|
171
|
+
* authorization but re-verifies previously verified VCs (D-011/F-001).
|
|
172
|
+
* F-002: the loaded state is persisted to the current session IMMEDIATELY so
|
|
173
|
+
* session_start/session_tree restore paths cannot silently clear it.
|
|
174
|
+
*/
|
|
175
|
+
export function loadExecutionFromCheckpoint(
|
|
176
|
+
pi: ExtensionAPI,
|
|
177
|
+
ctx: ExtensionContext,
|
|
178
|
+
runId: string,
|
|
179
|
+
): CheckpointExecutionLoad {
|
|
180
|
+
const load = loadCheckpoint(ctx.cwd, runId);
|
|
181
|
+
if (load.status === "missing") return { status: "no-checkpoint" };
|
|
182
|
+
if (load.status === "corrupt") return { status: "corrupt", error: load.error };
|
|
183
|
+
const cp = load.checkpoint;
|
|
184
|
+
if (!cp.execution || cp.phase !== "executing") return { status: "no-execution" };
|
|
185
|
+
const planPath = cp.plan?.path;
|
|
186
|
+
if (!planPath || !fs.existsSync(planPath)) {
|
|
187
|
+
return { status: "plan-missing", error: planPath ? `plan file vanished: ${planPath}` : "checkpoint has no plan identity" };
|
|
188
|
+
}
|
|
189
|
+
const planText = fs.readFileSync(planPath, "utf8");
|
|
190
|
+
// F-002 (implementation review): the recorded plan identity is over BYTES —
|
|
191
|
+
// an in-place edit at the same path must not inherit the authorization or
|
|
192
|
+
// the verified VCs. Refuse the load and require a fresh handoff.
|
|
193
|
+
if (sha256File(planPath) !== cp.plan.sha256) {
|
|
194
|
+
return {
|
|
195
|
+
status: "plan-mismatch" as const,
|
|
196
|
+
error: `plan file changed since the approval record (${planPath}); re-approve via /plans-execute before executing`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const items = parseChecklist(planText);
|
|
200
|
+
if (items.length === 0) {
|
|
201
|
+
return { status: "plan-missing", error: `${planPath} has no parsable verifier checklist` };
|
|
202
|
+
}
|
|
203
|
+
const implItems = parseImplItems(planText);
|
|
204
|
+
const doneIds = new Set(cp.execution.doneVcIds);
|
|
205
|
+
// D-011/F-001: an unchanged plan digest keeps the recorded authorization;
|
|
206
|
+
// a changed HEAD under it forces re-verification of previously verified VCs.
|
|
207
|
+
// F-006 (implementation review): an approval without a resolvable HEAD
|
|
208
|
+
// recorded an unverifiable code state — re-verify instead of trusting.
|
|
209
|
+
const headNow = resolveHeadAt(ctx.cwd);
|
|
210
|
+
const headUnverifiable = cp.execution.approval === null || cp.execution.approval.headAtApproval === null;
|
|
211
|
+
const headChanged =
|
|
212
|
+
cp.execution.approval !== null &&
|
|
213
|
+
cp.execution.approval.headAtApproval !== null &&
|
|
214
|
+
cp.execution.approval.headAtApproval !== headNow;
|
|
215
|
+
const reverifyAll = cp.execution.reverifyAll === true || headChanged || headUnverifiable;
|
|
216
|
+
if (!reverifyAll) {
|
|
217
|
+
for (const item of items) {
|
|
218
|
+
if (doneIds.has(item.id)) item.done = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
execution = {
|
|
222
|
+
planPath,
|
|
223
|
+
items,
|
|
224
|
+
startedAt: utcNow(),
|
|
225
|
+
usage: { inToks: cp.execution.usage.inToks, outToks: cp.execution.usage.outToks },
|
|
226
|
+
implItems,
|
|
227
|
+
implStatus: { ...cp.execution.implStatus },
|
|
228
|
+
currentI: cp.execution.currentI,
|
|
229
|
+
goalWait: {
|
|
230
|
+
noProgressRounds: 0,
|
|
231
|
+
waitRounds: 0,
|
|
232
|
+
lastMarkers: null,
|
|
233
|
+
paused: cp.execution.pausedReason !== undefined,
|
|
234
|
+
pausedReason: cp.execution.pausedReason,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
executionRunId = runId;
|
|
238
|
+
bindRun(ctx.sessionManager, ctx.cwd, runId);
|
|
239
|
+
resetGoalWaitRuntime(ctx);
|
|
240
|
+
pendingExecutionFlush = false; // restored state: no inherited flush debt
|
|
241
|
+
resetExecutionCompactionState(ctx);
|
|
242
|
+
if (headChanged) {
|
|
243
|
+
withExecutionCheckpoint(ctx, (current) => applyExecutionHeadChanged(current));
|
|
244
|
+
}
|
|
245
|
+
if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
|
|
246
|
+
persist(pi); // F-002: immediate session snapshot
|
|
247
|
+
updateStatusWidget(ctx);
|
|
248
|
+
return {
|
|
249
|
+
status: "loaded",
|
|
250
|
+
planPath,
|
|
251
|
+
doneVcIds: [...doneIds],
|
|
252
|
+
reverifyAll,
|
|
253
|
+
pausedReason: cp.execution.pausedReason,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
107
257
|
const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
|
|
108
258
|
|
|
109
259
|
interface ExecutionCompactionState {
|
|
@@ -228,7 +378,7 @@ export function updateStatusWidget(ctx: ExtensionContext): void {
|
|
|
228
378
|
ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
|
|
229
379
|
return;
|
|
230
380
|
}
|
|
231
|
-
const active =
|
|
381
|
+
const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
|
|
232
382
|
if (active) {
|
|
233
383
|
// Idle indicator depends on the run's lifecycle, not just its existence:
|
|
234
384
|
// done reads as finished, abandoned as closed, stopped/accepted as paused.
|
|
@@ -275,6 +425,24 @@ function persist(pi: ExtensionAPI): void {
|
|
|
275
425
|
});
|
|
276
426
|
}
|
|
277
427
|
|
|
428
|
+
/** Checkpoint bookkeeping for the executing run; best-effort for legacy runs
|
|
429
|
+
* without checkpoints (their cross-session resume degrades to R-008 rules). */
|
|
430
|
+
function withExecutionCheckpoint(ctx: ExtensionContext, mutator: (cp: import("./workflow-state.ts").WorkflowCheckpoint) => import("./workflow-state.ts").WorkflowCheckpoint): void {
|
|
431
|
+
if (!execution) return;
|
|
432
|
+
const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
|
|
433
|
+
if (!active || active.run_id !== executionRunId) return;
|
|
434
|
+
try {
|
|
435
|
+
mutateCheckpoint(ctx.cwd, active.run_id, mutator);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// F-005 (implementation review): ownership loss and revision staleness
|
|
438
|
+
// must stop the advance, not vanish into the catch block.
|
|
439
|
+
if (error instanceof OwnershipError || error instanceof StaleCheckpointError) throw error;
|
|
440
|
+
/* legacy run or corrupt checkpoint: session snapshot still carries the loop */
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let executionRunId: string | null = null;
|
|
445
|
+
|
|
278
446
|
export async function startExecution(
|
|
279
447
|
pi: ExtensionAPI,
|
|
280
448
|
ctx: ExtensionContext,
|
|
@@ -286,11 +454,44 @@ export async function startExecution(
|
|
|
286
454
|
// Seed the marker baseline so the first quiet round is counted against a
|
|
287
455
|
// real snapshot instead of counting unconditionally (F-006).
|
|
288
456
|
if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
|
|
457
|
+
resetGoalWaitRuntime(ctx);
|
|
289
458
|
pendingExecutionFlush = false; // fresh run: no inherited flush debt
|
|
290
459
|
resetExecutionCompactionState(ctx);
|
|
291
460
|
persist(pi);
|
|
292
|
-
const active =
|
|
461
|
+
const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
|
|
462
|
+
executionRunId = active?.run_id ?? null;
|
|
293
463
|
if (active) {
|
|
464
|
+
bindRun(ctx.sessionManager, ctx.cwd, active.run_id);
|
|
465
|
+
// I-005: durable approval evidence — run + plan digest + HEAD at
|
|
466
|
+
// approval (D-003/D-011). Sets phase executing via the state machine.
|
|
467
|
+
try {
|
|
468
|
+
const load = loadCheckpoint(ctx.cwd, active.run_id);
|
|
469
|
+
if (load.status === "missing") {
|
|
470
|
+
createCheckpoint(ctx.cwd, { runId: active.run_id, originWorkdir: ctx.cwd, workdir: ctx.cwd });
|
|
471
|
+
}
|
|
472
|
+
const approval: ExecutionApproval = {
|
|
473
|
+
plan: planIdentityOf(path.resolve(planPath), 1),
|
|
474
|
+
worktree: resolveWorktreeRoot(ctx.cwd) ?? path.resolve(ctx.cwd),
|
|
475
|
+
headAtApproval: resolveHeadAt(ctx.cwd),
|
|
476
|
+
approvedAt: utcNow(),
|
|
477
|
+
};
|
|
478
|
+
mutateCheckpoint(ctx.cwd, active.run_id, (cp) => {
|
|
479
|
+
// Plan refinement may not have recorded the plan identity yet.
|
|
480
|
+
const withPlan = cp.plan === null ? { ...cp, plan: approval.plan } : cp;
|
|
481
|
+
// The checkpoint may not carry accept-execute (legacy flow);
|
|
482
|
+
// approval here came from the explicit handoff confirmation.
|
|
483
|
+
const aligned = withPlan.nextAction === "accept-execute"
|
|
484
|
+
? withPlan
|
|
485
|
+
: { ...withPlan, nextAction: "accept-execute" as const };
|
|
486
|
+
return applyExecutionApproved(aligned, approval);
|
|
487
|
+
});
|
|
488
|
+
} catch (error) {
|
|
489
|
+
// F-002 (implementation review): a plan-digest mismatch between the
|
|
490
|
+
// recorded checkpoint plan and the approval must fail closed and
|
|
491
|
+
// visibly — never silently execute without durable approval.
|
|
492
|
+
if (error instanceof StateError && /does not match/.test(error.message)) throw error;
|
|
493
|
+
/* legacy/corrupt checkpoint: run status still transitions below */
|
|
494
|
+
}
|
|
294
495
|
try {
|
|
295
496
|
setRunStatus(ctx.cwd, active.run_id, "executing");
|
|
296
497
|
} catch {
|
|
@@ -320,10 +521,30 @@ export function recordExecutionTurn(
|
|
|
320
521
|
execution.usage.inToks += usage.input;
|
|
321
522
|
execution.usage.outToks += usage.output;
|
|
322
523
|
}
|
|
524
|
+
// I-005: mirror progress into the run checkpoint so a different session
|
|
525
|
+
// can resume with the verified VC/I set (R-004).
|
|
526
|
+
withExecutionCheckpoint(_ctx, (cp) =>
|
|
527
|
+
applyExecutionProgress(cp, {
|
|
528
|
+
doneVcIds: execution!.items.filter((item) => item.done).map((item) => item.id),
|
|
529
|
+
implStatus: implStatusSnapshot(),
|
|
530
|
+
currentI: execution!.currentI,
|
|
531
|
+
usage: usage ? { inToks: usage.input, outToks: usage.output } : undefined,
|
|
532
|
+
}),
|
|
533
|
+
);
|
|
323
534
|
requestExecutionFlush(pi, _ctx);
|
|
324
535
|
updateStatusWidget(_ctx);
|
|
325
536
|
}
|
|
326
537
|
|
|
538
|
+
function implStatusSnapshot(): Record<string, string> {
|
|
539
|
+
const snapshot: Record<string, string> = {};
|
|
540
|
+
if (!execution?.implItems) return snapshot;
|
|
541
|
+
for (const item of execution.implItems) {
|
|
542
|
+
const state = execution.implStatus?.[item.id];
|
|
543
|
+
if (state) snapshot[item.id] = state;
|
|
544
|
+
}
|
|
545
|
+
return snapshot;
|
|
546
|
+
}
|
|
547
|
+
|
|
327
548
|
export function registerExecutionTurnHandlers(
|
|
328
549
|
pi: ExtensionAPI,
|
|
329
550
|
onTurnEnd?: (ctx: ExtensionContext) => Promise<void> | void,
|
|
@@ -331,6 +552,31 @@ export function registerExecutionTurnHandlers(
|
|
|
331
552
|
// The turn_end projection does not carry usage; message_end delivers the
|
|
332
553
|
// full assistant message, so cache it here and consume it per turn.
|
|
333
554
|
let lastAssistantUsage: { input: number; output: number } | null = null;
|
|
555
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
556
|
+
const runtime = currentGoalWaitRuntime(ctx);
|
|
557
|
+
if (!runtime) return;
|
|
558
|
+
runtime.handled = false;
|
|
559
|
+
runtime.stopReason = undefined;
|
|
560
|
+
runtime.text = "";
|
|
561
|
+
});
|
|
562
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
563
|
+
const runtime = currentGoalWaitRuntime(ctx);
|
|
564
|
+
if (runtime) runtime.wakeId = undefined;
|
|
565
|
+
});
|
|
566
|
+
pi.on("input", async (event, ctx) => {
|
|
567
|
+
if (event.source === "interactive" || event.source === "rpc") resumeGoalWaitIfPaused(pi, ctx);
|
|
568
|
+
});
|
|
569
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
570
|
+
drainExecutionFlush(pi, ctx);
|
|
571
|
+
maybeGoalWaitFollowUp(pi, ctx);
|
|
572
|
+
});
|
|
573
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
574
|
+
drainExecutionFlush(pi, ctx);
|
|
575
|
+
execution = null;
|
|
576
|
+
executionRunId = null;
|
|
577
|
+
goalWaitRuntime = null;
|
|
578
|
+
lastAssistantUsage = null;
|
|
579
|
+
});
|
|
334
580
|
pi.on("message_end", async (event) => {
|
|
335
581
|
const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
|
|
336
582
|
if (message?.role === "assistant" && message.usage) {
|
|
@@ -339,7 +585,7 @@ export function registerExecutionTurnHandlers(
|
|
|
339
585
|
});
|
|
340
586
|
|
|
341
587
|
pi.on("turn_end", async (event, ctx) => {
|
|
342
|
-
const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
|
|
588
|
+
const message = event.message as { role?: string; stopReason?: string; content?: Array<{ type: string; text?: string }> };
|
|
343
589
|
if (!message || message.role !== "assistant") {
|
|
344
590
|
updateStatusWidget(ctx);
|
|
345
591
|
return;
|
|
@@ -348,6 +594,11 @@ export function registerExecutionTurnHandlers(
|
|
|
348
594
|
.filter((part) => part.type === "text")
|
|
349
595
|
.map((part) => part.text ?? "")
|
|
350
596
|
.join("\n");
|
|
597
|
+
const runtime = currentGoalWaitRuntime(ctx);
|
|
598
|
+
if (runtime) {
|
|
599
|
+
runtime.stopReason = message.stopReason;
|
|
600
|
+
runtime.text = text;
|
|
601
|
+
}
|
|
351
602
|
const changedIds = applyDoneMarkers(text);
|
|
352
603
|
const changedImpls = applyImplMarkers(text);
|
|
353
604
|
const changedCurrentI = applyCurrentIMarker(text);
|
|
@@ -361,8 +612,6 @@ export function registerExecutionTurnHandlers(
|
|
|
361
612
|
}
|
|
362
613
|
if (getExecution() && isExecutionComplete()) {
|
|
363
614
|
await completeExecution(pi, ctx);
|
|
364
|
-
} else if (getExecution()) {
|
|
365
|
-
maybeGoalWaitFollowUp(pi, ctx, text);
|
|
366
615
|
}
|
|
367
616
|
await onTurnEnd?.(ctx);
|
|
368
617
|
});
|
|
@@ -373,7 +622,7 @@ const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
|
|
|
373
622
|
function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
|
|
374
623
|
const stateRoot = resolveStateRootOrNull(ctx.cwd);
|
|
375
624
|
if (!stateRoot) return null;
|
|
376
|
-
const active =
|
|
625
|
+
const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
|
|
377
626
|
if (!active) return null;
|
|
378
627
|
const run = getRun(ctx.cwd, active.run_id);
|
|
379
628
|
if (!run) return null;
|
|
@@ -492,7 +741,6 @@ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionCon
|
|
|
492
741
|
if (!event.willRetry && stats) {
|
|
493
742
|
ctx.ui.notify(formatVccCompactionStats(stats), "info");
|
|
494
743
|
if (followUpPrompt) {
|
|
495
|
-
compactionFollowUpSentThisTurn = true;
|
|
496
744
|
await pi.sendUserMessage?.(followUpPrompt);
|
|
497
745
|
} else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
|
|
498
746
|
state.resumeGuard = true;
|
|
@@ -569,6 +817,49 @@ export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
|
|
|
569
817
|
export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
|
|
570
818
|
const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
|
|
571
819
|
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// Pre-plan compaction: right after `plans start-run` creates a new planning
|
|
822
|
+
// run, the extension triggers one VCC compaction so the new plan starts on a
|
|
823
|
+
// lean context (LLM reasoning degrades with longer context; see PLAN
|
|
824
|
+
// preplan-compact). The pending flag is session-scoped and opportunistic: it
|
|
825
|
+
// is set by the start-run tool case and consumed by the plans tool_result
|
|
826
|
+
// hook in index.ts, which requests the extension-context compact action and
|
|
827
|
+
// resumes planning exactly once regardless of success or failure.
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
export { PLANNING_PREPLAN_COMPACT_HINT };
|
|
831
|
+
export const PLANNING_PREPLAN_RESUME_CUSTOM_TYPE = "pi-plans-preplan-resume";
|
|
832
|
+
|
|
833
|
+
interface PrePlanCompactPending {
|
|
834
|
+
runId: string;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function markPrePlanCompactPending(ctx: ExtensionContext, runId: string): void {
|
|
838
|
+
const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null };
|
|
839
|
+
session.__piPlansPrePlanCompact = { runId };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export function consumePrePlanCompactPending(ctx: ExtensionContext): PrePlanCompactPending | null {
|
|
843
|
+
const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null };
|
|
844
|
+
const pending = session.__piPlansPrePlanCompact ?? null;
|
|
845
|
+
session.__piPlansPrePlanCompact = null;
|
|
846
|
+
return pending;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Hidden resume message after the pre-plan compaction settles (success or
|
|
850
|
+
* failure): Pi's manual compaction never continues the aborted turn, so the
|
|
851
|
+
* planning workflow is continued exactly once from here. */
|
|
852
|
+
export function sendPrePlanCompactResume(pi: ExtensionAPI): void {
|
|
853
|
+
pi.sendMessage?.(
|
|
854
|
+
{
|
|
855
|
+
customType: PLANNING_PREPLAN_RESUME_CUSTOM_TYPE,
|
|
856
|
+
content: "Continue planning.",
|
|
857
|
+
display: false,
|
|
858
|
+
},
|
|
859
|
+
{ triggerTurn: true },
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
572
863
|
interface PlanningCompactionState {
|
|
573
864
|
inFlight: boolean;
|
|
574
865
|
resumeGuard: boolean;
|
|
@@ -698,8 +989,11 @@ export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void
|
|
|
698
989
|
}
|
|
699
990
|
|
|
700
991
|
export function requestPlanningCompaction(_ctx: ExtensionContext): void {
|
|
701
|
-
//
|
|
992
|
+
// Generic proactive pi-plans compaction is intentionally disabled. Manual,
|
|
702
993
|
// threshold, and overflow compactions are handled by session_before_compact.
|
|
994
|
+
// The single exception is the pre-plan compaction: index.ts requests the
|
|
995
|
+
// extension-context compact action from the plans tool_result hook right
|
|
996
|
+
// after start-run (see PLANNING_PREPLAN_COMPACT_HINT).
|
|
703
997
|
}
|
|
704
998
|
|
|
705
999
|
function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
|
|
@@ -833,7 +1127,7 @@ export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionCont
|
|
|
833
1127
|
}
|
|
834
1128
|
|
|
835
1129
|
export function filterPlanningResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
|
|
836
|
-
return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
|
|
1130
|
+
return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE && message.customType !== PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
|
|
837
1131
|
}
|
|
838
1132
|
|
|
839
1133
|
export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
|
|
@@ -842,7 +1136,11 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
|
|
|
842
1136
|
// Final synchronous write: drain any deferred flush and land the last snapshot.
|
|
843
1137
|
pendingExecutionFlush = false;
|
|
844
1138
|
persist(pi);
|
|
1139
|
+
// Checkpoint first: withExecutionCheckpoint guards on the live execution.
|
|
1140
|
+
withExecutionCheckpoint(ctx, (cp) => applyExecutionStopped(cp, reason));
|
|
845
1141
|
execution = null;
|
|
1142
|
+
executionRunId = null;
|
|
1143
|
+
goalWaitRuntime = null;
|
|
846
1144
|
pi.appendEntry("pi-plans-exec-cleared", { reason });
|
|
847
1145
|
pi.sendMessage(
|
|
848
1146
|
{
|
|
@@ -852,7 +1150,7 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
|
|
|
852
1150
|
},
|
|
853
1151
|
{ triggerTurn: false },
|
|
854
1152
|
);
|
|
855
|
-
const active =
|
|
1153
|
+
const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
|
|
856
1154
|
if (active) {
|
|
857
1155
|
try {
|
|
858
1156
|
setRunStatus(ctx.cwd, active.run_id, "stopped");
|
|
@@ -909,13 +1207,6 @@ export function isExecutionComplete(): boolean {
|
|
|
909
1207
|
return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
|
|
910
1208
|
}
|
|
911
1209
|
|
|
912
|
-
let compactionFollowUpSentThisTurn = false;
|
|
913
|
-
|
|
914
|
-
/** Reset per-turn continuation flags at the start of a new agent turn. */
|
|
915
|
-
export function resetGoalWaitTurnFlags(): void {
|
|
916
|
-
compactionFollowUpSentThisTurn = false;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
1210
|
function goalWaitSnapshot(): string {
|
|
920
1211
|
if (!execution) return "";
|
|
921
1212
|
return JSON.stringify({
|
|
@@ -942,46 +1233,67 @@ function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string):
|
|
|
942
1233
|
updateStatusWidget(ctx);
|
|
943
1234
|
}
|
|
944
1235
|
|
|
945
|
-
|
|
946
|
-
* Goal-wait continuation: a turn that ends with unpassed VCs gets one light
|
|
947
|
-
* followUp so the worker keeps going (working, or polling an external event
|
|
948
|
-
* per the taught backoff rules). Skipped when the compaction machinery owns
|
|
949
|
-
* continuation for this turn, and paused entirely by the no-progress guard.
|
|
950
|
-
* Note: the tri-flag check here deliberately runs BEFORE index.ts's
|
|
951
|
-
* handleExecutionTurnCompaction consumes resumeGuard — checking after the
|
|
952
|
-
* one-shot consumption would never observe it.
|
|
953
|
-
*/
|
|
954
|
-
function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistantText: string): void {
|
|
955
|
-
const ex = getExecution();
|
|
956
|
-
if (!ex) return;
|
|
957
|
-
ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
|
|
958
|
-
const goalWait = ex.goalWait;
|
|
959
|
-
if (goalWait.paused) {
|
|
960
|
-
updateStatusWidget(ctx);
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
1236
|
+
function canWakeExecution(ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
|
|
963
1237
|
const compaction = executionCompactionState(ctx);
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1238
|
+
return currentGoalWaitRuntime(ctx) === runtime
|
|
1239
|
+
&& (ctx.mode === "tui" || ctx.mode === "rpc")
|
|
1240
|
+
&& !isExecutionComplete()
|
|
1241
|
+
&& !runtime.owner.goalWait?.paused
|
|
1242
|
+
&& ctx.isIdle()
|
|
1243
|
+
&& !ctx.hasPendingMessages()
|
|
1244
|
+
&& !ctx.signal?.aborted
|
|
1245
|
+
&& !compactionInFlight(ctx, "execution")
|
|
1246
|
+
&& !compaction?.inFlight
|
|
1247
|
+
&& !compaction?.resumeGuard
|
|
1248
|
+
&& compaction?.pendingFollowUpPrompt == null;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function sendGoalWaitWake(pi: ExtensionAPI, ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
|
|
1252
|
+
if (!canWakeExecution(ctx, runtime)) return false;
|
|
1253
|
+
try {
|
|
1254
|
+
// Custom messages bypass before_agent_start, so carry fresh execution rules.
|
|
1255
|
+
const content = executionContextMessage(ctx);
|
|
1256
|
+
if (!content) return false;
|
|
1257
|
+
runtime.wakeId = randomUUID();
|
|
1258
|
+
pi.sendMessage({
|
|
1259
|
+
customType: GOAL_WAIT_CUSTOM_TYPE,
|
|
1260
|
+
content,
|
|
1261
|
+
display: false,
|
|
1262
|
+
details: { wakeId: runtime.wakeId },
|
|
1263
|
+
}, { triggerTurn: true });
|
|
1264
|
+
return true;
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
runtime.wakeId = undefined;
|
|
1267
|
+
pauseGoalWait(pi, ctx, `continuation failed: ${String(error)}`);
|
|
1268
|
+
return false;
|
|
967
1269
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Only a fully settled agent run can need an extra wake, never a tool turn. */
|
|
1273
|
+
function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
1274
|
+
const runtime = currentGoalWaitRuntime(ctx);
|
|
1275
|
+
if (!runtime || runtime.handled || !ctx.isIdle()) return;
|
|
1276
|
+
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
1277
|
+
if (runtime.stopReason === "error" || runtime.stopReason === "aborted" || ctx.signal?.aborted) {
|
|
1278
|
+
runtime.handled = true;
|
|
1279
|
+
pauseGoalWait(pi, ctx, runtime.stopReason === "error" ? "agent failed" : "agent interrupted");
|
|
971
1280
|
return;
|
|
972
1281
|
}
|
|
1282
|
+
if (runtime.stopReason !== "stop" || !canWakeExecution(ctx, runtime)) return;
|
|
1283
|
+
runtime.handled = true;
|
|
1284
|
+
const ex = runtime.owner;
|
|
1285
|
+
ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
|
|
1286
|
+
const goalWait = ex.goalWait;
|
|
973
1287
|
const snapshot = goalWaitSnapshot();
|
|
974
1288
|
const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
|
|
975
1289
|
goalWait.lastMarkers = snapshot;
|
|
976
|
-
if (
|
|
977
|
-
if (/waiting for/i.test(assistantText)) {
|
|
978
|
-
goalWait.waitRounds += 1;
|
|
979
|
-
} else {
|
|
980
|
-
goalWait.noProgressRounds += 1;
|
|
981
|
-
}
|
|
982
|
-
} else {
|
|
1290
|
+
if (changed) {
|
|
983
1291
|
goalWait.noProgressRounds = 0;
|
|
984
1292
|
goalWait.waitRounds = 0;
|
|
1293
|
+
} else if (/waiting for/i.test(runtime.text)) {
|
|
1294
|
+
goalWait.waitRounds += 1;
|
|
1295
|
+
} else {
|
|
1296
|
+
goalWait.noProgressRounds += 1;
|
|
985
1297
|
}
|
|
986
1298
|
if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
|
|
987
1299
|
pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
|
|
@@ -991,25 +1303,40 @@ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistan
|
|
|
991
1303
|
pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
|
|
992
1304
|
return;
|
|
993
1305
|
}
|
|
994
|
-
|
|
995
|
-
const remainingIds = remaining.map((item) => `\`${item.id}\``).join(", ");
|
|
996
|
-
pi.sendUserMessage?.(
|
|
997
|
-
`Goal wait: ${remaining.length}/${ex.items.length} verifier items still open (${remainingIds}). Continue the plan — if blocked on an external event, keep waiting per the backoff rules; otherwise resolve the remaining items.`,
|
|
998
|
-
{ deliverAs: "followUp" },
|
|
999
|
-
);
|
|
1306
|
+
persist(pi);
|
|
1000
1307
|
updateStatusWidget(ctx);
|
|
1308
|
+
// No await between the live gate and dispatch: another input cannot interleave.
|
|
1309
|
+
sendGoalWaitWake(pi, ctx, runtime);
|
|
1001
1310
|
}
|
|
1002
1311
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1312
|
+
export function filterGoalWaitMessages<T extends { customType?: string; details?: unknown }>(messages: T[]): T[] {
|
|
1313
|
+
return messages.filter((message) => message.customType !== GOAL_WAIT_CUSTOM_TYPE
|
|
1314
|
+
|| (goalWaitRuntime?.owner === execution && goalWaitRuntime?.wakeId !== undefined
|
|
1315
|
+
&& (message.details as { wakeId?: unknown } | undefined)?.wakeId === goalWaitRuntime.wakeId));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Called only for genuine user input or an explicit same-execution resume. */
|
|
1319
|
+
export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
|
|
1005
1320
|
const ex = getExecution();
|
|
1006
|
-
if (!ex?.goalWait?.paused) return;
|
|
1321
|
+
if (!ex?.goalWait?.paused || !currentGoalWaitRuntime(ctx)) return false;
|
|
1007
1322
|
ex.goalWait.paused = false;
|
|
1008
1323
|
ex.goalWait.pausedReason = undefined;
|
|
1009
1324
|
ex.goalWait.noProgressRounds = 0;
|
|
1010
1325
|
ex.goalWait.waitRounds = 0;
|
|
1326
|
+
ex.goalWait.lastMarkers = goalWaitSnapshot();
|
|
1011
1327
|
persist(pi);
|
|
1012
1328
|
updateStatusWidget(ctx);
|
|
1329
|
+
return true;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
export function resumeActiveExecution(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
|
|
1333
|
+
if (!resumeGoalWaitIfPaused(pi, ctx)) return false;
|
|
1334
|
+
const runtime = currentGoalWaitRuntime(ctx)!;
|
|
1335
|
+
if (canWakeExecution(ctx, runtime)) {
|
|
1336
|
+
runtime.handled = true;
|
|
1337
|
+
sendGoalWaitWake(pi, ctx, runtime);
|
|
1338
|
+
}
|
|
1339
|
+
return true;
|
|
1013
1340
|
}
|
|
1014
1341
|
|
|
1015
1342
|
export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
@@ -1021,7 +1348,11 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
|
|
|
1021
1348
|
|
|
1022
1349
|
const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
|
|
1023
1350
|
const planPath = execution.planPath;
|
|
1351
|
+
// Checkpoint first (live-execution guard), then clear the session state.
|
|
1352
|
+
withExecutionCheckpoint(ctx, (cp) => applyExecutionCompleted(cp));
|
|
1024
1353
|
execution = null;
|
|
1354
|
+
executionRunId = null;
|
|
1355
|
+
goalWaitRuntime = null;
|
|
1025
1356
|
pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
|
|
1026
1357
|
// Post-execution goal-running continuation: in interactive sessions, attach
|
|
1027
1358
|
// the continuation block and trigger a new turn so the agent immediately
|
|
@@ -1043,7 +1374,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
|
|
|
1043
1374
|
if (interactive) {
|
|
1044
1375
|
pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
|
|
1045
1376
|
}
|
|
1046
|
-
const active =
|
|
1377
|
+
const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
|
|
1047
1378
|
if (active) {
|
|
1048
1379
|
try {
|
|
1049
1380
|
setRunStatus(ctx.cwd, active.run_id, "done");
|
|
@@ -1059,7 +1390,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
|
|
|
1059
1390
|
* implementation-review loop. Termination options are single-sourced from
|
|
1060
1391
|
* src/termination-prompt.ts (shared with the ask_choice trailing branch). */
|
|
1061
1392
|
export const AMELIORATION_PROMPT_TEXT = `---
|
|
1062
|
-
Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
|
|
1393
|
+
Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. ${TERMINATION_RECORDING_INSTRUCTIONS} Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
|
|
1063
1394
|
|
|
1064
1395
|
/** Injection text for before_agent_start while executing. */
|
|
1065
1396
|
export function executionContextMessage(ctx: ExtensionContext): string | null {
|
|
@@ -1111,6 +1442,7 @@ interface SessionEntry {
|
|
|
1111
1442
|
*/
|
|
1112
1443
|
export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
|
|
1113
1444
|
pendingExecutionFlush = false; // no flush debt survives a restart
|
|
1445
|
+
goalWaitRuntime = null;
|
|
1114
1446
|
resetExecutionCompactionState(ctx);
|
|
1115
1447
|
let snapshotIndex = -1;
|
|
1116
1448
|
let snapshot: ExecState | null = null;
|
|
@@ -1169,6 +1501,7 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
|
|
|
1169
1501
|
}
|
|
1170
1502
|
}
|
|
1171
1503
|
if (execution) {
|
|
1504
|
+
resetGoalWaitRuntime(ctx);
|
|
1172
1505
|
// D-010: replay may have advanced progress past the persisted baseline.
|
|
1173
1506
|
// Recompute the goal-wait markers; new progress resets the guard counters.
|
|
1174
1507
|
if (execution.goalWait) {
|