@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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoalRuntime — continuation scheduling, stale-checkpoint state, the turn-stop
|
|
3
|
+
* guard, and one-time steering reminders (post-compaction, budget reached).
|
|
4
|
+
*
|
|
5
|
+
* The extension (`extensions/goal.ts`) instantiates one GoalRuntime with hooks
|
|
6
|
+
* bound to its closure state and the pi API; every runtime decision is
|
|
7
|
+
* encapsulated here so the scheduling/guarding behavior is independently
|
|
8
|
+
* testable with a mock context.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { GoalCheckpointDetailsV3, GoalRecord } from "./goal-record.ts";
|
|
13
|
+
import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
|
|
14
|
+
import { POST_STOP_ALLOWED_TOOLS } from "./goal-tool-names.ts";
|
|
15
|
+
import { networkErrorBackoffPlan, type NetworkErrorBackoffPlan, type NetworkErrorRecoveryPolicy } from "./network-error-backoff.ts";
|
|
16
|
+
|
|
17
|
+
export const CONTINUATION_IDLE_RETRY_MS = 50;
|
|
18
|
+
|
|
19
|
+
const POST_STOP_ALLOWED = new Set<string>(POST_STOP_ALLOWED_TOOLS);
|
|
20
|
+
|
|
21
|
+
export interface GoalRuntimeHooks {
|
|
22
|
+
authorize?(ctx: ExtensionContext, goal: GoalRecord): Record<string, unknown> | null;
|
|
23
|
+
recover?(ctx: ExtensionContext): void;
|
|
24
|
+
dispatchFailed?(ctx: ExtensionContext): void;
|
|
25
|
+
/** Dispatch a hidden follow-up checkpoint message (pi.sendMessage + triggerTurn). */
|
|
26
|
+
sendFollowUp(content: string, details: Record<string, unknown>): void;
|
|
27
|
+
/** Current focused goal (state.goal). */
|
|
28
|
+
getGoal(): GoalRecord | null;
|
|
29
|
+
/** Whether a checkpointed goal id is still actionable (active + autoContinue). */
|
|
30
|
+
isActionable(goalId: string | null | undefined): boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class GoalRuntime {
|
|
34
|
+
// ── continuation scheduling ──────────────────────────────────────────
|
|
35
|
+
private continuationQueuedFor: string | null = null;
|
|
36
|
+
private continuationScheduledFor: string | null = null;
|
|
37
|
+
private continuationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
private networkErrorRetryGoalId: string | null = null;
|
|
39
|
+
private networkErrorRetryAttempt = 0;
|
|
40
|
+
private networkErrorRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
|
|
42
|
+
// ── turn-stop guard ──────────────────────────────────────────────────
|
|
43
|
+
private turnSeq = 0;
|
|
44
|
+
private turnStoppedFor: { goalId: string; turnSeq: number } | null = null;
|
|
45
|
+
private draftResolvedTurnSeq: number | null = null;
|
|
46
|
+
|
|
47
|
+
// ── stale checkpoint state ───────────────────────────────────────────
|
|
48
|
+
private checkpointGoalId: string | null = null;
|
|
49
|
+
|
|
50
|
+
/** Monotonic per-session counter persisted on v2 checkpoint details (issue #30). */
|
|
51
|
+
private checkpointSeq = 0;
|
|
52
|
+
|
|
53
|
+
// ── one-time steering reminders ──────────────────────────────────────
|
|
54
|
+
private postCompactReminderPending = false;
|
|
55
|
+
private postBudgetReminderPending = false;
|
|
56
|
+
|
|
57
|
+
private readonly hooks: GoalRuntimeHooks;
|
|
58
|
+
|
|
59
|
+
constructor(hooks: GoalRuntimeHooks) {
|
|
60
|
+
this.hooks = hooks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── continuation scheduling ──────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
clearContinuationState(resetNetworkErrorBackoff = true): void {
|
|
66
|
+
this.clearContinuationTimer();
|
|
67
|
+
this.continuationQueuedFor = null;
|
|
68
|
+
if (resetNetworkErrorBackoff) this.clearNetworkErrorBackoff();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clear the pending timer but keep the queued marker (used at session shutdown). */
|
|
72
|
+
clearContinuationTimer(): void {
|
|
73
|
+
if (this.continuationTimer) {
|
|
74
|
+
clearTimeout(this.continuationTimer);
|
|
75
|
+
this.continuationTimer = null;
|
|
76
|
+
}
|
|
77
|
+
this.continuationScheduledFor = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a continuation is queued or scheduled for this goal id. */
|
|
81
|
+
continuationPendingFor(goalId: string): boolean {
|
|
82
|
+
return this.continuationQueuedFor === goalId || this.continuationScheduledFor === goalId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Schedule the next auto-continuation for the focused active goal.
|
|
87
|
+
* Only `active` + autoContinue goals can queue. `force` bypasses the
|
|
88
|
+
* already-queued/scheduled dedup (used right after creation/resume).
|
|
89
|
+
*/
|
|
90
|
+
queueContinuation(ctx: ExtensionContext, goal: GoalRecord, force = false): void {
|
|
91
|
+
if (goal.status !== "active" || !goal.autoContinue) return;
|
|
92
|
+
const goalId = goal.id;
|
|
93
|
+
if (!force && this.continuationPendingFor(goalId)) return;
|
|
94
|
+
this.clearContinuationTimer();
|
|
95
|
+
let delay = CONTINUATION_IDLE_RETRY_MS;
|
|
96
|
+
try {
|
|
97
|
+
delay = ctx.isIdle() && !ctx.hasPendingMessages() ? 0 : CONTINUATION_IDLE_RETRY_MS;
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.continuationScheduledFor = goalId;
|
|
102
|
+
this.continuationTimer = setTimeout(() => this.sendQueuedContinuation(ctx, goalId), delay);
|
|
103
|
+
this.continuationTimer.unref?.();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Cancel a pending continuation for a goal id (e.g. after update/clear/focus change). */
|
|
107
|
+
cancelContinuationFor(goalId: string): void {
|
|
108
|
+
if (this.continuationQueuedFor === goalId) this.continuationQueuedFor = null;
|
|
109
|
+
if (this.continuationScheduledFor === goalId) this.clearContinuationState();
|
|
110
|
+
if (this.networkErrorRetryGoalId === goalId) this.clearNetworkErrorBackoff();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Schedule the next bounded recovery after Pi's built-in provider retries
|
|
115
|
+
* have failed. The counter stays in memory and is cleared on a successful
|
|
116
|
+
* turn or any user-owned cancellation path.
|
|
117
|
+
*/
|
|
118
|
+
scheduleNetworkErrorRetry(ctx: ExtensionContext, goal: GoalRecord, policy?: NetworkErrorRecoveryPolicy): NetworkErrorBackoffPlan | null {
|
|
119
|
+
if (goal.status !== "active" || !goal.autoContinue || this.networkErrorRetryTimer) return null;
|
|
120
|
+
if (this.networkErrorRetryGoalId !== goal.id) {
|
|
121
|
+
this.networkErrorRetryGoalId = goal.id;
|
|
122
|
+
this.networkErrorRetryAttempt = 0;
|
|
123
|
+
}
|
|
124
|
+
const plan = networkErrorBackoffPlan(this.networkErrorRetryAttempt + 1, policy);
|
|
125
|
+
if (!plan) return null;
|
|
126
|
+
this.networkErrorRetryAttempt = plan.attempt;
|
|
127
|
+
this.networkErrorRetryTimer = setTimeout(() => {
|
|
128
|
+
this.networkErrorRetryTimer = null;
|
|
129
|
+
if (!this.hooks.isActionable(goal.id)) return;
|
|
130
|
+
const currentGoal = this.hooks.getGoal();
|
|
131
|
+
if (!currentGoal || currentGoal.id !== goal.id) return;
|
|
132
|
+
this.hooks.recover?.(ctx);
|
|
133
|
+
}, plan.delayMs);
|
|
134
|
+
this.networkErrorRetryTimer.unref?.();
|
|
135
|
+
return plan;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Cancel and forget all goal-level network-error recovery state. */
|
|
139
|
+
clearNetworkErrorBackoff(): void {
|
|
140
|
+
if (this.networkErrorRetryTimer) clearTimeout(this.networkErrorRetryTimer);
|
|
141
|
+
this.networkErrorRetryTimer = null;
|
|
142
|
+
this.networkErrorRetryGoalId = null;
|
|
143
|
+
this.networkErrorRetryAttempt = 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Issue #30: the delivered follow-up must trigger the turn, but it no longer
|
|
148
|
+
* carries goal state. The persisted content is a tiny v2 marker and the
|
|
149
|
+
* details are a bounded structured record; before_agent_start injects the
|
|
150
|
+
* authoritative full prompt once per turn.
|
|
151
|
+
*/
|
|
152
|
+
private sendQueuedContinuation(ctx: ExtensionContext, scheduledGoalId: string): void {
|
|
153
|
+
this.continuationTimer = null;
|
|
154
|
+
this.continuationScheduledFor = null;
|
|
155
|
+
if (!this.hooks.isActionable(scheduledGoalId)) {
|
|
156
|
+
if (this.continuationQueuedFor === scheduledGoalId) this.continuationQueuedFor = null;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let ready: boolean;
|
|
161
|
+
try {
|
|
162
|
+
ready = !ctx.hasPendingMessages() && ctx.isIdle();
|
|
163
|
+
} catch {
|
|
164
|
+
if (this.continuationQueuedFor === scheduledGoalId) this.continuationQueuedFor = null;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!ready) {
|
|
169
|
+
this.continuationScheduledFor = scheduledGoalId;
|
|
170
|
+
this.continuationTimer = setTimeout(() => this.sendQueuedContinuation(ctx, scheduledGoalId), CONTINUATION_IDLE_RETRY_MS);
|
|
171
|
+
this.continuationTimer.unref?.();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const goal = this.hooks.getGoal();
|
|
175
|
+
if (!goal || goal.id !== scheduledGoalId) {
|
|
176
|
+
if (this.continuationQueuedFor === scheduledGoalId) this.continuationQueuedFor = null;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const authorization = this.hooks.authorize?.(ctx, goal);
|
|
180
|
+
if (!authorization) return;
|
|
181
|
+
this.checkpointSeq += 1;
|
|
182
|
+
this.continuationQueuedFor = goal.id;
|
|
183
|
+
const details: GoalCheckpointDetailsV3 = {
|
|
184
|
+
version: 3,
|
|
185
|
+
generation: String(authorization.generation),
|
|
186
|
+
dispatchId: String(authorization.dispatchId),
|
|
187
|
+
kind: "checkpoint",
|
|
188
|
+
goalId: goal.id,
|
|
189
|
+
status: "active",
|
|
190
|
+
revision: goal.revision ?? 0,
|
|
191
|
+
checkpointSeq: this.checkpointSeq,
|
|
192
|
+
timestamp: Date.now(),
|
|
193
|
+
};
|
|
194
|
+
try { this.hooks.sendFollowUp(checkpointTriggerPrompt(goal.id), details as unknown as Record<string, unknown>); }
|
|
195
|
+
catch { this.hooks.dispatchFailed?.(ctx); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── turn-stop guard ──────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
advanceTurn(): void {
|
|
201
|
+
this.turnSeq += 1;
|
|
202
|
+
if (this.turnStoppedFor?.turnSeq !== this.turnSeq) this.turnStoppedFor = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Mark the current turn stopped after a terminal/mutating goal tool. */
|
|
206
|
+
markTurnStopped(goalId: string): void {
|
|
207
|
+
this.turnStoppedFor = { goalId, turnSeq: this.turnSeq };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Goal id that stopped the current turn, or null. Stale markers are dropped. */
|
|
211
|
+
currentTurnStoppedGoalId(): string | null {
|
|
212
|
+
if (!this.turnStoppedFor) return null;
|
|
213
|
+
if (this.turnStoppedFor.turnSeq !== this.turnSeq) {
|
|
214
|
+
this.turnStoppedFor = null;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return this.turnStoppedFor.goalId;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Mark that a guided draft was confirmed or cancelled in the current turn. */
|
|
221
|
+
markDraftResolved(): void {
|
|
222
|
+
this.draftResolvedTurnSeq = this.turnSeq;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether a guided draft was confirmed or cancelled earlier in the current turn. */
|
|
226
|
+
isDraftResolvedThisTurn(): boolean {
|
|
227
|
+
return this.draftResolvedTurnSeq === this.turnSeq;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── stale checkpoint state ───────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
setCheckpoint(goalId: string | null): void {
|
|
233
|
+
this.checkpointGoalId = goalId;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getCheckpointGoalId(): string | null {
|
|
237
|
+
return this.checkpointGoalId;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Tools blocked when a stale checkpoint triggered the current turn. */
|
|
241
|
+
isStaleCheckpointBlocked(toolName: string): boolean {
|
|
242
|
+
return !POST_STOP_ALLOWED.has(toolName);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── one-time steering reminders ──────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
armPostCompactReminder(): void {
|
|
248
|
+
this.postCompactReminderPending = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Whether a post-compaction reminder is pending (read-only). */
|
|
252
|
+
isPostCompactReminderPending(): boolean {
|
|
253
|
+
return this.postCompactReminderPending;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
clearPostCompactReminder(): void {
|
|
257
|
+
this.postCompactReminderPending = false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True once if a post-compaction reminder is pending; clears it. */
|
|
261
|
+
consumePostCompactReminder(): boolean {
|
|
262
|
+
if (!this.postCompactReminderPending) return false;
|
|
263
|
+
this.postCompactReminderPending = false;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
armPostBudgetReminder(): void {
|
|
268
|
+
this.postBudgetReminderPending = true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** True once if a post-budget-limit reminder is pending; clears it. */
|
|
272
|
+
consumePostBudgetReminder(): boolean {
|
|
273
|
+
if (!this.postBudgetReminderPending) return false;
|
|
274
|
+
this.postBudgetReminderPending = false;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { commandHint, commandInstructions, type GoalCommands } from "./goal-controls.ts";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export type GoalDispatchKind = "ready" | "check" | "wake" | "repair" | "kickoff" | "recovery";
|
|
5
|
+
export interface GoalWait {
|
|
6
|
+
id: string;
|
|
7
|
+
token: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
deadline: number;
|
|
10
|
+
intervalMs?: number;
|
|
11
|
+
remainingChecks?: number;
|
|
12
|
+
nextCheckAt?: number;
|
|
13
|
+
signalled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface GoalSchedulerState {
|
|
16
|
+
version: 1;
|
|
17
|
+
owner: string;
|
|
18
|
+
generation: string;
|
|
19
|
+
used: number;
|
|
20
|
+
phase: "idle" | "ready" | "waiting" | "claimed" | "running" | "interrupted";
|
|
21
|
+
decision?: { kind: "ready"; purpose: GoalDispatchKind } | { kind: "wait" };
|
|
22
|
+
wait?: GoalWait;
|
|
23
|
+
dispatch?: { id: string; kind: GoalDispatchKind; claimedAt: number };
|
|
24
|
+
repairUsed: boolean;
|
|
25
|
+
/** Optional idle pacing, effective only with idleContinuationBackoff=true. */
|
|
26
|
+
idle?: { runs: number; nextAt: number };
|
|
27
|
+
}
|
|
28
|
+
export type GoalContinuation =
|
|
29
|
+
| { kind: "ready" }
|
|
30
|
+
| { kind: "wait"; reason: string; deadline: string; wait_id?: string; polling?: { interval_seconds: number; max_checks: number } };
|
|
31
|
+
|
|
32
|
+
export function newGoalScheduler(owner: string): GoalSchedulerState {
|
|
33
|
+
return { version: 1, owner, generation: randomUUID(), used: 0, phase: "idle", repairUsed: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Corrupt scheduling data must not silently reset spent allowance. */
|
|
37
|
+
export function normalizeGoalScheduler(raw: unknown): GoalSchedulerState | undefined {
|
|
38
|
+
if (raw === undefined) return undefined;
|
|
39
|
+
const invalid = (): GoalSchedulerState => ({ ...newGoalScheduler("invalid"), phase: "interrupted", used: Number.MAX_SAFE_INTEGER });
|
|
40
|
+
if (!raw || typeof raw !== "object") return invalid();
|
|
41
|
+
const s = raw as GoalSchedulerState;
|
|
42
|
+
const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
43
|
+
const text = (v: unknown) => typeof v === "string" && v.length > 0 && v.length <= 2000;
|
|
44
|
+
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();
|
|
45
|
+
if (s.idle !== undefined && (!s.idle || typeof s.idle !== "object" || !integer(s.idle.runs) || s.idle.runs < 1 || s.idle.runs > 6 || !integer(s.idle.nextAt) || s.idle.nextAt > 8_640_000_000_000_000)) return invalid();
|
|
46
|
+
// Goals saved before next actions were removed still carry decision.nextAction.
|
|
47
|
+
// The field is stripped after validation and never makes a goal invalid:
|
|
48
|
+
// rejecting one would strand it as interrupted with spent allowance.
|
|
49
|
+
if (s.decision && (s.decision.kind !== "wait" && (s.decision.kind !== "ready" || !["ready", "repair", "kickoff", "recovery"].includes(s.decision.purpose)))) return invalid();
|
|
50
|
+
if (s.wait) {
|
|
51
|
+
const w = s.wait;
|
|
52
|
+
if (!text(w.id) || !text(w.token) || !text(w.reason) || (!integer(w.deadline) || w.deadline > 8_640_000_000_000_000)) return invalid();
|
|
53
|
+
if (w.intervalMs !== undefined && (!integer(w.intervalMs) || w.intervalMs < 1000 || !integer(w.remainingChecks) || !integer(w.nextCheckAt))) return invalid();
|
|
54
|
+
if (w.intervalMs === undefined && (w.remainingChecks !== undefined || w.nextCheckAt !== undefined)) return invalid();
|
|
55
|
+
if (w.signalled !== undefined && typeof w.signalled !== "boolean") return invalid();
|
|
56
|
+
}
|
|
57
|
+
if (s.dispatch && (!text(s.dispatch.id) || !integer(s.dispatch.claimedAt) || !["ready", "check", "wake", "repair", "kickoff", "recovery"].includes(s.dispatch.kind))) return invalid();
|
|
58
|
+
if ((s.phase === "claimed" || s.phase === "running") && !s.dispatch) return invalid();
|
|
59
|
+
if (s.phase === "ready" && s.decision?.kind !== "ready") return invalid();
|
|
60
|
+
if (s.phase === "waiting" && (!s.wait || s.decision?.kind !== "wait")) return invalid();
|
|
61
|
+
const normalized = structuredClone(s) as GoalSchedulerState;
|
|
62
|
+
if (normalized.decision && "nextAction" in normalized.decision) delete (normalized.decision as Record<string, unknown>).nextAction;
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Split scheduling text into an append-safe counter line and the standing
|
|
68
|
+
* instructions. Instructions (next action, wait, admission) are cleared by
|
|
69
|
+
* omission, so a retained stale copy would keep issuing a cancelled order:
|
|
70
|
+
* they belong with the reset-on-change state, never with retained counters.
|
|
71
|
+
*/
|
|
72
|
+
export function schedulerSummaryParts(s: GoalSchedulerState | undefined, limit?: number, showRuns = true, idleBackoff = false, commands?: GoalCommands): { runs: string; instructions: string; idlePacing?: string } {
|
|
73
|
+
// An unlimited allowance has no limit to report, so the runs line is left
|
|
74
|
+
// empty rather than rendered as "used/unlimited". A finite allowance is
|
|
75
|
+
// reported unless showAutonomousRuns is off. Enforcement reads the limit
|
|
76
|
+
// elsewhere; this is display only.
|
|
77
|
+
const runs = limit !== undefined && showRuns ? `Autonomous runs: ${s?.used ?? 0}/${limit}${limit === 0 ? " (automatic continuation disabled)" : ""}.` : "";
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
let idlePacing: string | undefined;
|
|
80
|
+
if (s) {
|
|
81
|
+
if (idleBackoff && s.phase === "ready" && s.decision?.kind === "ready" && s.decision.purpose === "ready" && s.idle && s.idle.nextAt > Date.now()) {
|
|
82
|
+
const seconds = Math.ceil((s.idle.nextAt - Date.now()) / 1000);
|
|
83
|
+
const time = new Date(s.idle.nextAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
84
|
+
idlePacing = `Next automatic attempt in ${seconds}s (${time} local). ${commandHint("goal-resume", commands)} now.`;
|
|
85
|
+
lines.push(idlePacing);
|
|
86
|
+
}
|
|
87
|
+
if (s.wait) {
|
|
88
|
+
lines.push(`Waiting: ${s.wait.reason}; wait_id=${s.wait.id}; deadline=${new Date(s.wait.deadline).toISOString()}.`);
|
|
89
|
+
if (s.wait.nextCheckAt !== undefined) lines.push(`Next check: ${new Date(s.wait.nextCheckAt).toISOString()}; ${s.wait.remainingChecks} checks remaining.`);
|
|
90
|
+
}
|
|
91
|
+
if (s.phase === "interrupted") lines.push("Turn stopped. Resume or send a message to continue.");
|
|
92
|
+
if (s.phase === "claimed") lines.push(commandInstructions("Execution requires dispatch admission or explicit /goal-resume after interruption.", commands));
|
|
93
|
+
}
|
|
94
|
+
return { runs, instructions: lines.join("\n"), idlePacing };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function schedulerSummary(s: GoalSchedulerState | undefined, limit?: number, showRuns = true, idleBackoff = false, commands?: GoalCommands): string {
|
|
98
|
+
const { runs, instructions } = schedulerSummaryParts(s, limit, showRuns, idleBackoff, commands);
|
|
99
|
+
return [runs, instructions].filter(Boolean).join("\n");
|
|
100
|
+
}
|