@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.0
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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { commandInstructions } from "./goal-controls.ts";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { ExtensionContext, AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { GoalCore } from "./goal-state.ts";
|
|
5
|
+
import { asRecord, type GoalRecord } from "./goal-record.ts";
|
|
6
|
+
import { budgetReached } from "./goal-accounting.ts";
|
|
7
|
+
import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
|
|
8
|
+
import { newGoalScheduler, schedulerSummary, type GoalContinuation, type GoalSchedulerState } from "./goal-scheduler-state.ts";
|
|
9
|
+
|
|
10
|
+
/** Scheduling intent is durable; timers only arrange an opportunity to claim it. */
|
|
11
|
+
export class GoalScheduler {
|
|
12
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
private ctx: ExtensionContext | undefined;
|
|
14
|
+
private inRun = false;
|
|
15
|
+
private armedGeneration: string | undefined;
|
|
16
|
+
private declared = false;
|
|
17
|
+
private runGoalId: string | undefined;
|
|
18
|
+
private runGoalContent: string | undefined;
|
|
19
|
+
private runHadWork = false;
|
|
20
|
+
private runPacingEnabled = false;
|
|
21
|
+
private denied = false;
|
|
22
|
+
private unsubscribe: (() => void) | undefined;
|
|
23
|
+
private core: GoalCore;
|
|
24
|
+
constructor(core: GoalCore) { this.core = core; }
|
|
25
|
+
|
|
26
|
+
private owner(ctx: ExtensionContext): string { return ctx.sessionManager.getSessionId() || "unknown-session"; }
|
|
27
|
+
private limit(ctx: ExtensionContext): number | undefined { return loadGoalSettings(ctx.cwd).maxAutonomousRuns; }
|
|
28
|
+
private strict(ctx: ExtensionContext, s: GoalSchedulerState): boolean {
|
|
29
|
+
return loadGoalSettings(ctx.cwd).strictExecutionContract === true || !!s.wait;
|
|
30
|
+
}
|
|
31
|
+
private implicitReady(s: GoalSchedulerState): void {
|
|
32
|
+
s.phase = "ready"; s.dispatch = undefined; s.generation = randomUUID();
|
|
33
|
+
s.decision = { kind: "ready", purpose: "ready" };
|
|
34
|
+
}
|
|
35
|
+
private goalContent(goal: GoalRecord | null): string | undefined {
|
|
36
|
+
return goal ? JSON.stringify([goal.objective, goal.sisyphus, goal.verificationContract, goal.taskList, goal.currentTaskId]) : undefined;
|
|
37
|
+
}
|
|
38
|
+
private paceIdle(s: GoalSchedulerState, goal: GoalRecord): void {
|
|
39
|
+
if (this.runHadWork || this.goalContent(goal) !== this.runGoalContent) {
|
|
40
|
+
s.idle = undefined;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// One immediate reasoning-only continuation, then 30s, 60s, 120s,
|
|
44
|
+
// 240s and 300s. Saturate the streak to keep long-running state bounded.
|
|
45
|
+
const runs = Math.min(6, (s.idle?.runs ?? 0) + 1);
|
|
46
|
+
const delay = runs === 1 ? 0 : Math.min(300_000, 30_000 * 2 ** (runs - 2));
|
|
47
|
+
s.idle = { runs, nextAt: Date.now() + delay };
|
|
48
|
+
}
|
|
49
|
+
private idleDelay(s: GoalSchedulerState): number {
|
|
50
|
+
return s.phase === "ready" && s.decision?.kind === "ready" && s.decision.purpose === "ready"
|
|
51
|
+
? Math.max(0, (s.idle?.nextAt ?? 0) - Date.now()) : 0;
|
|
52
|
+
}
|
|
53
|
+
private available(ctx: ExtensionContext, s: GoalSchedulerState): boolean {
|
|
54
|
+
const limit = this.limit(ctx);
|
|
55
|
+
return limit === undefined || s.used < limit;
|
|
56
|
+
}
|
|
57
|
+
private state(ctx: ExtensionContext, goal: GoalRecord): GoalSchedulerState {
|
|
58
|
+
const s = goal.scheduler ?? newGoalScheduler(this.owner(ctx));
|
|
59
|
+
if (s.owner !== this.owner(ctx) || s.phase === "interrupted") throw new Error(commandInstructions("Goal scheduling belongs to another session or was interrupted. Use /goal-resume to take ownership.", this.core.loadedCommands));
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
private write(ctx: ExtensionContext, change: (g: GoalRecord) => GoalRecord): GoalRecord {
|
|
63
|
+
const error = this.core.goalService.flushForAudit(ctx);
|
|
64
|
+
if (error || this.core.goalService.isTurnBuffered()) throw new Error(error || "Goal changes have not reached disk.");
|
|
65
|
+
const id = this.core.state.goal?.id;
|
|
66
|
+
if (!id) throw new Error("No focused goal.");
|
|
67
|
+
const result = this.core.goalService.apply(ctx, { expectedGoalId: id, mutate: change,
|
|
68
|
+
ledger: g => g.status === "paused" ? [{ type: "goal_paused", goalId: g.id, reason: g.pauseReason ?? "Scheduling paused", source: "agent", at: new Date().toISOString() }] : [],
|
|
69
|
+
});
|
|
70
|
+
if (!result.ok) throw new Error(result.message);
|
|
71
|
+
this.core.updateUI(ctx);
|
|
72
|
+
return result.goal;
|
|
73
|
+
}
|
|
74
|
+
private update(ctx: ExtensionContext, change: (s: GoalSchedulerState, g: GoalRecord) => void): GoalRecord {
|
|
75
|
+
return this.write(ctx, g => { const s = this.state(ctx, g); change(s, g); return { ...g, scheduler: s }; });
|
|
76
|
+
}
|
|
77
|
+
private safe(ctx: ExtensionContext, fn: () => void): void {
|
|
78
|
+
try { fn(); } catch (error) {
|
|
79
|
+
this.cancelTimer();
|
|
80
|
+
this.core.runtime.clearContinuationState();
|
|
81
|
+
ctx.ui.notify(`Goal scheduling stopped: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
cancelTimer(): void {
|
|
85
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
86
|
+
this.timer = undefined;
|
|
87
|
+
this.armedGeneration = undefined;
|
|
88
|
+
}
|
|
89
|
+
private later(ctx: ExtensionContext, delay: number, fn: () => void): void {
|
|
90
|
+
this.cancelTimer();
|
|
91
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.safe(ctx, fn); }, Math.min(2_147_483_647, Math.max(0, delay)));
|
|
92
|
+
this.timer.unref?.();
|
|
93
|
+
}
|
|
94
|
+
private pause(ctx: ExtensionContext, reason: string): void {
|
|
95
|
+
this.cancelTimer();
|
|
96
|
+
this.core.runtime.clearContinuationState();
|
|
97
|
+
this.write(ctx, g => {
|
|
98
|
+
if (g.scheduler && g.scheduler.owner !== this.owner(ctx)) throw new Error("Scheduling ownership changed; no pause was applied.");
|
|
99
|
+
return { ...g, status: "paused", autoContinue: false, stopReason: "agent", pauseReason: reason,
|
|
100
|
+
scheduler: g.scheduler ? { ...g.scheduler, generation: randomUUID(), phase: "idle", decision: undefined, dispatch: undefined, wait: undefined } : undefined };
|
|
101
|
+
});
|
|
102
|
+
this.core.clearActiveAccounting();
|
|
103
|
+
ctx.ui.notify(reason, "warning");
|
|
104
|
+
}
|
|
105
|
+
private allowanceReason(ctx: ExtensionContext): string {
|
|
106
|
+
return this.limit(ctx) === 0 ? commandInstructions("Automatic continuation disabled by maxAutonomousRuns=0. Change the setting in /goal-settings, then use /goal-resume.", this.core.loadedCommands) : commandInstructions("Autonomous-run allowance exhausted. Increase or remove maxAutonomousRuns, or use /goal-resume to renew.", this.core.loadedCommands);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Bind once per live session. The event is deliberately not a triggerTurn message. */
|
|
110
|
+
attach(ctx: ExtensionContext): void {
|
|
111
|
+
this.ctx = ctx;
|
|
112
|
+
this.unsubscribe?.();
|
|
113
|
+
this.unsubscribe = this.core.pi.events?.on("pi-goal:wake", (event: unknown) => {
|
|
114
|
+
const raw = asRecord(event);
|
|
115
|
+
if (raw && this.ctx) this.signal(this.ctx, raw.goalId, raw.waitToken);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
shutdown(): void { this.cancelTimer(); this.unsubscribe?.(); this.unsubscribe = undefined; this.ctx = undefined; this.denied = false; }
|
|
119
|
+
|
|
120
|
+
restore(ctx: ExtensionContext): void {
|
|
121
|
+
this.attach(ctx);
|
|
122
|
+
this.inRun = false; this.declared = false; this.runGoalId = undefined; this.denied = false;
|
|
123
|
+
this.cancelTimer();
|
|
124
|
+
this.safe(ctx, () => {
|
|
125
|
+
this.core.reconcileFocusedGoalFromDisk(ctx);
|
|
126
|
+
const goal = this.core.state.goal;
|
|
127
|
+
if (!goal || goal.status !== "active" || !goal.autoContinue || !goal.scheduler) return;
|
|
128
|
+
const s = goal.scheduler;
|
|
129
|
+
if (s.owner !== this.owner(ctx)) { ctx.ui.notify(commandInstructions("Goal owned by another session. Use /goal-resume to take ownership.", this.core.loadedCommands), "info"); return; }
|
|
130
|
+
if (["claimed", "running", "interrupted"].includes(s.phase)) {
|
|
131
|
+
this.pause(ctx, commandInstructions("Previous autonomous execution was interrupted. Use /goal-resume; its dispatch will not be replayed.", this.core.loadedCommands));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.schedule(ctx);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Persist a stopped turn without renewing allowance or scheduling work. */
|
|
139
|
+
interrupt(ctx: ExtensionContext): void {
|
|
140
|
+
this.cancelTimer();
|
|
141
|
+
this.safe(ctx, () => this.update(ctx, s => {
|
|
142
|
+
s.phase = "interrupted"; s.generation = randomUUID(); s.dispatch = undefined; s.decision = undefined; s.wait = undefined;
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
releaseInterrupt(ctx: ExtensionContext): void {
|
|
146
|
+
this.safe(ctx, () => this.write(ctx, g => {
|
|
147
|
+
const s = g.scheduler;
|
|
148
|
+
if (!s || s.owner !== this.owner(ctx) || s.phase !== "interrupted") return g;
|
|
149
|
+
return { ...g, scheduler: { ...s, phase: "idle", generation: randomUUID() } };
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Only explicit user resume resets spent allowance, including while already active/waiting. */
|
|
154
|
+
resume(ctx: ExtensionContext): boolean {
|
|
155
|
+
invalidateGoalSettingsCache();
|
|
156
|
+
if (this.limit(ctx) === 0) { ctx.ui.notify(this.allowanceReason(ctx), "warning"); return false; }
|
|
157
|
+
try {
|
|
158
|
+
this.cancelTimer();
|
|
159
|
+
this.core.runtime.clearContinuationState();
|
|
160
|
+
this.write(ctx, g => {
|
|
161
|
+
if (g.status === "complete" || budgetReached(g)) throw new Error("A completed or token-budget-limited goal cannot resume.");
|
|
162
|
+
return { ...g, status: "active", autoContinue: true, stopReason: undefined, pauseReason: undefined, pauseSuggestedAction: undefined,
|
|
163
|
+
scheduler: { ...newGoalScheduler(this.owner(ctx)), phase: "ready", decision: { kind: "ready", purpose: "kickoff" } } };
|
|
164
|
+
});
|
|
165
|
+
this.core.interruption.resumed(ctx);
|
|
166
|
+
// A resume during host work waits for settlement; another model turn
|
|
167
|
+
// still invalidates it through turn(), just like a declaration.
|
|
168
|
+
this.declared = this.inRun;
|
|
169
|
+
this.schedule(ctx);
|
|
170
|
+
return true;
|
|
171
|
+
} catch (error) { ctx.ui.notify(String(error), "warning"); return false; }
|
|
172
|
+
}
|
|
173
|
+
kickoff(ctx: ExtensionContext): void {
|
|
174
|
+
// A goal created in ordinary user work can finish in that run. Replacing
|
|
175
|
+
// another run's goal needs its own saved kickoff and fresh attribution.
|
|
176
|
+
if (this.inRun && (!this.runGoalId || this.runGoalId === this.core.state.goal?.id)) return;
|
|
177
|
+
this.safe(ctx, () => {
|
|
178
|
+
const goal = this.core.state.goal;
|
|
179
|
+
if (!goal || goal.status !== "active" || !goal.autoContinue) return;
|
|
180
|
+
if (this.limit(ctx) === 0) { this.update(ctx, () => {}); ctx.ui.notify(this.allowanceReason(ctx), "info"); return; }
|
|
181
|
+
this.update(ctx, s => { s.phase = "ready"; s.decision = { kind: "ready", purpose: "kickoff" }; });
|
|
182
|
+
this.schedule(ctx);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
declare(ctx: ExtensionContext, input: GoalContinuation): AgentToolResult<unknown> {
|
|
187
|
+
try {
|
|
188
|
+
if (!this.core.state.goal || this.core.state.goal.status !== "active" || !this.core.state.goal.autoContinue || this.denied) throw new Error("Scheduling requires an active auto-continue goal in this session.");
|
|
189
|
+
invalidateGoalSettingsCache(); // an agent may have just configured the allowance
|
|
190
|
+
const goal = this.update(ctx, s => {
|
|
191
|
+
if (!this.available(ctx, s)) throw new Error(this.allowanceReason(ctx));
|
|
192
|
+
if (this.strict(ctx, s)) s.idle = undefined;
|
|
193
|
+
if (input.kind === "ready") {
|
|
194
|
+
s.phase = "ready"; s.decision = { kind: "ready", purpose: "ready" }; s.wait = undefined;
|
|
195
|
+
} else if (input.kind === "wait") {
|
|
196
|
+
if (!this.strict(ctx, s)) throw new Error(commandInstructions("New waits require strictExecutionContract=true in /goal-settings. This is a user preference; continue pursuing the goal without a wait unless the user opts in.", this.core.loadedCommands));
|
|
197
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 2000) throw new Error("wait requires a nonempty reason (at most 2000 characters).");
|
|
198
|
+
const deadline = Date.parse(input.deadline);
|
|
199
|
+
if (!/T.*(?:Z|[+-]\d\d:\d\d)$/.test(input.deadline) || !Number.isSafeInteger(deadline) || deadline <= Date.now()) throw new Error("wait requires a future ISO deadline with a timezone.");
|
|
200
|
+
if (s.wait) {
|
|
201
|
+
if (input.wait_id !== s.wait.id || deadline !== s.wait.deadline) throw new Error("Re-declare the current wait_id with its original deadline; checks cannot be reset.");
|
|
202
|
+
if (input.polling && (input.polling.interval_seconds * 1000 !== s.wait.intervalMs || input.polling.max_checks !== s.wait.remainingChecks)) throw new Error("Existing wait polling cannot be reset; omit polling when re-declaring wait_id.");
|
|
203
|
+
s.wait.token = randomUUID(); s.wait.signalled = false;
|
|
204
|
+
if (s.wait.intervalMs) s.wait.nextCheckAt = Date.now() + s.wait.intervalMs;
|
|
205
|
+
} else {
|
|
206
|
+
if (input.wait_id) throw new Error("Unknown wait_id.");
|
|
207
|
+
const polling = input.polling;
|
|
208
|
+
if (polling && (!Number.isSafeInteger(polling.interval_seconds) || polling.interval_seconds < 1 || polling.interval_seconds > 2_147_483 || !Number.isSafeInteger(polling.max_checks) || polling.max_checks < 1)) throw new Error("Polling requires positive whole interval_seconds (at most 2147483) and max_checks.");
|
|
209
|
+
s.wait = { id: randomUUID(), token: randomUUID(), reason: input.reason.trim(), deadline,
|
|
210
|
+
...(polling ? { intervalMs: polling.interval_seconds * 1000, remainingChecks: polling.max_checks, nextCheckAt: Date.now() + polling.interval_seconds * 1000 } : {}) };
|
|
211
|
+
}
|
|
212
|
+
s.phase = "waiting"; s.decision = { kind: "wait" };
|
|
213
|
+
} else throw new Error("Unknown continuation kind.");
|
|
214
|
+
s.dispatch = undefined;
|
|
215
|
+
s.generation = randomUUID();
|
|
216
|
+
});
|
|
217
|
+
this.declared = true;
|
|
218
|
+
this.core.runtime.markTurnStopped(goal.id);
|
|
219
|
+
const wait = goal.scheduler?.wait;
|
|
220
|
+
return { content: [{ type: "text", text: `Scheduling decision saved. Stop this execution.\n${schedulerSummary(goal.scheduler, this.limit(ctx), loadGoalSettings(ctx.cwd).showAutonomousRuns, loadGoalSettings(ctx.cwd).idleContinuationBackoff === true, this.core.loadedCommands)}${wait ? `\nWake token: ${wait.token}. Register this token with the producer before completion; adapters must retain early results.` : ""}` }], details: { goal, ...(wait ? { wait_id: wait.id, waitToken: wait.token } : {}) }, terminate: true };
|
|
221
|
+
} catch (error) { return { content: [{ type: "text", text: `Scheduling decision NOT saved: ${error instanceof Error ? error.message : String(error)}` }], details: { error: true }, terminate: false }; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Reject an internal dispatch without giving its cancellation to the focused goal. */
|
|
225
|
+
rejectCheckpoint(ctx: ExtensionContext): void {
|
|
226
|
+
this.denied = true;
|
|
227
|
+
this.cancelTimer();
|
|
228
|
+
this.core.runtime.clearContinuationTimer();
|
|
229
|
+
this.core.runningGoalId = null;
|
|
230
|
+
ctx.abort?.();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One logical run can contain multiple agent_start events during native retry/compaction. */
|
|
234
|
+
begin(ctx: ExtensionContext): void {
|
|
235
|
+
this.ctx = ctx;
|
|
236
|
+
// Native custom-turn preparation can reject a checkpoint before agent_start.
|
|
237
|
+
// Its later lifecycle events still belong to that rejected dispatch.
|
|
238
|
+
if (this.denied) return;
|
|
239
|
+
if (this.inRun) { this.core.runningGoalId = this.runGoalId ?? null; return; }
|
|
240
|
+
this.inRun = true; this.declared = false; this.denied = false;
|
|
241
|
+
this.runGoalId = this.core.state.goal?.id;
|
|
242
|
+
this.runPacingEnabled = loadGoalSettings(ctx.cwd).idleContinuationBackoff === true;
|
|
243
|
+
this.runGoalContent = this.runPacingEnabled ? this.goalContent(this.core.state.goal) : undefined;
|
|
244
|
+
this.runHadWork = false;
|
|
245
|
+
this.cancelTimer(); this.core.runtime.clearContinuationTimer();
|
|
246
|
+
this.core.runningGoalId = this.core.state.goal?.id ?? null;
|
|
247
|
+
}
|
|
248
|
+
/** Work in any tool turn counts until the entire logical execution settles. */
|
|
249
|
+
recordWork(): void { if (this.inRun && this.runPacingEnabled) this.runHadWork = true; }
|
|
250
|
+
turn(ctx: ExtensionContext): void {
|
|
251
|
+
if (!this.declared) return;
|
|
252
|
+
this.declared = false;
|
|
253
|
+
this.safe(ctx, () => this.update(ctx, s => { s.decision = undefined; s.phase = "idle"; s.dispatch = undefined; }));
|
|
254
|
+
}
|
|
255
|
+
message(ctx: ExtensionContext, rawMessage: unknown): void {
|
|
256
|
+
if (this.denied) return;
|
|
257
|
+
const msg = asRecord(rawMessage);
|
|
258
|
+
if (!msg || !["user", "custom"].includes(String(msg.role))) return;
|
|
259
|
+
if (msg.customType === "pi-goal-audit-event") return;
|
|
260
|
+
if (msg.customType === "pi-goal-event") {
|
|
261
|
+
const d = asRecord(msg.details);
|
|
262
|
+
if (d?.kind !== "checkpoint") return;
|
|
263
|
+
try {
|
|
264
|
+
this.update(ctx, (s, g) => {
|
|
265
|
+
if (d.version !== 3 || s.phase !== "claimed" || g.id !== d.goalId || s.generation !== d.generation || s.dispatch?.id !== d.dispatchId || g.status !== "active" || !g.autoContinue) throw new Error("Stale or unclaimed goal checkpoint.");
|
|
266
|
+
// Retain the admitted action as context; phase prevents reusing its authorization.
|
|
267
|
+
s.phase = "running";
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.rejectCheckpoint(ctx);
|
|
271
|
+
this.core.runtime.setCheckpoint(String(d?.goalId ?? "invalid"));
|
|
272
|
+
ctx.ui.notify(`Goal checkpoint rejected: ${String(error)}`, "warning");
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Genuine host work supersedes an old scheduling claim, but never resets used.
|
|
277
|
+
this.takeover(ctx);
|
|
278
|
+
}
|
|
279
|
+
takeover(ctx: ExtensionContext): void {
|
|
280
|
+
this.cancelTimer(); this.core.runtime.clearContinuationState();
|
|
281
|
+
this.declared = false;
|
|
282
|
+
const s = this.core.state.goal?.scheduler;
|
|
283
|
+
if (!s || s.owner !== this.owner(ctx)) return;
|
|
284
|
+
this.safe(ctx, () => this.update(ctx, state => {
|
|
285
|
+
state.generation = randomUUID(); state.phase = "idle"; state.decision = undefined; state.dispatch = undefined; state.wait = undefined;
|
|
286
|
+
state.repairUsed = false;
|
|
287
|
+
state.idle = undefined;
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
settled(ctx: ExtensionContext, successful = true): void {
|
|
291
|
+
this.inRun = false;
|
|
292
|
+
if (this.denied) {
|
|
293
|
+
this.denied = false;
|
|
294
|
+
this.runGoalId = undefined;
|
|
295
|
+
// A rejected message creates no scheduling intent. Resume only intent
|
|
296
|
+
// that the currently focused goal already saved before the rejection.
|
|
297
|
+
this.schedule(ctx);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (this.runGoalId && this.core.state.goal?.id !== this.runGoalId) { this.schedule(ctx); return; }
|
|
301
|
+
invalidateGoalSettingsCache();
|
|
302
|
+
this.safe(ctx, () => {
|
|
303
|
+
const goal = this.core.state.goal;
|
|
304
|
+
if (!goal || goal.status !== "active" || !goal.autoContinue || !successful) return;
|
|
305
|
+
const s = this.state(ctx, goal);
|
|
306
|
+
const strict = this.strict(ctx, s);
|
|
307
|
+
if (this.declared && s.decision) {
|
|
308
|
+
if (strict || (s.decision.kind === "ready" && s.decision.purpose !== "ready")) { this.schedule(ctx); return; }
|
|
309
|
+
}
|
|
310
|
+
if (!strict) {
|
|
311
|
+
this.update(ctx, (state, current) => { this.implicitReady(state); if (loadGoalSettings(ctx.cwd).idleContinuationBackoff && this.runPacingEnabled) this.paceIdle(state, current); else state.idle = undefined; });
|
|
312
|
+
this.schedule(ctx); return;
|
|
313
|
+
}
|
|
314
|
+
if (s.repairUsed || !this.available(ctx, s)) {
|
|
315
|
+
this.pause(ctx, !this.available(ctx, s) ? this.allowanceReason(ctx) : commandInstructions("No execution disposition after the contract-repair prompt. Use /goal-resume to continue.", this.core.loadedCommands));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
this.update(ctx, state => {
|
|
319
|
+
state.phase = "ready"; state.dispatch = undefined;
|
|
320
|
+
state.decision = { kind: "ready", purpose: "repair" };
|
|
321
|
+
});
|
|
322
|
+
this.schedule(ctx);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
recover(ctx: ExtensionContext): void {
|
|
326
|
+
this.safe(ctx, () => {
|
|
327
|
+
this.update(ctx, s => { s.phase = "ready"; s.dispatch = undefined; s.decision = { kind: "ready", purpose: "recovery" }; });
|
|
328
|
+
this.schedule(ctx);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
signal(ctx: ExtensionContext, goalId: unknown, token: unknown): void {
|
|
333
|
+
this.safe(ctx, () => {
|
|
334
|
+
this.core.reconcileFocusedGoalFromDisk(ctx);
|
|
335
|
+
const g = this.core.state.goal; const s = g?.scheduler;
|
|
336
|
+
if (!g || g.id !== goalId || g.status !== "active" || !g.autoContinue || s?.owner !== this.owner(ctx) || s.phase !== "waiting" || !s.wait || s.wait.token !== token || s.wait.signalled) return;
|
|
337
|
+
this.update(ctx, current => { if (current.wait?.token !== token || current.phase !== "waiting") throw new Error("Wait changed."); current.wait.signalled = true; });
|
|
338
|
+
if (!this.inRun) this.schedule(ctx);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** Apply an explicit preference edit without renewing runs or lifecycle state. */
|
|
342
|
+
refreshSettings(ctx: ExtensionContext): void {
|
|
343
|
+
invalidateGoalSettingsCache();
|
|
344
|
+
if (this.inRun && !loadGoalSettings(ctx.cwd).idleContinuationBackoff) {
|
|
345
|
+
this.runPacingEnabled = false;
|
|
346
|
+
const s = this.core.state.goal?.scheduler;
|
|
347
|
+
if (s?.idle && s.owner === this.owner(ctx)) this.safe(ctx, () => this.update(ctx, state => { state.idle = undefined; }));
|
|
348
|
+
}
|
|
349
|
+
this.schedule(ctx);
|
|
350
|
+
// Keep the explicit refresh snapshot warm so the next /goal-refresh can
|
|
351
|
+
// distinguish an external edit from the state just shown to the user.
|
|
352
|
+
loadGoalSettings(ctx.cwd);
|
|
353
|
+
}
|
|
354
|
+
schedule(ctx: ExtensionContext): void {
|
|
355
|
+
if (this.inRun || this.denied || this.core.interruption.isHeld()) return;
|
|
356
|
+
invalidateGoalSettingsCache();
|
|
357
|
+
this.safe(ctx, () => {
|
|
358
|
+
this.core.reconcileFocusedGoalFromDisk(ctx);
|
|
359
|
+
const g = this.core.state.goal;
|
|
360
|
+
if (!g || g.status !== "active" || !g.autoContinue || !g.scheduler) return;
|
|
361
|
+
let s = this.state(ctx, g);
|
|
362
|
+
if (!loadGoalSettings(ctx.cwd).idleContinuationBackoff && s.idle) s = this.update(ctx, state => { state.idle = undefined; }).scheduler!;
|
|
363
|
+
if (s.phase === "ready" && s.decision?.kind === "ready" && s.decision.purpose === "repair" && !this.strict(ctx, s)) {
|
|
364
|
+
s = this.update(ctx, state => this.implicitReady(state)).scheduler!;
|
|
365
|
+
}
|
|
366
|
+
if (!["ready", "waiting"].includes(s.phase)) return;
|
|
367
|
+
if (budgetReached(g)) { this.pause(ctx, "Goal token budget exhausted."); return; }
|
|
368
|
+
if (!this.available(ctx, s)) { this.pause(ctx, this.allowanceReason(ctx)); return; }
|
|
369
|
+
// Recovery and repair retain the wait while changing phase to ready.
|
|
370
|
+
if (s.wait && Date.now() >= s.wait.deadline) { this.pause(ctx, "Wait deadline reached."); return; }
|
|
371
|
+
if (s.phase === "waiting" && s.wait) {
|
|
372
|
+
this.core.clearActiveAccounting();
|
|
373
|
+
if (!s.wait.signalled && s.wait.remainingChecks === 0) { this.pause(ctx, "Wait check allowance exhausted."); return; }
|
|
374
|
+
if (!s.wait.signalled && (s.wait.nextCheckAt === undefined || s.wait.nextCheckAt > Date.now())) {
|
|
375
|
+
const due = Math.min(s.wait.deadline, s.wait.nextCheckAt ?? Infinity);
|
|
376
|
+
this.later(ctx, due - Date.now(), () => this.schedule(ctx)); return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const idleDelay = this.idleDelay(s);
|
|
380
|
+
if (idleDelay > 0) {
|
|
381
|
+
this.core.runtime.clearContinuationTimer();
|
|
382
|
+
this.core.clearActiveAccounting();
|
|
383
|
+
this.later(ctx, idleDelay, () => this.schedule(ctx)); return;
|
|
384
|
+
}
|
|
385
|
+
this.cancelTimer();
|
|
386
|
+
// Runtime readiness polling cannot authorize: claim() is called again at delivery.
|
|
387
|
+
this.armedGeneration = s.generation;
|
|
388
|
+
this.core.runtime.queueContinuation(ctx, g, true);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
claim(ctx: ExtensionContext): Record<string, unknown> | null {
|
|
392
|
+
if (this.core.interruption.isHeld()) return null;
|
|
393
|
+
const generation = this.armedGeneration;
|
|
394
|
+
if (!generation) return null;
|
|
395
|
+
this.armedGeneration = undefined;
|
|
396
|
+
invalidateGoalSettingsCache();
|
|
397
|
+
try {
|
|
398
|
+
let result: Record<string, unknown> | null = null;
|
|
399
|
+
let deferred = false;
|
|
400
|
+
this.update(ctx, (s, g) => {
|
|
401
|
+
if (s.generation !== generation) throw new Error("Scheduling generation changed.");
|
|
402
|
+
if (g.status !== "active" || !g.autoContinue || budgetReached(g) || !this.available(ctx, s)) throw new Error(this.allowanceReason(ctx));
|
|
403
|
+
if (s.wait && Date.now() >= s.wait.deadline) throw new Error("Wait deadline reached.");
|
|
404
|
+
if (s.phase === "ready" && s.decision?.kind === "ready" && s.decision.purpose === "repair" && !this.strict(ctx, s)) this.implicitReady(s);
|
|
405
|
+
if (!loadGoalSettings(ctx.cwd).idleContinuationBackoff) s.idle = undefined;
|
|
406
|
+
if (this.idleDelay(s) > 0) { deferred = true; return; }
|
|
407
|
+
let kind = s.phase === "ready" && s.decision?.kind === "ready" ? s.decision.purpose : undefined;
|
|
408
|
+
if (s.phase === "waiting" && s.wait) {
|
|
409
|
+
if (s.wait.signalled) kind = "wake";
|
|
410
|
+
else if (s.wait.nextCheckAt !== undefined && s.wait.nextCheckAt <= Date.now() && (s.wait.remainingChecks ?? 0) > 0) { kind = "check"; s.wait.remainingChecks!--; }
|
|
411
|
+
}
|
|
412
|
+
if (!kind) throw new Error("No scheduling decision authorizes this checkpoint.");
|
|
413
|
+
s.used++; s.phase = "claimed"; s.dispatch = { id: randomUUID(), kind, claimedAt: Date.now() };
|
|
414
|
+
if (kind === "repair") s.repairUsed = true;
|
|
415
|
+
else if (kind !== "recovery") s.repairUsed = false;
|
|
416
|
+
if (s.wait) { s.wait.signalled = false; s.wait.token = randomUUID(); }
|
|
417
|
+
result = { generation: s.generation, dispatchId: s.dispatch.id };
|
|
418
|
+
});
|
|
419
|
+
if (deferred) this.schedule(ctx);
|
|
420
|
+
return result;
|
|
421
|
+
} catch (error) {
|
|
422
|
+
const current = this.core.state.goal?.scheduler;
|
|
423
|
+
if (current && (current.owner !== this.owner(ctx) || current.generation !== generation)) return null;
|
|
424
|
+
this.safe(ctx, () => this.pause(ctx, `Goal wake stopped: ${error instanceof Error ? error.message : String(error)}`));
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
failedDispatch(ctx: ExtensionContext): void {
|
|
429
|
+
this.safe(ctx, () => this.pause(ctx, commandInstructions("Goal checkpoint delivery failed after claiming allowance. Use /goal-resume; it will not be replayed.", this.core.loadedCommands)));
|
|
430
|
+
}
|
|
431
|
+
isWaiting(): boolean {
|
|
432
|
+
const s = this.core.state.goal?.scheduler;
|
|
433
|
+
return !this.inRun && !!s && (s.phase === "waiting" || (!!this.ctx && loadGoalSettings(this.ctx.cwd).idleContinuationBackoff === true && this.idleDelay(s) > 0));
|
|
434
|
+
}
|
|
435
|
+
isDenied(): boolean { return this.denied; }
|
|
436
|
+
}
|