pi-goal-x 0.31.3 → 0.31.4
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 +38 -0
- package/extensions/goal-commands.ts +8 -28
- package/extensions/goal-core-tools.ts +14 -5
- package/extensions/goal-events.ts +25 -32
- package/extensions/goal-record.ts +11 -1
- package/extensions/goal-runtime.ts +13 -5
- package/extensions/goal-scheduler-state.ts +67 -0
- package/extensions/goal-scheduler.ts +327 -0
- package/extensions/goal-session-health.ts +1 -1
- package/extensions/goal-settings.ts +17 -0
- package/extensions/goal-state.ts +18 -4
- package/extensions/goal-status.ts +3 -0
- package/extensions/prompts/goal-prompts.ts +7 -4
- package/extensions/widgets/goal-dashboard-model.ts +5 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +3 -0
- package/extensions/widgets/goal-widget.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,7 @@ Open `/goal-settings` to change these options. You can save defaults for all pro
|
|
|
115
115
|
|
|
116
116
|
| Setting | What it controls |
|
|
117
117
|
| --- | --- |
|
|
118
|
+
| Autonomous run allowance (`maxAutonomousRuns`) | Positive whole number of extension-started runs per creation or `/goal-resume` period. **Unset disables automatic continuation.** Settings edits change the limit without resetting usage. |
|
|
118
119
|
| Task tracking (`disableTasks`) | Turn task lists on or off. Set to `true` to disable them. |
|
|
119
120
|
| Subtask depth (`subtaskDepth`) | Limit how many levels of subtasks the agent can create. |
|
|
120
121
|
| Completion requirements (`disableContracts`) | Turn explicit goal and task completion requirements on or off. Set to `true` to disable them. |
|
|
@@ -122,6 +123,43 @@ Open `/goal-settings` to change these options. You can save defaults for all pro
|
|
|
122
123
|
| Auditor provider, model, and thinking level | Choose which model reviews completed work and its reasoning effort. |
|
|
123
124
|
|
|
124
125
|
|
|
126
|
+
### Explicit execution and waiting
|
|
127
|
+
|
|
128
|
+
Goals no longer restart merely because they remain unfinished or a tool was used. Before yielding, the agent declares runnable work or an external wait using `update_goal`, or reports complete, paused, or blocked. A missing decision permits one repair prompt within the remaining allowance, then pauses.
|
|
129
|
+
|
|
130
|
+
Set an appropriate allowance in `/goal-settings`, or in `.pi/pi-goal-x-settings.json`:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "maxAutonomousRuns": 20 }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Agents may edit this setting. Changing it does not replenish consumed runs; explicit `/goal-resume` renews the period and continues now, including from a waiting goal. It requires a configured allowance. Tool calls within a run are not separate runs. Existing token budgets still apply.
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
update_goal({ continuation: { kind: "ready", next_action: "Verify the build artifacts" } })
|
|
140
|
+
update_goal({ continuation: {
|
|
141
|
+
kind: "wait", reason: "Await the remote build",
|
|
142
|
+
deadline: "2026-09-15T12:00:00Z",
|
|
143
|
+
polling: { interval_seconds: 60, max_checks: 3 }
|
|
144
|
+
} })
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Use a future deadline appropriate to the task. Omit `polling` for an event-only wait. Successful declarations terminate the execution segment. On a scheduled check, reuse the returned `wait_id` and original deadline, omitting `polling`; remaining checks cannot be reset. A ready decision ends the wait. Time spent waiting is not active execution time.
|
|
148
|
+
|
|
149
|
+
The dashboard, `/goal-status`, and `get_goal` show scheduling state, timing, checks, and allowance consumption. Expired waits and exhausted checks or allowance pause without another model call. Waits survive reopening the same session, without replaying missed checks; Pi must remain open for timers to execute. Another session requires explicit resume to take ownership. An ambiguous interrupted dispatch requires resume instead of automatic replay.
|
|
150
|
+
|
|
151
|
+
### Background producer integration
|
|
152
|
+
|
|
153
|
+
Budget-controlled producers emit a scheduler signal instead of starting their own model turn:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
pi.events.emit("pi-goal:wake", { goalId, waitToken });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`waitToken` is returned in the wait declaration's tool-result details. Register it before the producer completes, or retain the completion until registration (for example, observe the `update_goal` tool result in the host adapter). The token changes after consumption and re-declaration. A matching signal received before agent settlement is retained; duplicate, stale and wrong-goal tokens are ignored. A signal and timer can claim only one wake.
|
|
160
|
+
|
|
161
|
+
Existing producers that directly send `triggerTurn`/`followUp` messages still run as ordinary host work and supersede old pending decisions. Those independently started turns are **outside this extension's allowance**; use `pi-goal:wake` to put them through its spending gate. The allowance also does not limit Pi's own within-run tool loop or native retries. It bounds the goal extension's kickoff, continuation, check, signal, repair and recovery dispatches.
|
|
162
|
+
|
|
125
163
|
## License
|
|
126
164
|
|
|
127
165
|
MIT
|
|
@@ -278,6 +278,7 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
278
278
|
const health = /^\s*health\b/i.test(rawArgs);
|
|
279
279
|
const ledger = readGoalLedger(ctx);
|
|
280
280
|
const text = buildGoalStatusText({
|
|
281
|
+
maxAutonomousRuns: loadGoalSettings(ctx.cwd).maxAutonomousRuns,
|
|
281
282
|
goal: view,
|
|
282
283
|
focused: view !== null && core.focusedGoalId === view.id,
|
|
283
284
|
otherOpenGoals: otherCount,
|
|
@@ -332,33 +333,10 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
332
333
|
if (!core.state.goal && otherOpenGoalCount(core.goalsById, null) > 0) {
|
|
333
334
|
const selected = await chooseOpenGoal(ctx, "Resume or focus open goal");
|
|
334
335
|
if (!selected) return;
|
|
335
|
-
if (selected.status === "active") {
|
|
336
|
-
core.armFocusedContinuation(ctx);
|
|
337
|
-
ctx.ui.notify(`Goal focused: ${oneLineSummary(selected)}`, "info");
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
336
|
}
|
|
341
|
-
|
|
342
|
-
if (!
|
|
343
|
-
|
|
344
|
-
ctx.ui.notify(resumeGate.message, level);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (!core.state.goal) throw new Error("Goal disappeared during resume validation.");
|
|
348
|
-
core.setGoal(
|
|
349
|
-
{
|
|
350
|
-
...mergeGoalPromptFromDisk(ctx, core.state.goal),
|
|
351
|
-
status: "active",
|
|
352
|
-
autoContinue: true,
|
|
353
|
-
stopReason: undefined,
|
|
354
|
-
pauseReason: undefined,
|
|
355
|
-
pauseSuggestedAction: undefined,
|
|
356
|
-
},
|
|
357
|
-
ctx,
|
|
358
|
-
);
|
|
359
|
-
core.beginAccounting();
|
|
360
|
-
ctx.ui.notify("Goal resumed.", "info");
|
|
361
|
-
core.queueContinuation(ctx, true);
|
|
337
|
+
if (!core.state.goal) { ctx.ui.notify("No goal is focused.", "warning"); return; }
|
|
338
|
+
if (!core.scheduler.resume(ctx)) return;
|
|
339
|
+
ctx.ui.notify("Goal resumed; autonomous allowance renewed.", "info");
|
|
362
340
|
// Append ledger event for resumption
|
|
363
341
|
try {
|
|
364
342
|
core.goalService.appendEvents(ctx, [{
|
|
@@ -392,6 +370,7 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
392
370
|
{ key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
|
|
393
371
|
{ key: "hideUnfocusedBanner", label: "hideUnfocusedBanner", section: "Goal behavior", kind: "boolean" },
|
|
394
372
|
{ key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
|
|
373
|
+
{ key: "maxAutonomousRuns", label: "autonomous run allowance", section: "Goal behavior", kind: "positiveInteger" },
|
|
395
374
|
{ key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
|
|
396
375
|
{ key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
|
|
397
376
|
{ key: "disableTasks", label: "disableTasks", section: "Task tracking", kind: "boolean" },
|
|
@@ -414,6 +393,7 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
414
393
|
return config[key] === true ? "true" : "false";
|
|
415
394
|
}
|
|
416
395
|
if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
|
|
396
|
+
if (key === "maxAutonomousRuns") return config.maxAutonomousRuns === 0 ? "0 (disabled)" : String(config.maxAutonomousRuns ?? "unlimited (default)");
|
|
417
397
|
if (key === "stallTimeoutMinutes") return config.stallTimeoutMinutes !== undefined ? String(config.stallTimeoutMinutes) : "0";
|
|
418
398
|
if (key === "objectiveMaxChars") return config.objectiveMaxChars !== undefined ? String(config.objectiveMaxChars) : "0";
|
|
419
399
|
if (key === "keybindings") return config.keybindings ? `${config.keybindings.dashboard.toggleExpand}, ${config.keybindings.dashboard.scrollUp}, ${config.keybindings.dashboard.scrollDown}` : "(default)";
|
|
@@ -564,7 +544,7 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
564
544
|
}
|
|
565
545
|
|
|
566
546
|
if (row.kind === "positiveInteger") {
|
|
567
|
-
const min = row.path ? 1 : ((row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1);
|
|
547
|
+
const min = row.path ? 1 : ((row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars" || row.key === "maxAutonomousRuns") ? 0 : 1);
|
|
568
548
|
const actions = [`Set ${scope} override...`];
|
|
569
549
|
if (hasLocalOverride) actions.push(inheritLabel);
|
|
570
550
|
actions.push("Cancel");
|
|
@@ -825,7 +805,7 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
825
805
|
},
|
|
826
806
|
});
|
|
827
807
|
pi.registerCommand("goal-resume", {
|
|
828
|
-
description: "
|
|
808
|
+
description: "Continue now and renew the configured autonomous-run allowance.",
|
|
829
809
|
handler: async (_rawArgs, ctx) => {
|
|
830
810
|
await handleGoalResume(ctx);
|
|
831
811
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { schedulerSummary, type GoalContinuation } from "./goal-scheduler-state.ts";
|
|
1
2
|
import { goalDetailPage, type GoalDetailSection } from "./goal-detail.ts";
|
|
2
3
|
import { taskIndex } from "./goal-task-index.ts";
|
|
3
4
|
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
@@ -86,7 +87,7 @@ pi.registerTool(defineTool({
|
|
|
86
87
|
}
|
|
87
88
|
if (params.cursor || params.task_id) return {content: [{type: "text", text: "Use section=objective, tasks, or history for detail retrieval; task_id requires tasks."}], details: goalDetails(view)};
|
|
88
89
|
if (verbose && !params.section) {
|
|
89
|
-
const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}
|
|
90
|
+
const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns)];
|
|
90
91
|
lines.push(`Objective: ${view.objective}`, "");
|
|
91
92
|
lines.push(`Status: ${statusLabel(view)}`);
|
|
92
93
|
lines.push(`Mode: ${view.sisyphus ? "sisyphus" : "regular"}`);
|
|
@@ -129,7 +130,7 @@ pi.registerTool(defineTool({
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
// Compact state read; full requirements remain available through detail pages.
|
|
132
|
-
const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}
|
|
133
|
+
const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns)];
|
|
133
134
|
lines.push(`Objective: ${truncateText(view.objective, 180)}${view.objective.length > 180 ? " (full: get_goal section=objective)" : ""}`);
|
|
134
135
|
if (view.taskList) {
|
|
135
136
|
const { findCurrentTask, firstPendingTask } = conciseTaskPointers(view);
|
|
@@ -508,11 +509,15 @@ async function runGoalAgentPauseFlow(ctx: ExtensionContext, reason: string | und
|
|
|
508
509
|
pi.registerTool(defineTool({
|
|
509
510
|
name: "update_goal",
|
|
510
511
|
label: "Update Goal",
|
|
511
|
-
description: "
|
|
512
|
-
promptSnippet: "
|
|
512
|
+
description: "End execution with status OR continuation. Wait: future ISO deadline; reuse wait_id/deadline without polling on recheck. Completion is audited.",
|
|
513
|
+
promptSnippet: "Declare the next execution disposition or complete, block, or pause.",
|
|
513
514
|
promptGuidelines: [],
|
|
514
515
|
parameters: Type.Object({
|
|
515
|
-
status: StringEnum(["complete", "blocked", "paused"] as const, { description: "
|
|
516
|
+
status: Type.Optional(StringEnum(["complete", "blocked", "paused"] as const, { description: "Exclusive with continuation." })),
|
|
517
|
+
continuation: Type.Optional(Type.Union([
|
|
518
|
+
Type.Object({ kind: Type.Literal("ready"), next_action: Type.String({ minLength: 1, maxLength: 2000 }) }, { additionalProperties: false }),
|
|
519
|
+
Type.Object({ kind: Type.Literal("wait"), reason: Type.String({ minLength: 1, maxLength: 2000 }), deadline: Type.String(), wait_id: Type.Optional(Type.String()), polling: Type.Optional(Type.Object({ interval_seconds: Type.Integer({ minimum: 1, maximum: 2147483 }), max_checks: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) }, { additionalProperties: false })) }, { additionalProperties: false }),
|
|
520
|
+
])),
|
|
516
521
|
reason: Type.Optional(Type.String({ description: "Required when status is paused or blocked: describe the concrete blocker." })),
|
|
517
522
|
attempted_actions: Type.Optional(Type.Array(Type.String({ maxLength: 240 }), { maxItems: 8, description: "Actions attempted against the blocker." })),
|
|
518
523
|
suggested_action: Type.Optional(Type.String({ description: "Optional suggested next step when status is paused." })),
|
|
@@ -523,6 +528,10 @@ pi.registerTool(defineTool({
|
|
|
523
528
|
// P1-3: persist any buffered in-turn mutations now so the auditor and
|
|
524
529
|
// status transitions observe the current task/state, not the stale disk.
|
|
525
530
|
core.flushGoalTransaction(ctx);
|
|
531
|
+
if (!!params.continuation === !!params.status || (params.continuation && (params.reason !== undefined || params.attempted_actions !== undefined || params.suggested_action !== undefined || params.completion_summary !== undefined))) {
|
|
532
|
+
return { content: [{ type: "text", text: "Provide exactly one lifecycle status or continuation, without mixing their fields." }], details: {}, terminate: false };
|
|
533
|
+
}
|
|
534
|
+
if (params.continuation) return core.scheduler.declare(ctx, params.continuation as GoalContinuation);
|
|
526
535
|
if (params.status === "blocked") {
|
|
527
536
|
const attempted = Array.isArray((params as { attempted_actions?: unknown }).attempted_actions)
|
|
528
537
|
? ((params as { attempted_actions: unknown[] }).attempted_actions.filter((a): a is string => typeof a === "string"))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { schedulerSummary } from "./goal-scheduler-state.ts";
|
|
1
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import {
|
|
3
4
|
GOAL_EVENT_ENTRY,
|
|
@@ -10,7 +11,6 @@ import {
|
|
|
10
11
|
isAbortedAssistantMessage,
|
|
11
12
|
isErrorAssistantMessage,
|
|
12
13
|
isMeaningfulProgressToolCall,
|
|
13
|
-
isToolUseAssistantMessage,
|
|
14
14
|
} from "./goal-format.ts";
|
|
15
15
|
import { buildCompactionSummary, buildPostCompactionGoalDelta } from "./goal-compaction.ts";
|
|
16
16
|
import { latestAuditorResultForGoal, readGoalLedger, goalRuntimeEvents, invalidateGoalLedgerCache } from "./goal-ledger.ts";
|
|
@@ -86,19 +86,28 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
86
86
|
const { pi } = core;
|
|
87
87
|
let continuationAfterSettleFor: string | null = null;
|
|
88
88
|
let networkErrorRecoveryAfterSettleFor: string | null = null;
|
|
89
|
-
// Run-scoped empty-turn gate: turn_start resets the per-turn flag, so a text-only
|
|
90
|
-
// last turn after earlier work must not wipe the run. before_agent_start starts a
|
|
91
|
-
// new run. agent_settled must not auto-continue a run that did no goal work, or
|
|
92
|
-
// empty replies ("Paused. No action.") loop forever.
|
|
93
|
-
let goalWorkThisRun = false;
|
|
94
89
|
|
|
95
|
-
pi.on("context", async (event) => {
|
|
90
|
+
pi.on("context", async (event, ctx) => {
|
|
96
91
|
const filtered = filterGoalSessionContext(event.messages);
|
|
97
92
|
const messages = compactGoalCheckpointContext(filtered ?? event.messages, core.state.goal) ?? filtered;
|
|
93
|
+
if (core.scheduler.needsLiveContext() && core.state.goal) {
|
|
94
|
+
const goal = core.state.goal;
|
|
95
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
96
|
+
// Custom-message runs bypass before_agent_start. Refresh mutable scheduling
|
|
97
|
+
// state, but do not repeat policy/objective already in the inherited prompt.
|
|
98
|
+
const content = ctx.getSystemPrompt?.().includes(`[PI GOAL ACTIVE goalId=${goal.id}]`)
|
|
99
|
+
? `[CURRENT EXECUTION STATE goalId=${goal.id}]\nStatus: ${goal.status}; autoContinue: ${goal.autoContinue}.\n${schedulerSummary(goal.scheduler, settings.maxAutonomousRuns)}`
|
|
100
|
+
: goalPrompt(goal, settings);
|
|
101
|
+
const live = { role: "custom" as const, customType: "pi-goal-live-context", content, display: false, timestamp: Date.now() };
|
|
102
|
+
return { messages: [live, ...(messages ?? event.messages)] as typeof event.messages };
|
|
103
|
+
}
|
|
98
104
|
return messages === null ? undefined : { messages: messages as typeof event.messages };
|
|
99
105
|
});
|
|
100
106
|
|
|
107
|
+
pi.on("agent_start", async (_event, ctx) => { core.scheduler.begin(ctx); });
|
|
108
|
+
pi.on("message_start", async (event, ctx) => { core.scheduler.message(ctx, event.message); });
|
|
101
109
|
pi.on("turn_start", async (_event, ctx) => {
|
|
110
|
+
core.scheduler.turn(ctx);
|
|
102
111
|
// Per-turn flag resets (#4 + C9 fix).
|
|
103
112
|
core.advanceTurnSeq();
|
|
104
113
|
core.goalWorkToolCalledThisTurn = false;
|
|
@@ -111,6 +120,7 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
111
120
|
// #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
|
|
112
121
|
pi.on("tool_call", async (event, ctx) => {
|
|
113
122
|
const stoppedGoalId = core.currentTurnStoppedGoalId();
|
|
123
|
+
if (core.scheduler.isDenied()) return { block: true, reason: "Stale goal dispatch; use /goal-resume." };
|
|
114
124
|
// Post-stop in-turn block: after update_goal / set_goal_tasks (or a user
|
|
115
125
|
// lifecycle command) fires in this turn, block all subsequent tool calls
|
|
116
126
|
// except read-only inspection.
|
|
@@ -133,10 +143,9 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
133
143
|
`End the turn with a brief summary and yield to the user.`,
|
|
134
144
|
};
|
|
135
145
|
}
|
|
136
|
-
// Track
|
|
146
|
+
// Track Oracle work attempts; this does not authorize scheduling.
|
|
137
147
|
if (isMeaningfulProgressToolCall(event.toolName, asRecord(event)?.args)) {
|
|
138
148
|
core.goalWorkToolCalledThisTurn = true;
|
|
139
|
-
goalWorkThisRun = true;
|
|
140
149
|
// Issue #26: record a meaningful work attempt against armed Oracle
|
|
141
150
|
// advice. get_goal / echo-only reads are excluded upstream by
|
|
142
151
|
// isMeaningfulProgressToolCall.
|
|
@@ -252,17 +261,6 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
252
261
|
core.updateUI(ctx);
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
// If the assistant ended a turn without queuing more tool calls, push a continuation right away.
|
|
256
|
-
// #4: only queue if some real work was done this turn — otherwise the model is
|
|
257
|
-
// just chatting and we should not keep firing turns on noise.
|
|
258
|
-
if (
|
|
259
|
-
!isToolUseAssistantMessage(message)
|
|
260
|
-
&& core.state.goal?.status === "active"
|
|
261
|
-
&& core.state.goal.autoContinue
|
|
262
|
-
&& core.goalWorkToolCalledThisTurn
|
|
263
|
-
) {
|
|
264
|
-
core.queueContinuation(ctx);
|
|
265
|
-
}
|
|
266
264
|
core.goalService.endTurn(ctx); // P1-3: single flush (lock + write + ledger batch)
|
|
267
265
|
});
|
|
268
266
|
|
|
@@ -311,11 +309,11 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
311
309
|
const current = core.state.goal;
|
|
312
310
|
const shouldResume = await ctx.ui.confirm("Resume paused goal?", `Goal: ${current.objective}`);
|
|
313
311
|
if (shouldResume) {
|
|
314
|
-
core.
|
|
312
|
+
core.scheduler.resume(ctx);
|
|
315
313
|
}
|
|
316
314
|
}
|
|
317
315
|
core.beginAccounting();
|
|
318
|
-
core.
|
|
316
|
+
core.scheduler.restore(ctx);
|
|
319
317
|
});
|
|
320
318
|
|
|
321
319
|
pi.on("session_before_compact", async (_event, ctx) => {
|
|
@@ -341,11 +339,11 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
341
339
|
rehydrateDraft(core, ctx);
|
|
342
340
|
syncTerminalInputPause(core, ctx);
|
|
343
341
|
core.beginAccounting();
|
|
344
|
-
core.
|
|
342
|
+
core.scheduler.restore(ctx);
|
|
345
343
|
});
|
|
346
344
|
|
|
347
345
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
348
|
-
|
|
346
|
+
core.scheduler.prepare();
|
|
349
347
|
core.advanceTurnSeq();
|
|
350
348
|
if (!hasActiveDraft(core)) core.installGoalToolProfile(!loadGoalSettings(ctx.cwd).disableTasks);
|
|
351
349
|
const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
|
|
@@ -531,10 +529,6 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
531
529
|
core.runtime.clearNetworkErrorBackoff();
|
|
532
530
|
core.persist(ctx);
|
|
533
531
|
core.updateUI(ctx);
|
|
534
|
-
// Empty-turn gate: agent_end clears any turn_end continuation timer, then
|
|
535
|
-
// agent_settled re-queues with force=true. Without this check, a no-tool
|
|
536
|
-
// reply (chat, or "Paused. No action." after resume) loops forever.
|
|
537
|
-
if (!goalWorkThisRun) return;
|
|
538
532
|
// agent_end runs before pi finishes retries, compaction, terminating-tool
|
|
539
533
|
// settlement, and queued messages. Starting the continuation timer here
|
|
540
534
|
// can poll a stale busy context for minutes on pi 0.84. agent_settled is
|
|
@@ -549,10 +543,8 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
549
543
|
continuationAfterSettleFor = null;
|
|
550
544
|
const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
|
|
551
545
|
networkErrorRecoveryAfterSettleFor = null;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
546
|
+
core.scheduler.settled(ctx, !!goalId && core.isActionableContinuationGoal(goalId));
|
|
547
|
+
if (goalId && core.isActionableContinuationGoal(goalId)) return;
|
|
556
548
|
if (!networkErrorGoalId || !core.isActionableContinuationGoal(networkErrorGoalId)) return;
|
|
557
549
|
const recovery = loadGoalSettings(ctx.cwd).networkRecovery;
|
|
558
550
|
const policy = recovery
|
|
@@ -576,6 +568,7 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
576
568
|
|
|
577
569
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
578
570
|
core.auditMessages.clear();
|
|
571
|
+
core.scheduler.shutdown();
|
|
579
572
|
continuationAfterSettleFor = null;
|
|
580
573
|
networkErrorRecoveryAfterSettleFor = null;
|
|
581
574
|
core.accountProgress(ctx);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeGoalScheduler, type GoalSchedulerState } from "./goal-scheduler-state.ts";
|
|
1
2
|
export type GoalStatus = "active" | "paused" | "blocked" | "budget_limited" | "complete";
|
|
2
3
|
export type StopReason = "user" | "agent";
|
|
3
4
|
export type GoalEventKind = "checkpoint" | "stale";
|
|
@@ -56,6 +57,7 @@ export interface GoalRecord {
|
|
|
56
57
|
revision?: number;
|
|
57
58
|
/** Optional token budget (whole tokens). When accounted usage reaches it, the runtime marks the goal budget_limited. */
|
|
58
59
|
tokenBudget?: number;
|
|
60
|
+
scheduler?: GoalSchedulerState;
|
|
59
61
|
/**
|
|
60
62
|
* Execution focus: the id of the task (or subtask) the agent is working on.
|
|
61
63
|
* Optional for backward compatibility; normalized at load (only an existing
|
|
@@ -119,7 +121,13 @@ export interface GoalStaleDetails {
|
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/** Everything that can appear in a persisted pi-goal-event entry's details. */
|
|
122
|
-
export
|
|
124
|
+
export interface GoalCheckpointDetailsV3 extends Omit<GoalCheckpointDetailsV2, "version"> {
|
|
125
|
+
version: 3;
|
|
126
|
+
generation: string;
|
|
127
|
+
dispatchId: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type GoalEventEntryDetails = GoalCheckpointDetailsV3 | GoalCheckpointDetailsV2 | LegacyGoalEventDetails | GoalStaleDetails;
|
|
123
131
|
|
|
124
132
|
/**
|
|
125
133
|
* Legacy normalized renderer shape. Kept as the stable contract for
|
|
@@ -181,6 +189,7 @@ function cloneGoalTask(task: GoalTask): GoalTask {
|
|
|
181
189
|
export function cloneGoal(goal: GoalRecord): GoalRecord {
|
|
182
190
|
return {
|
|
183
191
|
...goal,
|
|
192
|
+
...(goal.scheduler ? { scheduler: structuredClone(goal.scheduler) } : {}),
|
|
184
193
|
usage: { ...goal.usage },
|
|
185
194
|
taskList: goal.taskList
|
|
186
195
|
? { ...goal.taskList, tasks: goal.taskList.tasks.map(cloneGoalTask) }
|
|
@@ -370,6 +379,7 @@ export function normalizeGoalRecord(value: unknown): GoalRecord | null {
|
|
|
370
379
|
skipAuditor: raw.skipAuditor === true ? true : undefined,
|
|
371
380
|
revision: Number.isSafeInteger(raw.revision) && (raw.revision as number) >= 0 ? (raw.revision as number) : 0,
|
|
372
381
|
tokenBudget: normalizePositiveSafeInteger(raw.tokenBudget),
|
|
382
|
+
...(raw.scheduler !== undefined ? { scheduler: normalizeGoalScheduler(raw.scheduler) } : {}),
|
|
373
383
|
taskList,
|
|
374
384
|
currentTaskId,
|
|
375
385
|
verificationContract: typeof raw.verificationContract === "string" ? raw.verificationContract : undefined,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import type {
|
|
12
|
+
import type { GoalCheckpointDetailsV3, GoalRecord } from "./goal-record.ts";
|
|
13
13
|
import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
|
|
14
14
|
import { POST_STOP_ALLOWED_TOOLS } from "./goal-tool-names.ts";
|
|
15
15
|
import { networkErrorBackoffPlan, type NetworkErrorBackoffPlan, type NetworkErrorRecoveryPolicy } from "./network-error-backoff.ts";
|
|
@@ -19,6 +19,9 @@ export const CONTINUATION_IDLE_RETRY_MS = 50;
|
|
|
19
19
|
const POST_STOP_ALLOWED = new Set<string>(POST_STOP_ALLOWED_TOOLS);
|
|
20
20
|
|
|
21
21
|
export interface GoalRuntimeHooks {
|
|
22
|
+
authorize?(ctx: ExtensionContext, goal: GoalRecord): Record<string, unknown> | null;
|
|
23
|
+
recover?(ctx: ExtensionContext): void;
|
|
24
|
+
dispatchFailed?(ctx: ExtensionContext): void;
|
|
22
25
|
/** Dispatch a hidden follow-up checkpoint message (pi.sendMessage + triggerTurn). */
|
|
23
26
|
sendFollowUp(content: string, details: Record<string, unknown>): void;
|
|
24
27
|
/** Current focused goal (state.goal). */
|
|
@@ -125,7 +128,7 @@ export class GoalRuntime {
|
|
|
125
128
|
if (!this.hooks.isActionable(goal.id)) return;
|
|
126
129
|
const currentGoal = this.hooks.getGoal();
|
|
127
130
|
if (!currentGoal || currentGoal.id !== goal.id) return;
|
|
128
|
-
this.
|
|
131
|
+
this.hooks.recover?.(ctx);
|
|
129
132
|
}, plan.delayMs);
|
|
130
133
|
this.networkErrorRetryTimer.unref?.();
|
|
131
134
|
return plan;
|
|
@@ -172,10 +175,14 @@ export class GoalRuntime {
|
|
|
172
175
|
if (this.continuationQueuedFor === scheduledGoalId) this.continuationQueuedFor = null;
|
|
173
176
|
return;
|
|
174
177
|
}
|
|
178
|
+
const authorization = this.hooks.authorize?.(ctx, goal);
|
|
179
|
+
if (!authorization) return;
|
|
175
180
|
this.checkpointSeq += 1;
|
|
176
181
|
this.continuationQueuedFor = goal.id;
|
|
177
|
-
const details:
|
|
178
|
-
version:
|
|
182
|
+
const details: GoalCheckpointDetailsV3 = {
|
|
183
|
+
version: 3,
|
|
184
|
+
generation: String(authorization.generation),
|
|
185
|
+
dispatchId: String(authorization.dispatchId),
|
|
179
186
|
kind: "checkpoint",
|
|
180
187
|
goalId: goal.id,
|
|
181
188
|
status: "active",
|
|
@@ -183,7 +190,8 @@ export class GoalRuntime {
|
|
|
183
190
|
checkpointSeq: this.checkpointSeq,
|
|
184
191
|
timestamp: Date.now(),
|
|
185
192
|
};
|
|
186
|
-
this.hooks.sendFollowUp(checkpointTriggerPrompt(goal.id), details as unknown as Record<string, unknown>);
|
|
193
|
+
try { this.hooks.sendFollowUp(checkpointTriggerPrompt(goal.id), details as unknown as Record<string, unknown>); }
|
|
194
|
+
catch { this.hooks.dispatchFailed?.(ctx); }
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
// ── turn-stop guard ──────────────────────────────────────────────────
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type GoalDispatchKind = "ready" | "check" | "wake" | "repair" | "kickoff" | "recovery";
|
|
4
|
+
export interface GoalWait {
|
|
5
|
+
id: string;
|
|
6
|
+
token: string;
|
|
7
|
+
reason: string;
|
|
8
|
+
deadline: number;
|
|
9
|
+
intervalMs?: number;
|
|
10
|
+
remainingChecks?: number;
|
|
11
|
+
nextCheckAt?: number;
|
|
12
|
+
signalled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface GoalSchedulerState {
|
|
15
|
+
version: 1;
|
|
16
|
+
owner: string;
|
|
17
|
+
generation: string;
|
|
18
|
+
used: number;
|
|
19
|
+
phase: "idle" | "ready" | "waiting" | "claimed" | "running" | "interrupted";
|
|
20
|
+
decision?: { kind: "ready"; nextAction: string; purpose: GoalDispatchKind } | { kind: "wait" };
|
|
21
|
+
wait?: GoalWait;
|
|
22
|
+
dispatch?: { id: string; kind: GoalDispatchKind; claimedAt: number };
|
|
23
|
+
repairUsed: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type GoalContinuation =
|
|
26
|
+
| { kind: "ready"; next_action: string }
|
|
27
|
+
| { kind: "wait"; reason: string; deadline: string; wait_id?: string; polling?: { interval_seconds: number; max_checks: number } };
|
|
28
|
+
|
|
29
|
+
export function newGoalScheduler(owner: string): GoalSchedulerState {
|
|
30
|
+
return { version: 1, owner, generation: randomUUID(), used: 0, phase: "idle", repairUsed: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Corrupt scheduling data must not silently reset spent allowance. */
|
|
34
|
+
export function normalizeGoalScheduler(raw: unknown): GoalSchedulerState | undefined {
|
|
35
|
+
if (raw === undefined) return undefined;
|
|
36
|
+
const invalid = (): GoalSchedulerState => ({ ...newGoalScheduler("invalid"), phase: "interrupted", used: Number.MAX_SAFE_INTEGER });
|
|
37
|
+
if (!raw || typeof raw !== "object") return invalid();
|
|
38
|
+
const s = raw as GoalSchedulerState;
|
|
39
|
+
const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
40
|
+
const text = (v: unknown) => typeof v === "string" && v.length > 0 && v.length <= 2000;
|
|
41
|
+
if (s.version !== 1 || !text(s.owner) || !text(s.generation) || !integer(s.used) || typeof s.repairUsed !== "boolean" || !["idle", "ready", "waiting", "claimed", "running", "interrupted"].includes(s.phase)) return invalid();
|
|
42
|
+
if (s.decision && (s.decision.kind !== "wait" && (s.decision.kind !== "ready" || !text(s.decision.nextAction) || !["ready", "repair", "kickoff", "recovery"].includes(s.decision.purpose)))) return invalid();
|
|
43
|
+
if (s.wait) {
|
|
44
|
+
const w = s.wait;
|
|
45
|
+
if (!text(w.id) || !text(w.token) || !text(w.reason) || (!integer(w.deadline) || w.deadline > 8_640_000_000_000_000)) return invalid();
|
|
46
|
+
if (w.intervalMs !== undefined && (!integer(w.intervalMs) || w.intervalMs < 1000 || !integer(w.remainingChecks) || !integer(w.nextCheckAt))) return invalid();
|
|
47
|
+
if (w.intervalMs === undefined && (w.remainingChecks !== undefined || w.nextCheckAt !== undefined)) return invalid();
|
|
48
|
+
if (w.signalled !== undefined && typeof w.signalled !== "boolean") return invalid();
|
|
49
|
+
}
|
|
50
|
+
if (s.dispatch && (!text(s.dispatch.id) || !integer(s.dispatch.claimedAt) || !["ready", "check", "wake", "repair", "kickoff", "recovery"].includes(s.dispatch.kind))) return invalid();
|
|
51
|
+
if ((s.phase === "claimed" || s.phase === "running") && !s.dispatch) return invalid();
|
|
52
|
+
if (s.phase === "ready" && s.decision?.kind !== "ready") return invalid();
|
|
53
|
+
if (s.phase === "waiting" && (!s.wait || s.decision?.kind !== "wait")) return invalid();
|
|
54
|
+
return structuredClone(s);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function schedulerSummary(s: GoalSchedulerState | undefined, limit?: number): string {
|
|
58
|
+
const lines = [`Autonomous runs: ${s?.used ?? 0}/${limit ?? "unlimited"}${limit === 0 ? " (automatic continuation disabled)" : ""}.`];
|
|
59
|
+
if (!s) return lines.join("\n");
|
|
60
|
+
if (s.decision?.kind === "ready") lines.push(`Next action: ${s.decision.nextAction}`);
|
|
61
|
+
if (s.wait) {
|
|
62
|
+
lines.push(`Waiting: ${s.wait.reason}; wait_id=${s.wait.id}; deadline=${new Date(s.wait.deadline).toISOString()}.`);
|
|
63
|
+
if (s.wait.nextCheckAt !== undefined) lines.push(`Next check: ${new Date(s.wait.nextCheckAt).toISOString()}; ${s.wait.remainingChecks} checks remaining.`);
|
|
64
|
+
}
|
|
65
|
+
if (s.phase === "interrupted" || s.phase === "claimed") lines.push("Execution requires dispatch admission or explicit /goal-resume after interruption.");
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionContext, AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { GoalCore } from "./goal-state.ts";
|
|
4
|
+
import { asRecord, type GoalRecord } from "./goal-record.ts";
|
|
5
|
+
import { budgetReached } from "./goal-accounting.ts";
|
|
6
|
+
import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
|
|
7
|
+
import { newGoalScheduler, schedulerSummary, type GoalContinuation, type GoalSchedulerState } from "./goal-scheduler-state.ts";
|
|
8
|
+
|
|
9
|
+
/** Scheduling intent is durable; timers only arrange an opportunity to claim it. */
|
|
10
|
+
export class GoalScheduler {
|
|
11
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
12
|
+
private ctx: ExtensionContext | undefined;
|
|
13
|
+
private inRun = false;
|
|
14
|
+
private prepared = false;
|
|
15
|
+
private liveContext = false;
|
|
16
|
+
private armedGeneration: string | undefined;
|
|
17
|
+
private declared = false;
|
|
18
|
+
private runGoalId: string | undefined;
|
|
19
|
+
private denied = false;
|
|
20
|
+
private unsubscribe: (() => void) | undefined;
|
|
21
|
+
private core: GoalCore;
|
|
22
|
+
constructor(core: GoalCore) { this.core = core; }
|
|
23
|
+
|
|
24
|
+
private owner(ctx: ExtensionContext): string { return ctx.sessionManager.getSessionId() || "unknown-session"; }
|
|
25
|
+
private limit(ctx: ExtensionContext): number | undefined { return loadGoalSettings(ctx.cwd).maxAutonomousRuns; }
|
|
26
|
+
private available(ctx: ExtensionContext, s: GoalSchedulerState): boolean {
|
|
27
|
+
const limit = this.limit(ctx);
|
|
28
|
+
return limit === undefined || s.used < limit;
|
|
29
|
+
}
|
|
30
|
+
private state(ctx: ExtensionContext, goal: GoalRecord): GoalSchedulerState {
|
|
31
|
+
const s = goal.scheduler ?? newGoalScheduler(this.owner(ctx));
|
|
32
|
+
if (s.owner !== this.owner(ctx) || s.phase === "interrupted") throw new Error("Goal scheduling belongs to another session or was interrupted. Use /goal-resume to take ownership.");
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
private write(ctx: ExtensionContext, change: (g: GoalRecord) => GoalRecord): GoalRecord {
|
|
36
|
+
const error = this.core.goalService.flushForAudit(ctx);
|
|
37
|
+
if (error || this.core.goalService.isTurnBuffered()) throw new Error(error || "Goal changes have not reached disk.");
|
|
38
|
+
const id = this.core.state.goal?.id;
|
|
39
|
+
if (!id) throw new Error("No focused goal.");
|
|
40
|
+
const result = this.core.goalService.apply(ctx, { expectedGoalId: id, mutate: change,
|
|
41
|
+
ledger: g => g.status === "paused" ? [{ type: "goal_paused", goalId: g.id, reason: g.pauseReason ?? "Scheduling paused", source: "agent", at: new Date().toISOString() }] : [],
|
|
42
|
+
});
|
|
43
|
+
if (!result.ok) throw new Error(result.message);
|
|
44
|
+
this.core.updateUI(ctx);
|
|
45
|
+
return result.goal;
|
|
46
|
+
}
|
|
47
|
+
private update(ctx: ExtensionContext, change: (s: GoalSchedulerState, g: GoalRecord) => void): GoalRecord {
|
|
48
|
+
return this.write(ctx, g => { const s = this.state(ctx, g); change(s, g); return { ...g, scheduler: s }; });
|
|
49
|
+
}
|
|
50
|
+
private safe(ctx: ExtensionContext, fn: () => void): void {
|
|
51
|
+
try { fn(); } catch (error) {
|
|
52
|
+
this.cancelTimer();
|
|
53
|
+
this.core.runtime.clearContinuationState();
|
|
54
|
+
ctx.ui.notify(`Goal scheduling stopped: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
cancelTimer(): void {
|
|
58
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
59
|
+
this.timer = undefined;
|
|
60
|
+
this.armedGeneration = undefined;
|
|
61
|
+
}
|
|
62
|
+
private later(ctx: ExtensionContext, delay: number, fn: () => void): void {
|
|
63
|
+
this.cancelTimer();
|
|
64
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.safe(ctx, fn); }, Math.min(2_147_483_647, Math.max(0, delay)));
|
|
65
|
+
this.timer.unref?.();
|
|
66
|
+
}
|
|
67
|
+
private pause(ctx: ExtensionContext, reason: string): void {
|
|
68
|
+
this.cancelTimer();
|
|
69
|
+
this.core.runtime.clearContinuationState();
|
|
70
|
+
this.write(ctx, g => {
|
|
71
|
+
if (g.scheduler && g.scheduler.owner !== this.owner(ctx)) throw new Error("Scheduling ownership changed; no pause was applied.");
|
|
72
|
+
return { ...g, status: "paused", autoContinue: false, stopReason: "agent", pauseReason: reason,
|
|
73
|
+
scheduler: g.scheduler ? { ...g.scheduler, generation: randomUUID(), phase: "idle", decision: undefined, dispatch: undefined, wait: undefined } : undefined };
|
|
74
|
+
});
|
|
75
|
+
this.core.clearActiveAccounting();
|
|
76
|
+
ctx.ui.notify(reason, "warning");
|
|
77
|
+
}
|
|
78
|
+
private allowanceReason(ctx: ExtensionContext): string {
|
|
79
|
+
return this.limit(ctx) === 0 ? "Automatic continuation disabled by maxAutonomousRuns=0. Change the setting in /goal-settings, then use /goal-resume." : "Autonomous-run allowance exhausted. Increase or remove maxAutonomousRuns, or use /goal-resume to renew.";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Bind once per live session. The event is deliberately not a triggerTurn message. */
|
|
83
|
+
attach(ctx: ExtensionContext): void {
|
|
84
|
+
this.ctx = ctx;
|
|
85
|
+
this.unsubscribe?.();
|
|
86
|
+
this.unsubscribe = this.core.pi.events?.on("pi-goal:wake", (event: unknown) => {
|
|
87
|
+
const raw = asRecord(event);
|
|
88
|
+
if (raw && this.ctx) this.signal(this.ctx, raw.goalId, raw.waitToken);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
shutdown(): void { this.cancelTimer(); this.unsubscribe?.(); this.unsubscribe = undefined; this.ctx = undefined; }
|
|
92
|
+
|
|
93
|
+
restore(ctx: ExtensionContext): void {
|
|
94
|
+
this.attach(ctx);
|
|
95
|
+
this.inRun = false; this.declared = false; this.runGoalId = undefined;
|
|
96
|
+
this.cancelTimer();
|
|
97
|
+
this.safe(ctx, () => {
|
|
98
|
+
this.core.reconcileFocusedGoalFromDisk(ctx);
|
|
99
|
+
const goal = this.core.state.goal;
|
|
100
|
+
if (!goal || goal.status !== "active" || !goal.autoContinue || !goal.scheduler) return;
|
|
101
|
+
const s = goal.scheduler;
|
|
102
|
+
if (s.owner !== this.owner(ctx)) { ctx.ui.notify("Goal owned by another session. Use /goal-resume to take ownership.", "info"); return; }
|
|
103
|
+
if (["claimed", "running", "interrupted"].includes(s.phase)) {
|
|
104
|
+
this.pause(ctx, "Previous autonomous execution was interrupted. Use /goal-resume; its dispatch will not be replayed.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.schedule(ctx);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Only explicit user resume resets spent allowance, including while already active/waiting. */
|
|
112
|
+
resume(ctx: ExtensionContext): boolean {
|
|
113
|
+
invalidateGoalSettingsCache();
|
|
114
|
+
if (this.limit(ctx) === 0) { ctx.ui.notify(this.allowanceReason(ctx), "warning"); return false; }
|
|
115
|
+
try {
|
|
116
|
+
this.cancelTimer();
|
|
117
|
+
this.core.runtime.clearContinuationState();
|
|
118
|
+
this.write(ctx, g => {
|
|
119
|
+
if (g.status === "complete" || budgetReached(g)) throw new Error("A completed or token-budget-limited goal cannot resume.");
|
|
120
|
+
return { ...g, status: "active", autoContinue: true, stopReason: undefined, pauseReason: undefined, pauseSuggestedAction: undefined,
|
|
121
|
+
scheduler: { ...newGoalScheduler(this.owner(ctx)), phase: "ready", decision: { kind: "ready", nextAction: "Continue the goal at the user's request.", purpose: "kickoff" } } };
|
|
122
|
+
});
|
|
123
|
+
// A resume during host work waits for settlement; another model turn
|
|
124
|
+
// still invalidates it through turn(), just like a declaration.
|
|
125
|
+
this.declared = this.inRun;
|
|
126
|
+
this.schedule(ctx);
|
|
127
|
+
return true;
|
|
128
|
+
} catch (error) { ctx.ui.notify(String(error), "warning"); return false; }
|
|
129
|
+
}
|
|
130
|
+
kickoff(ctx: ExtensionContext): void {
|
|
131
|
+
if (this.inRun) return; // creating a goal within a user run needs no extra run
|
|
132
|
+
this.safe(ctx, () => {
|
|
133
|
+
const goal = this.core.state.goal;
|
|
134
|
+
if (!goal || goal.status !== "active" || !goal.autoContinue) return;
|
|
135
|
+
if (this.limit(ctx) === 0) { this.update(ctx, () => {}); ctx.ui.notify(this.allowanceReason(ctx), "info"); return; }
|
|
136
|
+
this.update(ctx, s => { s.phase = "ready"; s.decision = { kind: "ready", nextAction: "Begin the requested goal.", purpose: "kickoff" }; });
|
|
137
|
+
this.schedule(ctx);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
declare(ctx: ExtensionContext, input: GoalContinuation): AgentToolResult<unknown> {
|
|
142
|
+
try {
|
|
143
|
+
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.");
|
|
144
|
+
invalidateGoalSettingsCache(); // an agent may have just configured the allowance
|
|
145
|
+
const goal = this.update(ctx, s => {
|
|
146
|
+
if (!this.available(ctx, s)) throw new Error(this.allowanceReason(ctx));
|
|
147
|
+
if (input.kind === "ready") {
|
|
148
|
+
if (typeof input.next_action !== "string" || !input.next_action.trim() || input.next_action.length > 2000) throw new Error("ready requires a nonempty next_action (at most 2000 characters).");
|
|
149
|
+
s.phase = "ready"; s.decision = { kind: "ready", nextAction: input.next_action.trim(), purpose: "ready" }; s.wait = undefined;
|
|
150
|
+
} else if (input.kind === "wait") {
|
|
151
|
+
if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 2000) throw new Error("wait requires a nonempty reason (at most 2000 characters).");
|
|
152
|
+
const deadline = Date.parse(input.deadline);
|
|
153
|
+
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.");
|
|
154
|
+
if (s.wait) {
|
|
155
|
+
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.");
|
|
156
|
+
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.");
|
|
157
|
+
s.wait.token = randomUUID(); s.wait.signalled = false;
|
|
158
|
+
if (s.wait.intervalMs) s.wait.nextCheckAt = Date.now() + s.wait.intervalMs;
|
|
159
|
+
} else {
|
|
160
|
+
if (input.wait_id) throw new Error("Unknown wait_id.");
|
|
161
|
+
const polling = input.polling;
|
|
162
|
+
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.");
|
|
163
|
+
s.wait = { id: randomUUID(), token: randomUUID(), reason: input.reason.trim(), deadline,
|
|
164
|
+
...(polling ? { intervalMs: polling.interval_seconds * 1000, remainingChecks: polling.max_checks, nextCheckAt: Date.now() + polling.interval_seconds * 1000 } : {}) };
|
|
165
|
+
}
|
|
166
|
+
s.phase = "waiting"; s.decision = { kind: "wait" };
|
|
167
|
+
} else throw new Error("Unknown continuation kind.");
|
|
168
|
+
s.dispatch = undefined;
|
|
169
|
+
s.generation = randomUUID();
|
|
170
|
+
});
|
|
171
|
+
this.declared = true;
|
|
172
|
+
this.core.runtime.markTurnStopped(goal.id);
|
|
173
|
+
const wait = goal.scheduler?.wait;
|
|
174
|
+
return { content: [{ type: "text", text: `Scheduling decision saved. Stop this execution.\n${schedulerSummary(goal.scheduler, this.limit(ctx))}${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 };
|
|
175
|
+
} catch (error) { return { content: [{ type: "text", text: `Scheduling decision NOT saved: ${error instanceof Error ? error.message : String(error)}` }], details: { error: true }, terminate: false }; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
prepare(): void { this.prepared = true; }
|
|
179
|
+
needsLiveContext(): boolean { return this.liveContext; }
|
|
180
|
+
|
|
181
|
+
/** One logical run can contain multiple agent_start events during native retry/compaction. */
|
|
182
|
+
begin(ctx: ExtensionContext): void {
|
|
183
|
+
this.ctx = ctx;
|
|
184
|
+
if (this.inRun) { this.core.runningGoalId = this.core.state.goal?.id ?? null; return; }
|
|
185
|
+
this.liveContext = !this.prepared; this.prepared = false;
|
|
186
|
+
this.inRun = true; this.declared = false; this.denied = false;
|
|
187
|
+
this.runGoalId = this.core.state.goal?.id;
|
|
188
|
+
this.cancelTimer(); this.core.runtime.clearContinuationTimer();
|
|
189
|
+
this.core.runningGoalId = this.core.state.goal?.id ?? null;
|
|
190
|
+
}
|
|
191
|
+
turn(ctx: ExtensionContext): void {
|
|
192
|
+
if (!this.declared) return;
|
|
193
|
+
this.declared = false;
|
|
194
|
+
this.safe(ctx, () => this.update(ctx, s => { s.decision = undefined; s.phase = "idle"; s.dispatch = undefined; }));
|
|
195
|
+
}
|
|
196
|
+
message(ctx: ExtensionContext, rawMessage: unknown): void {
|
|
197
|
+
const msg = asRecord(rawMessage);
|
|
198
|
+
if (!msg || !["user", "custom"].includes(String(msg.role))) return;
|
|
199
|
+
if (msg.customType === "pi-goal-audit-event") return;
|
|
200
|
+
if (msg.customType === "pi-goal-event") {
|
|
201
|
+
const d = asRecord(msg.details);
|
|
202
|
+
if (d?.kind !== "checkpoint") return;
|
|
203
|
+
try {
|
|
204
|
+
this.update(ctx, (s, g) => {
|
|
205
|
+
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.");
|
|
206
|
+
// Retain the admitted action as context; phase prevents reusing its authorization.
|
|
207
|
+
s.phase = "running";
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
this.denied = true;
|
|
211
|
+
this.core.runtime.setCheckpoint(String(d?.goalId ?? "invalid"));
|
|
212
|
+
ctx.abort?.();
|
|
213
|
+
ctx.ui.notify(`Goal checkpoint rejected: ${String(error)}`, "warning");
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Genuine host work supersedes an old scheduling claim, but never resets used.
|
|
218
|
+
this.takeover(ctx);
|
|
219
|
+
}
|
|
220
|
+
takeover(ctx: ExtensionContext): void {
|
|
221
|
+
this.cancelTimer(); this.core.runtime.clearContinuationState();
|
|
222
|
+
this.declared = false;
|
|
223
|
+
const s = this.core.state.goal?.scheduler;
|
|
224
|
+
if (!s || s.owner !== this.owner(ctx)) return;
|
|
225
|
+
this.safe(ctx, () => this.update(ctx, state => {
|
|
226
|
+
state.generation = randomUUID(); state.phase = "idle"; state.decision = undefined; state.dispatch = undefined; state.wait = undefined;
|
|
227
|
+
state.repairUsed = false;
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
settled(ctx: ExtensionContext, successful = true): void {
|
|
231
|
+
this.inRun = false;
|
|
232
|
+
if (this.denied || (this.runGoalId && this.core.state.goal?.id !== this.runGoalId)) return;
|
|
233
|
+
this.safe(ctx, () => {
|
|
234
|
+
const goal = this.core.state.goal;
|
|
235
|
+
if (!goal || goal.status !== "active" || !goal.autoContinue || !successful) return;
|
|
236
|
+
if (this.declared && goal.scheduler?.decision) { this.schedule(ctx); return; }
|
|
237
|
+
const s = this.state(ctx, goal);
|
|
238
|
+
if (s.repairUsed || !this.available(ctx, s)) {
|
|
239
|
+
this.pause(ctx, !this.available(ctx, s) ? this.allowanceReason(ctx) : "No execution disposition after the contract-repair prompt. Use /goal-resume to continue.");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.update(ctx, state => {
|
|
243
|
+
state.phase = "ready"; state.dispatch = undefined;
|
|
244
|
+
state.decision = { kind: "ready", nextAction: "The prior execution did not declare a disposition. Declare ready with a concrete next action, wait with a deadline, or complete/pause/block. This is the only repair prompt.", purpose: "repair" };
|
|
245
|
+
});
|
|
246
|
+
this.schedule(ctx);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
recover(ctx: ExtensionContext): void {
|
|
250
|
+
this.safe(ctx, () => {
|
|
251
|
+
this.update(ctx, s => { s.phase = "ready"; s.dispatch = undefined; s.decision = { kind: "ready", nextAction: "Retry after the provider error, then declare an execution disposition.", purpose: "recovery" }; });
|
|
252
|
+
this.schedule(ctx);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
signal(ctx: ExtensionContext, goalId: unknown, token: unknown): void {
|
|
257
|
+
this.safe(ctx, () => {
|
|
258
|
+
this.core.reconcileFocusedGoalFromDisk(ctx);
|
|
259
|
+
const g = this.core.state.goal; const s = g?.scheduler;
|
|
260
|
+
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;
|
|
261
|
+
this.update(ctx, current => { if (current.wait?.token !== token || current.phase !== "waiting") throw new Error("Wait changed."); current.wait.signalled = true; });
|
|
262
|
+
if (!this.inRun) this.schedule(ctx);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
schedule(ctx: ExtensionContext): void {
|
|
266
|
+
if (this.inRun) return;
|
|
267
|
+
this.safe(ctx, () => {
|
|
268
|
+
this.core.reconcileFocusedGoalFromDisk(ctx);
|
|
269
|
+
const g = this.core.state.goal;
|
|
270
|
+
if (!g || g.status !== "active" || !g.autoContinue || !g.scheduler) return;
|
|
271
|
+
const s = this.state(ctx, g);
|
|
272
|
+
if (!["ready", "waiting"].includes(s.phase)) return;
|
|
273
|
+
if (budgetReached(g)) { this.pause(ctx, "Goal token budget exhausted."); return; }
|
|
274
|
+
if (!this.available(ctx, s)) { this.pause(ctx, this.allowanceReason(ctx)); return; }
|
|
275
|
+
// Recovery and repair retain the wait while changing phase to ready.
|
|
276
|
+
if (s.wait && Date.now() >= s.wait.deadline) { this.pause(ctx, "Wait deadline reached."); return; }
|
|
277
|
+
if (s.phase === "waiting" && s.wait) {
|
|
278
|
+
this.core.clearActiveAccounting();
|
|
279
|
+
if (!s.wait.signalled && s.wait.remainingChecks === 0) { this.pause(ctx, "Wait check allowance exhausted."); return; }
|
|
280
|
+
if (!s.wait.signalled && (s.wait.nextCheckAt === undefined || s.wait.nextCheckAt > Date.now())) {
|
|
281
|
+
const due = Math.min(s.wait.deadline, s.wait.nextCheckAt ?? Infinity);
|
|
282
|
+
this.later(ctx, due - Date.now(), () => this.schedule(ctx)); return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.cancelTimer();
|
|
286
|
+
// Runtime readiness polling cannot authorize: claim() is called again at delivery.
|
|
287
|
+
this.armedGeneration = s.generation;
|
|
288
|
+
this.core.runtime.queueContinuation(ctx, g, true);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
claim(ctx: ExtensionContext): Record<string, unknown> | null {
|
|
292
|
+
const generation = this.armedGeneration;
|
|
293
|
+
if (!generation) return null;
|
|
294
|
+
this.armedGeneration = undefined;
|
|
295
|
+
invalidateGoalSettingsCache();
|
|
296
|
+
try {
|
|
297
|
+
let result: Record<string, unknown> | null = null;
|
|
298
|
+
this.update(ctx, (s, g) => {
|
|
299
|
+
if (s.generation !== generation) throw new Error("Scheduling generation changed.");
|
|
300
|
+
if (g.status !== "active" || !g.autoContinue || budgetReached(g) || !this.available(ctx, s)) throw new Error(this.allowanceReason(ctx));
|
|
301
|
+
if (s.wait && Date.now() >= s.wait.deadline) throw new Error("Wait deadline reached.");
|
|
302
|
+
let kind = s.phase === "ready" && s.decision?.kind === "ready" ? s.decision.purpose : undefined;
|
|
303
|
+
if (s.phase === "waiting" && s.wait) {
|
|
304
|
+
if (s.wait.signalled) kind = "wake";
|
|
305
|
+
else if (s.wait.nextCheckAt !== undefined && s.wait.nextCheckAt <= Date.now() && (s.wait.remainingChecks ?? 0) > 0) { kind = "check"; s.wait.remainingChecks!--; }
|
|
306
|
+
}
|
|
307
|
+
if (!kind) throw new Error("No scheduling decision authorizes this checkpoint.");
|
|
308
|
+
s.used++; s.phase = "claimed"; s.dispatch = { id: randomUUID(), kind, claimedAt: Date.now() };
|
|
309
|
+
if (kind === "repair") s.repairUsed = true;
|
|
310
|
+
else if (kind !== "recovery") s.repairUsed = false;
|
|
311
|
+
if (s.wait) { s.wait.signalled = false; s.wait.token = randomUUID(); }
|
|
312
|
+
result = { generation: s.generation, dispatchId: s.dispatch.id };
|
|
313
|
+
});
|
|
314
|
+
return result;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const current = this.core.state.goal?.scheduler;
|
|
317
|
+
if (current && (current.owner !== this.owner(ctx) || current.generation !== generation)) return null;
|
|
318
|
+
this.safe(ctx, () => this.pause(ctx, `Goal wake stopped: ${error instanceof Error ? error.message : String(error)}`));
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
failedDispatch(ctx: ExtensionContext): void {
|
|
323
|
+
this.safe(ctx, () => this.pause(ctx, "Goal checkpoint delivery failed after claiming allowance. Use /goal-resume; it will not be replayed."));
|
|
324
|
+
}
|
|
325
|
+
isWaiting(): boolean { return !this.inRun && this.core.state.goal?.scheduler?.phase === "waiting"; }
|
|
326
|
+
isDenied(): boolean { return this.denied; }
|
|
327
|
+
}
|
|
@@ -68,7 +68,7 @@ function accumulateCheckpointHealth(health: CheckpointHealth, entry: unknown): v
|
|
|
68
68
|
if (checkpoint.content.length > health.largestCheckpointChars) {
|
|
69
69
|
health.largestCheckpointChars = checkpoint.content.length;
|
|
70
70
|
}
|
|
71
|
-
const isV2 = checkpoint.version === 2 && V2_MARKER_PATTERN.test(checkpoint.content);
|
|
71
|
+
const isV2 = (checkpoint.version === 2 || checkpoint.version === 3) && V2_MARKER_PATTERN.test(checkpoint.content);
|
|
72
72
|
if (isV2) {
|
|
73
73
|
health.v2Minimal += 1;
|
|
74
74
|
} else {
|
|
@@ -102,6 +102,8 @@ export interface GoalSettingsResolvedShape {
|
|
|
102
102
|
auditorProjectResources?: boolean;
|
|
103
103
|
/** F5: stall detector timeout in minutes (0 = off). */
|
|
104
104
|
stallTimeoutMinutes?: number;
|
|
105
|
+
/** Optional extension-run limit per creation/resume; zero disables, absent inherits (default unlimited). */
|
|
106
|
+
maxAutonomousRuns?: number;
|
|
105
107
|
/**
|
|
106
108
|
* Maximum objective length in characters (0/unset = no limit, the
|
|
107
109
|
* default; >0 caps objectives in create_goal, propose_goal_draft, and
|
|
@@ -328,6 +330,7 @@ const ALLOWED_SETTINGS_KEYS = new Set([
|
|
|
328
330
|
"autoSelectSingleGoal",
|
|
329
331
|
"auditorProjectResources",
|
|
330
332
|
"stallTimeoutMinutes",
|
|
333
|
+
"maxAutonomousRuns",
|
|
331
334
|
"objectiveMaxChars",
|
|
332
335
|
"keybindings",
|
|
333
336
|
"hideUnfocusedBanner",
|
|
@@ -397,6 +400,12 @@ export function parseSettingsLayer(
|
|
|
397
400
|
else layer.subtaskDepth = parsed;
|
|
398
401
|
break;
|
|
399
402
|
}
|
|
403
|
+
case "maxAutonomousRuns": {
|
|
404
|
+
const n = typeof value === "number" ? value : typeof value === "string" && /^[0-9]+$/.test(value.trim()) ? Number(value) : NaN;
|
|
405
|
+
if (!Number.isSafeInteger(n) || n < 0) diagnostics.push(diagnostic("invalid_value", "maxAutonomousRuns must be a nonnegative safe integer (0 disables automatic continuation)", key));
|
|
406
|
+
else layer.maxAutonomousRuns = n;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
400
409
|
case "stallTimeoutMinutes":
|
|
401
410
|
case "objectiveMaxChars": {
|
|
402
411
|
const parsed = asNonNegativeInt(value);
|
|
@@ -781,6 +790,11 @@ function resolvedSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv): Settings
|
|
|
781
790
|
globalValue: global.layer.oracle?.maxFailedAttemptsPerBlocker,
|
|
782
791
|
defaultValue: 2,
|
|
783
792
|
}));
|
|
793
|
+
const maxAutonomousRuns = track("maxAutonomousRuns", resolveLeaf<number | undefined>({
|
|
794
|
+
projectValue: project.layer.maxAutonomousRuns,
|
|
795
|
+
globalValue: global.layer.maxAutonomousRuns,
|
|
796
|
+
defaultValue: undefined,
|
|
797
|
+
}));
|
|
784
798
|
const stallTimeoutMinutes = track("stallTimeoutMinutes", resolveLeaf<number>({
|
|
785
799
|
projectValue: project.layer.stallTimeoutMinutes,
|
|
786
800
|
globalValue: global.layer.stallTimeoutMinutes,
|
|
@@ -840,6 +854,7 @@ function resolvedSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv): Settings
|
|
|
840
854
|
auditorProjectResources,
|
|
841
855
|
hideUnfocusedBanner,
|
|
842
856
|
stallTimeoutMinutes,
|
|
857
|
+
maxAutonomousRuns,
|
|
843
858
|
objectiveMaxChars,
|
|
844
859
|
keybindings,
|
|
845
860
|
networkRecovery: {
|
|
@@ -1189,6 +1204,7 @@ function buildPersistedLayer(settings: GoalSettings): Record<string, unknown> {
|
|
|
1189
1204
|
if (so.maxFailedAttemptsPerBlocker !== undefined) o.maxFailedAttemptsPerBlocker = so.maxFailedAttemptsPerBlocker;
|
|
1190
1205
|
if (Object.keys(o).length > 0) persisted.oracle = o;
|
|
1191
1206
|
}
|
|
1207
|
+
if (settings.maxAutonomousRuns !== undefined) persisted.maxAutonomousRuns = settings.maxAutonomousRuns;
|
|
1192
1208
|
if (settings.stallTimeoutMinutes !== undefined) persisted.stallTimeoutMinutes = settings.stallTimeoutMinutes;
|
|
1193
1209
|
if (settings.objectiveMaxChars !== undefined) persisted.objectiveMaxChars = settings.objectiveMaxChars;
|
|
1194
1210
|
if (settings.keybindings?.dashboard) {
|
|
@@ -1232,6 +1248,7 @@ export function effectiveSettingsReport(cwd: string, env: NodeJS.ProcessEnv = pr
|
|
|
1232
1248
|
{ key: "thinkingLevel", label: "thinking_level", format: () => snapshot.value.thinkingLevel ?? "(default)" },
|
|
1233
1249
|
{ key: "auditorProjectResources", label: "auditor project resources", format: () => String(snapshot.value.auditorProjectResources) },
|
|
1234
1250
|
{ key: "hideUnfocusedBanner", label: "hide unfocused banner", format: () => String(snapshot.value.hideUnfocusedBanner) },
|
|
1251
|
+
{ key: "maxAutonomousRuns", label: "autonomous run allowance", format: () => snapshot.value.maxAutonomousRuns === 0 ? "0 (disabled)" : String(snapshot.value.maxAutonomousRuns ?? "unlimited (default)") },
|
|
1235
1252
|
{ key: "stallTimeoutMinutes", label: "stall timeout (minutes)", format: () => String(snapshot.value.stallTimeoutMinutes) },
|
|
1236
1253
|
{ key: "objectiveMaxChars", label: "max objective length (0 = none)", format: () => String(snapshot.value.objectiveMaxChars) },
|
|
1237
1254
|
{ key: "networkRecovery", label: "network recovery attempts (0 = unbounded)", format: () => String(snapshot.value.networkRecovery?.maxAttempts ?? 0) },
|
package/extensions/goal-state.ts
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
import { GoalService } from "./goal-service.ts";
|
|
32
32
|
import { goalActivityEvents } from "./goal-ledger.ts";
|
|
33
33
|
import { GoalAccounting } from "./goal-accounting.ts";
|
|
34
|
+
import { newGoalScheduler } from "./goal-scheduler-state.ts";
|
|
35
|
+
import { GoalScheduler } from "./goal-scheduler.ts";
|
|
34
36
|
import { GoalRuntime } from "./goal-runtime.ts";
|
|
35
37
|
import { GoalAuditMessages } from "./goal-session-safety.ts";
|
|
36
38
|
import {
|
|
@@ -79,6 +81,7 @@ export interface GoalCore {
|
|
|
79
81
|
goalWidgetComponentRef: { current: GoalWidgetComponent | null };
|
|
80
82
|
goalService: GoalService;
|
|
81
83
|
runtime: GoalRuntime;
|
|
84
|
+
scheduler: GoalScheduler;
|
|
82
85
|
auditMessages: GoalAuditMessages;
|
|
83
86
|
accounting: GoalAccounting;
|
|
84
87
|
|
|
@@ -262,7 +265,11 @@ export function createGoalCore(
|
|
|
262
265
|
// token/time accounting lives in `accounting` (extensions/goal-accounting.ts).
|
|
263
266
|
let goalWorkToolCalledThisTurn = false;
|
|
264
267
|
|
|
268
|
+
let scheduler: GoalScheduler;
|
|
265
269
|
const runtime = new GoalRuntime({
|
|
270
|
+
authorize: ctx => scheduler.claim(ctx),
|
|
271
|
+
recover: ctx => scheduler.recover(ctx),
|
|
272
|
+
dispatchFailed: ctx => scheduler.failedDispatch(ctx),
|
|
266
273
|
sendFollowUp: (content, details) => {
|
|
267
274
|
pi.sendMessage<GoalEventDetails>(
|
|
268
275
|
{
|
|
@@ -365,6 +372,7 @@ export function createGoalCore(
|
|
|
365
372
|
}
|
|
366
373
|
|
|
367
374
|
function clearContinuationState(resetNetworkErrorBackoff = true): void {
|
|
375
|
+
scheduler?.cancelTimer();
|
|
368
376
|
runtime.clearContinuationState(resetNetworkErrorBackoff);
|
|
369
377
|
}
|
|
370
378
|
|
|
@@ -413,6 +421,7 @@ export function createGoalCore(
|
|
|
413
421
|
opts: { recordLedger?: boolean } = {},
|
|
414
422
|
): void {
|
|
415
423
|
const previousGoalId = focusedGoalId;
|
|
424
|
+
if (previousGoalId !== goalId) scheduler.takeover(ctx);
|
|
416
425
|
if (previousGoalId !== goalId) goalService.flushTurn(ctx); // P1-3: persist the old buffer before switching focus
|
|
417
426
|
assignFocusedGoalId(goalId && goalsById.has(goalId) ? goalId : null);
|
|
418
427
|
if (previousGoalId !== focusedGoalId) {
|
|
@@ -455,6 +464,7 @@ export function createGoalCore(
|
|
|
455
464
|
}
|
|
456
465
|
|
|
457
466
|
function beginAccounting(): void {
|
|
467
|
+
if (scheduler?.isWaiting()) { clearActiveAccounting(); return; }
|
|
458
468
|
if (!state.goal || (state.goal.status !== "active")) {
|
|
459
469
|
clearActiveAccounting();
|
|
460
470
|
return;
|
|
@@ -862,8 +872,8 @@ export function createGoalCore(
|
|
|
862
872
|
}
|
|
863
873
|
|
|
864
874
|
function queueContinuation(ctx: ExtensionContext, force = false): void {
|
|
865
|
-
|
|
866
|
-
|
|
875
|
+
void force;
|
|
876
|
+
scheduler.schedule(ctx);
|
|
867
877
|
}
|
|
868
878
|
|
|
869
879
|
function enterGoalModal(): void {
|
|
@@ -876,6 +886,7 @@ export function createGoalCore(
|
|
|
876
886
|
|
|
877
887
|
function replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow = true, verificationContract?: string, tokenBudget?: number): void {
|
|
878
888
|
const goal = createGoal(config);
|
|
889
|
+
goal.scheduler = newGoalScheduler(ctx.sessionManager.getSessionId());
|
|
879
890
|
if (verificationContract) goal.verificationContract = verificationContract;
|
|
880
891
|
if (config.taskList) goal.taskList = config.taskList;
|
|
881
892
|
if (typeof tokenBudget === "number" && tokenBudget > 0) goal.tokenBudget = Math.floor(tokenBudget);
|
|
@@ -902,10 +913,10 @@ export function createGoalCore(
|
|
|
902
913
|
if (result.focusChanged) appendFocusEntry(result.goalId, "created");
|
|
903
914
|
beginAccounting();
|
|
904
915
|
ctx.ui.notify(buildGoalRunningNotification(config), "info");
|
|
905
|
-
if (startNow && state.goal?.autoContinue)
|
|
916
|
+
if (startNow && state.goal?.autoContinue) scheduler.kickoff(ctx);
|
|
906
917
|
}
|
|
907
918
|
|
|
908
|
-
|
|
919
|
+
const core: GoalCore = {
|
|
909
920
|
pi,
|
|
910
921
|
dependencies,
|
|
911
922
|
state,
|
|
@@ -1002,6 +1013,7 @@ export function createGoalCore(
|
|
|
1002
1013
|
goalWidgetComponentRef,
|
|
1003
1014
|
goalService,
|
|
1004
1015
|
runtime,
|
|
1016
|
+
get scheduler() { return scheduler; },
|
|
1005
1017
|
auditMessages: new GoalAuditMessages(),
|
|
1006
1018
|
accounting,
|
|
1007
1019
|
assignFocusedGoalId,
|
|
@@ -1049,4 +1061,6 @@ export function createGoalCore(
|
|
|
1049
1061
|
checkStall,
|
|
1050
1062
|
replaceGoal,
|
|
1051
1063
|
};
|
|
1064
|
+
scheduler = new GoalScheduler(core);
|
|
1065
|
+
return core;
|
|
1052
1066
|
}
|
|
@@ -31,6 +31,7 @@ export const PLAIN_THEME = {
|
|
|
31
31
|
} as never;
|
|
32
32
|
|
|
33
33
|
export interface GoalStatusTextOptions {
|
|
34
|
+
maxAutonomousRuns?: number;
|
|
34
35
|
goal: GoalRecord | null;
|
|
35
36
|
focused: boolean;
|
|
36
37
|
otherOpenGoals: number;
|
|
@@ -72,6 +73,7 @@ export function buildGoalStatusText(options: GoalStatusTextOptions): string {
|
|
|
72
73
|
const events = options.ledgerEvents ?? [];
|
|
73
74
|
const model = deriveGoalDashboardModel(options.goal, {
|
|
74
75
|
focused: options.focused,
|
|
76
|
+
maxAutonomousRuns: options.maxAutonomousRuns,
|
|
75
77
|
otherOpenGoals: options.otherOpenGoals,
|
|
76
78
|
ledgerEvents: events,
|
|
77
79
|
});
|
|
@@ -218,6 +220,7 @@ function visibleLen(value: string): number {
|
|
|
218
220
|
function buildVerboseStatus(goal: GoalRecord, model: ReturnType<typeof deriveGoalDashboardModel>, events: GoalLedgerEvent[], settingsReport: string[], _width: number): string {
|
|
219
221
|
const lines: string[] = [];
|
|
220
222
|
lines.push(`Goal id: ${goal.id}`);
|
|
223
|
+
lines.push(...(model?.scheduling ?? []));
|
|
221
224
|
lines.push(`Revision: ${goal.revision ?? 0}`);
|
|
222
225
|
lines.push(`Status: ${model?.status.label}${model?.focused ? " (focused)" : " (not focused)"}`);
|
|
223
226
|
lines.push(`Mode: ${goal.sisyphus ? "sisyphus" : "goal"} · auto-continue: ${goal.autoContinue ? "on" : "off"}`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { schedulerSummary } from "../goal-scheduler-state.ts";
|
|
1
2
|
import { taskIndex } from "../goal-task-index.ts";
|
|
2
3
|
import { statusLabel, truncateText } from "../goal-core.ts";
|
|
3
4
|
import { promptSafeObjective } from "../goal-contract.ts";
|
|
@@ -182,9 +183,11 @@ export function sisyphusDisciplineBlock(goal: GoalRecord): string {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
/** Shared outcome/blocker policy for active goals (bounded). */
|
|
185
|
-
function lifecyclePolicyBlock(): string {
|
|
186
|
+
function lifecyclePolicyBlock(autonomous: boolean): string {
|
|
186
187
|
return [
|
|
187
188
|
"[OUTCOMES]",
|
|
189
|
+
'- Automatic runs default to unlimited. maxAutonomousRuns in .pi/pi-goal-x-settings.json caps runs; 0 disables (agents may set it). Only creation or user /goal-resume renews usage.',
|
|
190
|
+
...(autonomous ? ['- End execution with update_goal: ready for runnable work, wait for an external condition, or a status below. Saved decisions terminate; further work invalidates them. Missing decisions allow one repair. Never busy-poll.'] : []),
|
|
188
191
|
'- update_goal({status: "complete"}) only when every requirement is satisfied; the independent completion auditor checks actual evidence. Approval archives; rejection requires rework.',
|
|
189
192
|
'- update_goal({status: "blocked"}) only after the SAME blocker recurs on three consecutive goal turns; keep trying concrete steps before then.',
|
|
190
193
|
'- update_goal({status: "paused", reason: "…"}) pauses immediately. User controls: /goal-pause, /goal-resume, /goal-clear.',
|
|
@@ -204,7 +207,7 @@ let promptCacheChars = 0;
|
|
|
204
207
|
function cachedPrompt(goal: GoalRecord, settings: GoalSettings | undefined, kind: "goal" | "continuation", build: () => string): string {
|
|
205
208
|
const key = [kind, goal.id, goal.status, goal.autoContinue, goal.sisyphus, goal.objective,
|
|
206
209
|
goal.verificationContract, settings?.disableTasks ? undefined : taskIndex(goal.taskList?.tasks),
|
|
207
|
-
goal.taskList?.blockCompletion, promptProfile(), goal.currentTaskId, settings?.disableTasks, settings?.disableContracts];
|
|
210
|
+
goal.taskList?.blockCompletion, promptProfile(), goal.currentTaskId, settings?.disableTasks, settings?.disableContracts, settings?.maxAutonomousRuns !== 0];
|
|
208
211
|
for (let i = promptFragmentCache.length - 1; i >= 0; i--) {
|
|
209
212
|
const entry = promptFragmentCache[i]!;
|
|
210
213
|
if (key.every((part, j) => part === entry.key[j])) return entry.value;
|
|
@@ -227,7 +230,7 @@ function retainedTaskChars(task: GoalTask): number {
|
|
|
227
230
|
export function goalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
|
|
228
231
|
const fixed = cachedPrompt(goal, settings, "goal", () => buildGoalPrompt(goal, settings));
|
|
229
232
|
const budget = budgetLine(goal);
|
|
230
|
-
return `${fixed}\nUsage: ${formatUsage(goal)}${budget ? `\n${budget}` : ""}`;
|
|
233
|
+
return `${fixed}\nUsage: ${formatUsage(goal)}${budget ? `\n${budget}` : ""}\n${schedulerSummary(goal.scheduler, settings?.maxAutonomousRuns)}`;
|
|
231
234
|
}
|
|
232
235
|
|
|
233
236
|
function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
|
|
@@ -235,7 +238,7 @@ function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
|
|
|
235
238
|
// Bound individual data fields so essential rules can never be sliced off.
|
|
236
239
|
return [
|
|
237
240
|
`[PI GOAL ACTIVE goalId=${goal.id}]`,
|
|
238
|
-
lifecyclePolicyBlock(), sisyphusDisciplineBlock(goal),
|
|
241
|
+
lifecyclePolicyBlock(settings?.maxAutonomousRuns !== 0), sisyphusDisciplineBlock(goal),
|
|
239
242
|
`Status: ${statusLabel(goal)}\nMode: ${goal.sisyphus ? "sisyphus" : "regular"}`,
|
|
240
243
|
untrustedObjectiveBlock(goal), taskListBlock(goal, settings), verificationContractBlock(goal, settings),
|
|
241
244
|
].filter(Boolean).join("\n\n");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { schedulerSummary } from "../goal-scheduler-state.ts";
|
|
1
2
|
import { taskIndex } from "../goal-task-index.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Shared dashboard view model (plan §6) — the single source of truth for the
|
|
@@ -22,6 +23,7 @@ import { deriveGoalActivity, type GoalActivityItem } from "../goal-activity.ts";
|
|
|
22
23
|
export type DashboardStatusCode = "running" | "idle" | "paused" | "blocked" | "budget_limited" | "complete";
|
|
23
24
|
|
|
24
25
|
export interface GoalDashboardModel {
|
|
26
|
+
scheduling: string[];
|
|
25
27
|
goalId: string;
|
|
26
28
|
title: string;
|
|
27
29
|
|
|
@@ -100,6 +102,7 @@ export interface DashboardTaskNode {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
export interface GoalDashboardModelOptions {
|
|
105
|
+
maxAutonomousRuns?: number;
|
|
103
106
|
focused: boolean;
|
|
104
107
|
otherOpenGoals: number;
|
|
105
108
|
ledgerEvents?: readonly GoalLedgerEvent[];
|
|
@@ -470,6 +473,7 @@ export function deriveGoalDashboardModel(
|
|
|
470
473
|
const { focused, otherOpenGoals, ledgerEvents = [], activityLimit, tasksDisabled = false } = options;
|
|
471
474
|
|
|
472
475
|
const status = deriveGoalStatus(goal);
|
|
476
|
+
if (goal.status === "active" && goal.scheduler?.phase === "waiting") { status.code = "idle"; status.label = "Waiting"; status.footerLabel = "waiting"; }
|
|
473
477
|
// §9.5: with tasks disabled, omit task sections entirely (status,
|
|
474
478
|
// verification, usage, path, and focus remain).
|
|
475
479
|
const {taskProgress, taskTree, currentTask, taskTitles} = taskPresentation(goal, tasksDisabled);
|
|
@@ -494,6 +498,7 @@ export function deriveGoalDashboardModel(
|
|
|
494
498
|
if (goal.usage.tokensUsed > 0) footerUsageBits.push(formatCompactTokens(goal.usage.tokensUsed));
|
|
495
499
|
|
|
496
500
|
return {
|
|
501
|
+
scheduling: schedulerSummary(goal.scheduler, options.maxAutonomousRuns).split("\n"),
|
|
497
502
|
goalId: goal.id,
|
|
498
503
|
title: displayObjectiveTitle(goal.objective),
|
|
499
504
|
status,
|
|
@@ -310,6 +310,8 @@ export function renderCompactDashboard(
|
|
|
310
310
|
lines.push(boxLine(theme, safeWidth, statusLine(theme, model)));
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
for (const row of model.scheduling ?? []) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
|
|
314
|
+
|
|
313
315
|
// Budget (when configured): fuel gauge + amount, amber until the budget is
|
|
314
316
|
// exhausted, then soft red.
|
|
315
317
|
if (model.budget) {
|
|
@@ -455,6 +457,7 @@ export function renderExpandedDashboard(
|
|
|
455
457
|
lines.push(boxHeader(theme, safeWidth, `${accent(theme, "pi-goal-x")} ${frame(theme, `─ ${model.title}`)}`));
|
|
456
458
|
|
|
457
459
|
lines.push(boxLine(theme, safeWidth, statusLine(theme, model)));
|
|
460
|
+
for (const row of model.scheduling ?? []) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
|
|
458
461
|
|
|
459
462
|
if (spec.showPath && model.filePath) {
|
|
460
463
|
lines.push(boxLine(theme, safeWidth, dim(theme, `File: ${model.filePath}`)));
|
|
@@ -548,6 +548,7 @@ export class GoalWidgetComponent implements Component {
|
|
|
548
548
|
otherOpenGoals: otherCount,
|
|
549
549
|
ledgerEvents: this.getLedgerEvents(),
|
|
550
550
|
tasksDisabled: settings.disableTasks === true,
|
|
551
|
+
maxAutonomousRuns: settings.maxAutonomousRuns,
|
|
551
552
|
}) : null;
|
|
552
553
|
this.maybeReanchor(model);
|
|
553
554
|
const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
|
|
@@ -629,6 +630,7 @@ export class GoalWidgetComponent implements Component {
|
|
|
629
630
|
otherOpenGoals: Math.max(0, this.getOpenGoalCount() - 1),
|
|
630
631
|
ledgerEvents: this.getLedgerEvents(),
|
|
631
632
|
tasksDisabled: settings.disableTasks === true,
|
|
633
|
+
maxAutonomousRuns: settings.maxAutonomousRuns,
|
|
632
634
|
}) : null;
|
|
633
635
|
const list = model?.taskTree.filter((n) => n.depth === 0) ?? [];
|
|
634
636
|
const rows = compactTaskViewportRows(this.lastRenderWidth);
|
|
@@ -663,6 +665,7 @@ export class GoalWidgetComponent implements Component {
|
|
|
663
665
|
otherOpenGoals: Math.max(0, this.getOpenGoalCount() - 1),
|
|
664
666
|
ledgerEvents: this.getLedgerEvents(),
|
|
665
667
|
tasksDisabled: settings.disableTasks === true,
|
|
668
|
+
maxAutonomousRuns: settings.maxAutonomousRuns,
|
|
666
669
|
}) : null;
|
|
667
670
|
const list = model?.taskTree ?? [];
|
|
668
671
|
if (list.length === 0) return false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-x",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.4",
|
|
4
4
|
"description": "Adds /goal to pi: conversational goal planning, flexible or ordered goals, persistent progress, and an independent completion auditor.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "pi-goal-x contributors",
|