@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1
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 +239 -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 +330 -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,843 @@
|
|
|
1
|
+
import { goalStorageContext, goalStorageRoot } from "./storage/goal-root.ts";
|
|
2
|
+
import { cloneGoal, nowIso, type GoalFocusReason, type GoalRecord, type GoalTask, type GoalUsage } from "./goal-record.ts";
|
|
3
|
+
import { appendGoalEvent, appendGoalEvents, type GoalLedgerEvent } from "./goal-ledger.ts";
|
|
4
|
+
import { findTaskInTree, updateTaskInTree } from "./goal-policy.ts";
|
|
5
|
+
import {
|
|
6
|
+
GOALS_DIR,
|
|
7
|
+
archiveGoalFile,
|
|
8
|
+
atomicWriteGoalFile,
|
|
9
|
+
ensureDirectory,
|
|
10
|
+
mergeGoalPromptFromDisk,
|
|
11
|
+
parseGoalFile,
|
|
12
|
+
readActiveGoalPool,
|
|
13
|
+
readActiveGoalPoolView,
|
|
14
|
+
resolveGoalPath,
|
|
15
|
+
safeUnlinkGoalFile,
|
|
16
|
+
sanitizeGoalPaths,
|
|
17
|
+
writeActiveGoalFile,
|
|
18
|
+
type GoalFileContext,
|
|
19
|
+
} from "./storage/goal-files.ts";
|
|
20
|
+
import { acquireGoalLock, type GoalLock } from "./storage/goal-lock.ts";
|
|
21
|
+
import { mergeFocusedGoalWithDisk } from "./goal-pool.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Session state access + runtime glue hooks that the GoalService needs.
|
|
25
|
+
* The extension (`extensions/goal.ts`) wires these to its closure state so the
|
|
26
|
+
* service stays the sole mutation boundary while the runtime effects
|
|
27
|
+
* (continuation queue, accounting, nudge state, tools, UI) remain in the
|
|
28
|
+
* extension's event handlers.
|
|
29
|
+
*/
|
|
30
|
+
export interface GoalDiagnostic {
|
|
31
|
+
severity: "warning";
|
|
32
|
+
source: "ledger" | "storage";
|
|
33
|
+
goalId?: string;
|
|
34
|
+
eventType?: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GoalServiceRef {
|
|
39
|
+
getFocused(): GoalRecord | null;
|
|
40
|
+
/** Mirror of the `state.goal` setter: pool upsert + focus assignment. */
|
|
41
|
+
setFocused(goal: GoalRecord | null): void;
|
|
42
|
+
getPool(): Map<string, GoalRecord>;
|
|
43
|
+
replacePool(pool: Map<string, GoalRecord>): void;
|
|
44
|
+
getFocusedGoalId(): string | null;
|
|
45
|
+
/** Assigns the session focus; bumps the focus revision when it changes. */
|
|
46
|
+
assignFocusedGoalId(goalId: string | null): void;
|
|
47
|
+
focusToken(goalId: string): { goalId: string; revision: number };
|
|
48
|
+
isTokenCurrent(token: { goalId: string; revision: number }): boolean;
|
|
49
|
+
appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void;
|
|
50
|
+
/** The focused goal vanished during reconciliation (external clear/archive/delete). */
|
|
51
|
+
onFocusedGoalLost(lostGoalId: string | null, ctx: GoalServiceContext): void;
|
|
52
|
+
/** A reconciled goal is now focused; clear continuation/accounting as its status requires. */
|
|
53
|
+
onReconciled(goal: GoalRecord): void;
|
|
54
|
+
/** The session focus changed; clear continuation/accounting/nudge state. */
|
|
55
|
+
onFocusChanged(from: string | null, to: string | null): void;
|
|
56
|
+
/** Observable diagnostic sink for non-fatal failures (ledger appends). */
|
|
57
|
+
onDiagnostic(diagnostic: GoalDiagnostic): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type GoalServiceContext = GoalFileContext;
|
|
61
|
+
|
|
62
|
+
export interface GoalMutationSpec {
|
|
63
|
+
/** If provided, the focused goal id must equal this or the mutation is rejected. */
|
|
64
|
+
expectedGoalId?: string | null;
|
|
65
|
+
/** If provided, the token must still be current (same goal id + focus revision). */
|
|
66
|
+
focusToken?: { goalId: string; revision: number };
|
|
67
|
+
/** Skip the leading disk reconciliation (used when the caller already reconciled or must not). */
|
|
68
|
+
reconcile?: boolean;
|
|
69
|
+
/** Merge the authoritative objective body from disk before mutating. */
|
|
70
|
+
refreshFromDisk?: boolean;
|
|
71
|
+
/** Mutate a clone of the focused goal. May ignore its input to produce a fixed record. */
|
|
72
|
+
mutate: (goal: GoalRecord) => GoalRecord;
|
|
73
|
+
/** Ledger events appended best-effort AFTER the authoritative file write. */
|
|
74
|
+
ledger?: GoalLedgerEvent[] | ((written: GoalRecord) => GoalLedgerEvent[]);
|
|
75
|
+
/** Write the archived goal file instead of the active file (complete/clear/abort). */
|
|
76
|
+
archive?: boolean;
|
|
77
|
+
/** Commit the written record as the focused in-memory goal. Default true. */
|
|
78
|
+
commitFocused?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GoalMutationResult {
|
|
82
|
+
ok: true;
|
|
83
|
+
goal: GoalRecord;
|
|
84
|
+
previousGoalId: string | null;
|
|
85
|
+
goalId: string | null;
|
|
86
|
+
focusChanged: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface GoalMutationFailure {
|
|
90
|
+
ok: false;
|
|
91
|
+
message: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type GoalMutationOutcome = GoalMutationResult | GoalMutationFailure;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* GoalService — the extension's sole mutation boundary for goal records.
|
|
98
|
+
*
|
|
99
|
+
* Ordered pipeline for every focused-goal mutation (mirrors TECH.md storage
|
|
100
|
+
* section):
|
|
101
|
+
*
|
|
102
|
+
* 1. safe focused record reconciliation from disk;
|
|
103
|
+
* 2. expected goal id + focus revision check;
|
|
104
|
+
* 3. mutation on a clone;
|
|
105
|
+
* 4. active-file write (or archival for complete/clear/abort);
|
|
106
|
+
* 5. ledger append best effort;
|
|
107
|
+
* 6. in-memory pool/focus commit;
|
|
108
|
+
* 7. runtime/UI effects (returned and signalled through the ref hooks).
|
|
109
|
+
*
|
|
110
|
+
* If the active-file write fails it throws, so memory/ledger/focus/archive are
|
|
111
|
+
* never committed. A failed ledger append after the authoritative write keeps
|
|
112
|
+
* the successful state transition and reports diagnostics via the hook, matching
|
|
113
|
+
* the current best-effort ledger semantics.
|
|
114
|
+
*/
|
|
115
|
+
export interface GoalTaskUpdateSpec {
|
|
116
|
+
/** If provided, the token must still be current (same goal id + focus revision). */
|
|
117
|
+
focusToken?: { goalId: string; revision: number };
|
|
118
|
+
/** The task to update, loaded fresh from the disk record. */
|
|
119
|
+
taskId: string;
|
|
120
|
+
/** Optional transition validation against the FRESH task; a failure aborts with a typed message. */
|
|
121
|
+
validate?(task: GoalTask): { ok: true } | { ok: false; message: string };
|
|
122
|
+
/** Transform the FRESH task. A failure aborts; only this task's path changes. */
|
|
123
|
+
update(task: GoalTask): GoalTask | { ok: false; message: string };
|
|
124
|
+
/**
|
|
125
|
+
* §8.1: explicit execution focus — set the persisted currentTaskId to this
|
|
126
|
+
* task id (start). Replaces any previous current task. Otherwise focus is
|
|
127
|
+
* cleared automatically when the updated task becomes terminal
|
|
128
|
+
* (complete/skipped) and was the current task (§8.2/§8.3).
|
|
129
|
+
*/
|
|
130
|
+
setCurrentTaskId?: string;
|
|
131
|
+
/** Ledger events appended best-effort AFTER the authoritative file write. */
|
|
132
|
+
ledger?(written: GoalRecord, updatedTask: GoalTask): GoalLedgerEvent[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type GoalTaskUpdateOutcome = { ok: true; goal: GoalRecord; task: GoalTask } | { ok: false; message: string };
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* §8 execution-focus resolution for a task update: an explicit start sets the
|
|
139
|
+
* currentTaskId (replacing any previous); otherwise completing or skipping the
|
|
140
|
+
* current task clears it; otherwise focus is untouched.
|
|
141
|
+
*/
|
|
142
|
+
function resolveUpdatedCurrentTaskId(spec: GoalTaskUpdateSpec, current: string | undefined, updatedTask: GoalTask): string | undefined {
|
|
143
|
+
if (spec.setCurrentTaskId !== undefined) return spec.setCurrentTaskId;
|
|
144
|
+
const terminal = updatedTask.status === "complete" || updatedTask.status === "skipped";
|
|
145
|
+
if (terminal && current === spec.taskId) return undefined;
|
|
146
|
+
return current;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class GoalService {
|
|
150
|
+
private readonly ref: GoalServiceRef;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The usage this session last wrote to disk for a goal (or the disk usage
|
|
154
|
+
* the session's accounting is relative to after load/reconcile). The
|
|
155
|
+
* baseline for additive usage merging: on a persist revision conflict the
|
|
156
|
+
* delta since this baseline is merged onto the disk record instead of the
|
|
157
|
+
* local usage being dropped.
|
|
158
|
+
*/
|
|
159
|
+
private lastPersistedUsage: { goalId: string; tokensUsed: number; activeSeconds: number } | null = null;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Per-turn transaction buffer (P1-3): task/status/usage mutations and
|
|
163
|
+
* ledger events accumulate in memory during a turn and flush once — one
|
|
164
|
+
* lock acquire, one goal-file write, one ledger batch — at turn end or at
|
|
165
|
+
* an explicit flush boundary (audit dispatch, pause/stop, focus change,
|
|
166
|
+
* session reload). Reads during the turn overlay the buffered goal so
|
|
167
|
+
* in-turn mutations are always visible; the optimistic revision check runs
|
|
168
|
+
* at flush time.
|
|
169
|
+
*/
|
|
170
|
+
private flushError: string | null = null;
|
|
171
|
+
private turnBase: GoalRecord | null = null;
|
|
172
|
+
private turnContext: GoalServiceContext | null = null;
|
|
173
|
+
|
|
174
|
+
private turn: { active: boolean; goalId: string | null; goal: GoalRecord | null; archive: boolean; ledger: GoalLedgerEvent[] } = {
|
|
175
|
+
active: false,
|
|
176
|
+
goalId: null,
|
|
177
|
+
goal: null,
|
|
178
|
+
archive: false,
|
|
179
|
+
ledger: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Open (or reopen) the per-turn transaction for the focused goal. */
|
|
183
|
+
beginTurn(ctx: GoalServiceContext, goalId: string | null): void {
|
|
184
|
+
if (this.turn.active) {
|
|
185
|
+
this.flushTurn(ctx);
|
|
186
|
+
// Lock contention must not discard the previous transaction. Keep it
|
|
187
|
+
// open so the next turn boundary retries the same buffered write.
|
|
188
|
+
if (this.turn.active) return;
|
|
189
|
+
}
|
|
190
|
+
this.turn = { active: true, goalId, goal: null, archive: false, ledger: [] };
|
|
191
|
+
this.turnContext = goalStorageContext(ctx);
|
|
192
|
+
const focused = this.ref.getFocused();
|
|
193
|
+
this.turnBase = focused ? {...focused, usage: {...focused.usage}} : null;
|
|
194
|
+
this.flushError = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Flush the buffered transaction: one lock acquire, one (archived or
|
|
199
|
+
* active) goal-file write with a revision bump, one batched ledger append.
|
|
200
|
+
* No-op when nothing was buffered. Returns the written record or null.
|
|
201
|
+
*/
|
|
202
|
+
flushTurn(ctx: GoalServiceContext): GoalRecord | null {
|
|
203
|
+
if (!this.turn.active) return null;
|
|
204
|
+
ctx = this.turnContext ?? ctx;
|
|
205
|
+
this.flushError = null;
|
|
206
|
+
const goal = this.turn.goal;
|
|
207
|
+
if (!goal) {
|
|
208
|
+
this.turn.active = false;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
let lock: GoalLock;
|
|
212
|
+
try {
|
|
213
|
+
lock = acquireGoalLock(ctx, goal.id);
|
|
214
|
+
} catch {
|
|
215
|
+
this.flushError = "Goal storage is locked; pending changes have not been persisted.";
|
|
216
|
+
// Another writer holds the lock; preserve the buffer so a later turn
|
|
217
|
+
// boundary can retry it instead of silently losing the mutation.
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const freshDisk = this.readFreshDiskGoal(ctx, goal);
|
|
222
|
+
const expected = this.turnBase ?? goal;
|
|
223
|
+
if (!freshDisk || (freshDisk.revision ?? 0) !== (expected.revision ?? 0)) {
|
|
224
|
+
this.flushError = `Goal ${goal.id} changed in another process; buffered changes were rejected. Refresh and retry.`;
|
|
225
|
+
// Reject the speculative transaction, never overwrite another writer.
|
|
226
|
+
this.turn.active = false;
|
|
227
|
+
this.turn.goal = null;
|
|
228
|
+
this.turn.ledger = [];
|
|
229
|
+
if (freshDisk && this.ref.getFocusedGoalId() === goal.id) {
|
|
230
|
+
const tokens = Math.max(0, goal.usage.tokensUsed - expected.usage.tokensUsed);
|
|
231
|
+
const seconds = Math.max(0, goal.usage.activeSeconds - expected.usage.activeSeconds);
|
|
232
|
+
this.ref.setFocused({ ...freshDisk, usage: {tokensUsed: freshDisk.usage.tokensUsed + tokens, activeSeconds: freshDisk.usage.activeSeconds + seconds} });
|
|
233
|
+
this.trackBaseline(freshDisk.id, freshDisk.usage);
|
|
234
|
+
} else if (!freshDisk) {
|
|
235
|
+
this.ref.getPool().delete(goal.id);
|
|
236
|
+
if (this.ref.getFocusedGoalId() === goal.id) {
|
|
237
|
+
this.ref.assignFocusedGoalId(null);
|
|
238
|
+
this.ref.onFocusedGoalLost(goal.id, ctx);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this.ref.onDiagnostic({ severity: "warning", source: "storage", goalId: goal.id, message: this.flushError });
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const base = freshDisk;
|
|
245
|
+
const next = { ...goal, revision: (base.revision ?? 0) + 1 };
|
|
246
|
+
const written = this.turn.archive || next.status === "complete"
|
|
247
|
+
? archiveGoalFile(ctx, next)
|
|
248
|
+
: writeActiveGoalFile(ctx, next);
|
|
249
|
+
this.turn.active = false;
|
|
250
|
+
this.appendLedgerEventsBestEffort(ctx, this.turn.ledger);
|
|
251
|
+
this.trackBaseline(written.id, written.usage);
|
|
252
|
+
if (this.ref.getFocusedGoalId() === written.id) this.ref.setFocused(written);
|
|
253
|
+
else this.ref.getPool().set(written.id, written);
|
|
254
|
+
return written;
|
|
255
|
+
} finally {
|
|
256
|
+
lock.release();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Completion must never audit state that failed to flush. */
|
|
261
|
+
flushForAudit(ctx: GoalServiceContext): string | null {
|
|
262
|
+
this.flushTurn(ctx);
|
|
263
|
+
return this.flushError;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** End the turn: flush any pending transaction (safe to call always). */
|
|
267
|
+
endTurn(ctx: GoalServiceContext): void {
|
|
268
|
+
this.flushTurn(ctx);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** True while the transaction buffer is open. */
|
|
272
|
+
isTurnBuffered(): boolean {
|
|
273
|
+
return this.turn.active;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
constructor(ref: GoalServiceRef) {
|
|
277
|
+
this.ref = ref;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Record the usage value the focused goal's in-memory accounting builds on. */
|
|
281
|
+
private trackBaseline(goalId: string, usage: GoalUsage): void {
|
|
282
|
+
this.lastPersistedUsage = { goalId, tokensUsed: usage.tokensUsed, activeSeconds: usage.activeSeconds };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Session-local usage delta since the last recorded baseline (clamped at
|
|
287
|
+
* zero so usage is never reduced). Falls back to the disk usage when this
|
|
288
|
+
* session has no baseline for the goal (never persisted/reconciled it).
|
|
289
|
+
*/
|
|
290
|
+
private usageDelta(current: GoalRecord, disk: GoalRecord): { tokens: number; seconds: number } {
|
|
291
|
+
const baseline = this.lastPersistedUsage?.goalId === current.id
|
|
292
|
+
? this.lastPersistedUsage
|
|
293
|
+
: { goalId: current.id, tokensUsed: disk.usage.tokensUsed, activeSeconds: disk.usage.activeSeconds };
|
|
294
|
+
return {
|
|
295
|
+
tokens: Math.max(0, current.usage.tokensUsed - baseline.tokensUsed),
|
|
296
|
+
seconds: Math.max(0, current.usage.activeSeconds - baseline.activeSeconds),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Append ledger events without making them part of the authoritative state
|
|
302
|
+
* transaction. A batch is the common path; individual retries preserve the
|
|
303
|
+
* existing best-effort behavior and keep failures observable per event.
|
|
304
|
+
*/
|
|
305
|
+
private appendLedgerEventsBestEffort(ctx: GoalServiceContext, events: GoalLedgerEvent[]): boolean {
|
|
306
|
+
if (events.length === 0) return true;
|
|
307
|
+
if (events.length > 1 && appendGoalEvents(ctx, events).ok) return true;
|
|
308
|
+
let success = true;
|
|
309
|
+
for (const event of events) {
|
|
310
|
+
const append = appendGoalEvent(ctx, event);
|
|
311
|
+
if (!append.ok) {
|
|
312
|
+
success = false;
|
|
313
|
+
this.ref.onDiagnostic({
|
|
314
|
+
severity: "warning",
|
|
315
|
+
source: "ledger",
|
|
316
|
+
goalId: "goalId" in event ? event.goalId : undefined,
|
|
317
|
+
eventType: event.type,
|
|
318
|
+
message: `Ledger append failed for ${event.type}${"goalId" in event ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return success;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Safe focused record reconciliation from disk. */
|
|
326
|
+
reconcileFocused(ctx: GoalServiceContext, opts: { preserveMemoryUsage?: boolean } = {}): boolean {
|
|
327
|
+
const current = this.ref.getFocused();
|
|
328
|
+
const source = readActiveGoalPoolView(ctx);
|
|
329
|
+
const focused = this.ref.getFocusedGoalId();
|
|
330
|
+
const view = this.ref.getPool();
|
|
331
|
+
const buffered = this.turn.active ? this.turn.goal : null;
|
|
332
|
+
let unchanged = source.size === view.size && (!focused || source.has(focused));
|
|
333
|
+
if (unchanged) for (const [id, goal] of source) {
|
|
334
|
+
if (view.get(id) !== (buffered?.id === id ? buffered : goal)) { unchanged = false; break; }
|
|
335
|
+
}
|
|
336
|
+
if (unchanged) {
|
|
337
|
+
const same = focused ? view.get(focused) : undefined;
|
|
338
|
+
if (same) this.ref.onReconciled(same);
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
const fresh = new Map(source);
|
|
342
|
+
// P1-3: overlay the buffered goal so in-turn mutations are visible to
|
|
343
|
+
// every read (reconcile, get_goal, prompts) without a disk round trip.
|
|
344
|
+
if (this.turn.active && this.turn.goal) fresh.set(this.turn.goal.id, this.turn.goal);
|
|
345
|
+
const focusedGoalId = this.ref.getFocusedGoalId();
|
|
346
|
+
if (!focusedGoalId) {
|
|
347
|
+
this.ref.replacePool(fresh);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
const diskGoal = fresh.get(focusedGoalId) ?? null;
|
|
351
|
+
if (!diskGoal) {
|
|
352
|
+
if (current && !current.activePath) {
|
|
353
|
+
this.ref.replacePool(fresh);
|
|
354
|
+
fresh.set(current.id, current);
|
|
355
|
+
this.ref.assignFocusedGoalId(current.id);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
const lostGoalId = current?.id ?? null;
|
|
359
|
+
this.ref.replacePool(fresh);
|
|
360
|
+
this.ref.assignFocusedGoalId(null);
|
|
361
|
+
this.ref.onFocusedGoalLost(lostGoalId, ctx);
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const reconciled = current && opts.preserveMemoryUsage
|
|
365
|
+
? mergeFocusedGoalWithDisk({ memoryGoal: current, diskGoal })
|
|
366
|
+
: diskGoal;
|
|
367
|
+
this.ref.replacePool(fresh);
|
|
368
|
+
fresh.set(reconciled.id, reconciled);
|
|
369
|
+
this.ref.assignFocusedGoalId(reconciled.id);
|
|
370
|
+
this.ref.onReconciled(reconciled);
|
|
371
|
+
this.trackBaseline(reconciled.id, reconciled.usage);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Read the goal's authoritative active file directly (no complete-status
|
|
377
|
+
* filter, unlike the pool reader) for the optimistic revision check under
|
|
378
|
+
* the per-goal lock. Returns null when the file is gone (external
|
|
379
|
+
* archive/delete) or the goal has no active path.
|
|
380
|
+
*/
|
|
381
|
+
private readFreshDiskGoal(ctx: GoalServiceContext, current: GoalRecord): GoalRecord | null {
|
|
382
|
+
if (!current.activePath) return null;
|
|
383
|
+
try {
|
|
384
|
+
return parseGoalFile(resolveGoalPath(ctx, GOALS_DIR, current.activePath));
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The single ordered mutation pipeline for a focused goal.
|
|
392
|
+
* Returns the written record plus focus-change effects; the extension maps
|
|
393
|
+
* failures to user-facing results.
|
|
394
|
+
*
|
|
395
|
+
* Cross-process control (follow-up Stage 4): after the session-local
|
|
396
|
+
* validation, an exclusive per-goal lock is acquired (bounded, with stale
|
|
397
|
+
* recovery) and the authoritative file is re-read under the lock. If the
|
|
398
|
+
* persisted revision differs from the one captured at reconciliation, the
|
|
399
|
+
* mutation returns a typed conflict carrying the current revision instead
|
|
400
|
+
* of overwriting blindly. On success the revision is incremented, the file
|
|
401
|
+
* is written atomically, ledger events are appended best-effort, memory is
|
|
402
|
+
* committed, and the lock is released in a finally block.
|
|
403
|
+
*/
|
|
404
|
+
apply(ctx: GoalServiceContext, spec: GoalMutationSpec): GoalMutationOutcome {
|
|
405
|
+
// 1. reconcile (unless the caller opts out — e.g. the tweak path, which
|
|
406
|
+
// must not clobber the authoritative new objective with the old file).
|
|
407
|
+
if (spec.reconcile !== false && !this.reconcileFocused(ctx)) {
|
|
408
|
+
return { ok: false, message: "The focused goal was lost during reconciliation; the mutation was not applied." };
|
|
409
|
+
}
|
|
410
|
+
const current = this.ref.getFocused();
|
|
411
|
+
if (!current) {
|
|
412
|
+
return { ok: false, message: "No focused goal to mutate." };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 2. expected goal id + focus revision validation.
|
|
416
|
+
if (spec.expectedGoalId != null && current.id !== spec.expectedGoalId) {
|
|
417
|
+
return { ok: false, message: `Mutation rejected: expected goal ${spec.expectedGoalId} but the focused goal is ${current.id}.` };
|
|
418
|
+
}
|
|
419
|
+
if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) {
|
|
420
|
+
return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 2b. P1-3: during a turn the mutation is buffered in memory (no lock, no
|
|
424
|
+
// disk write yet); the flush acquires the lock, writes, and appends
|
|
425
|
+
// the ledger once per turn. Outside a turn the immediate path below
|
|
426
|
+
// (lock + optimistic revision check) is unchanged.
|
|
427
|
+
if (this.turn.active) {
|
|
428
|
+
if (this.turn.goalId !== null && current.id !== this.turn.goalId) {
|
|
429
|
+
// Focus changed mid-turn: persist the previous buffer first.
|
|
430
|
+
this.flushTurn(ctx);
|
|
431
|
+
if (this.turn.active) return {ok: false, message: this.flushError ?? "Previous goal changes are still awaiting persistence."};
|
|
432
|
+
this.turn.active = true;
|
|
433
|
+
this.turn.goalId = current.id;
|
|
434
|
+
this.turn.goal = current;
|
|
435
|
+
this.turnBase = current;
|
|
436
|
+
this.turn.archive = false;
|
|
437
|
+
this.turn.ledger = [];
|
|
438
|
+
}
|
|
439
|
+
if (!this.turn.goal) {
|
|
440
|
+
this.turn.goal = current;
|
|
441
|
+
this.turn.goalId = current.id;
|
|
442
|
+
if (this.turnBase?.id !== current.id) this.turnBase = {...current, usage: {...current.usage}};
|
|
443
|
+
}
|
|
444
|
+
const base = current;
|
|
445
|
+
const mutated = sanitizeGoalPaths(ctx, {
|
|
446
|
+
...spec.mutate(cloneGoal(base)),
|
|
447
|
+
revision: (current.revision ?? 0) + 1,
|
|
448
|
+
});
|
|
449
|
+
if (spec.ledger) {
|
|
450
|
+
try {
|
|
451
|
+
const events = typeof spec.ledger === "function" ? spec.ledger(mutated) : spec.ledger;
|
|
452
|
+
this.turn.ledger.push(...events);
|
|
453
|
+
} catch {
|
|
454
|
+
// Ledger spec error: nothing appended (same swallow as the write path).
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
this.turn.goal = mutated;
|
|
458
|
+
this.turn.archive = spec.archive === true;
|
|
459
|
+
const previousGoalId = current.id;
|
|
460
|
+
const commitFocused = spec.commitFocused !== false;
|
|
461
|
+
if (commitFocused) {
|
|
462
|
+
this.ref.setFocused(mutated);
|
|
463
|
+
this.trackBaseline(mutated.id, mutated.usage);
|
|
464
|
+
}
|
|
465
|
+
const goalId = commitFocused ? mutated.id : this.ref.getFocusedGoalId();
|
|
466
|
+
const focusChanged = commitFocused && previousGoalId !== mutated.id;
|
|
467
|
+
if (focusChanged) this.ref.onFocusChanged(previousGoalId, mutated.id);
|
|
468
|
+
return { ok: true, goal: mutated, previousGoalId, goalId, focusChanged };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 2b. exclusive per-goal lock + optimistic revision check (follow-up Stage 4).
|
|
472
|
+
const capturedRevision = current.revision ?? 0;
|
|
473
|
+
const lock = acquireGoalLock(ctx, current.id);
|
|
474
|
+
try {
|
|
475
|
+
const freshDisk = this.readFreshDiskGoal(ctx, current);
|
|
476
|
+
if (!freshDisk) {
|
|
477
|
+
return { ok: false, message: `Goal ${current.id} was deleted or archived by another process while this mutation was in progress; the mutation was not applied.` };
|
|
478
|
+
}
|
|
479
|
+
const diskRevision = freshDisk.revision ?? 0;
|
|
480
|
+
if (diskRevision !== capturedRevision) {
|
|
481
|
+
return { ok: false, message: `Goal ${current.id} was modified by another process (revision ${capturedRevision} -> ${diskRevision}); current revision is ${diskRevision}. Refresh and retry; the mutation was not applied.` };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 3. mutation on a clone (after an optional authoritative objective merge).
|
|
485
|
+
const base = spec.refreshFromDisk ? mergeGoalPromptFromDisk(ctx, current) : current;
|
|
486
|
+
const mutated = {
|
|
487
|
+
...spec.mutate(cloneGoal(base)),
|
|
488
|
+
revision: capturedRevision + 1,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// 4. authoritative file write (active or archive). A failure here throws
|
|
492
|
+
// and prevents any memory/ledger/focus/archive commit.
|
|
493
|
+
const written = spec.archive ? archiveGoalFile(ctx, mutated) : writeActiveGoalFile(ctx, mutated);
|
|
494
|
+
|
|
495
|
+
// 5. ledger append best effort.
|
|
496
|
+
if (spec.ledger) {
|
|
497
|
+
let events: GoalLedgerEvent[];
|
|
498
|
+
try {
|
|
499
|
+
events = typeof spec.ledger === "function" ? spec.ledger(written) : spec.ledger;
|
|
500
|
+
} catch {
|
|
501
|
+
events = [];
|
|
502
|
+
}
|
|
503
|
+
this.appendLedgerEventsBestEffort(ctx, events);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 6. in-memory pool/focus commit.
|
|
507
|
+
const previousGoalId = current.id;
|
|
508
|
+
const commitFocused = spec.commitFocused !== false;
|
|
509
|
+
if (commitFocused) {
|
|
510
|
+
this.ref.setFocused(written);
|
|
511
|
+
this.trackBaseline(written.id, written.usage);
|
|
512
|
+
}
|
|
513
|
+
const goalId = commitFocused ? written.id : this.ref.getFocusedGoalId();
|
|
514
|
+
const focusChanged = commitFocused && previousGoalId !== written.id;
|
|
515
|
+
|
|
516
|
+
// 7. runtime/UI effects.
|
|
517
|
+
if (focusChanged) this.ref.onFocusChanged(previousGoalId, written.id);
|
|
518
|
+
|
|
519
|
+
return { ok: true, goal: written, previousGoalId, goalId, focusChanged };
|
|
520
|
+
} finally {
|
|
521
|
+
lock.release();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Disk-fresh single-task transaction. Pipeline:
|
|
529
|
+
* 1. reconcile the focused record;
|
|
530
|
+
* 2. validate focus token/id;
|
|
531
|
+
* 3. load the fresh task from the cloned disk record;
|
|
532
|
+
* 4. validate the requested transition against that fresh task;
|
|
533
|
+
* 5. update only that task's path;
|
|
534
|
+
* 6. write the active file, append the ledger, and commit.
|
|
535
|
+
* Expected races (removed task, removed task list) return typed failures
|
|
536
|
+
* instead of throwing.
|
|
537
|
+
*/
|
|
538
|
+
updateTask(ctx: GoalServiceContext, spec: GoalTaskUpdateSpec): GoalTaskUpdateOutcome {
|
|
539
|
+
// P1-3: during a turn, apply to the buffered goal in memory (validation
|
|
540
|
+
// still runs against the current in-turn state); the flush writes once.
|
|
541
|
+
if (this.turn.active) {
|
|
542
|
+
const current = this.turn.goal ?? this.ref.getFocused();
|
|
543
|
+
if (!current) {
|
|
544
|
+
return { ok: false, message: "No focused goal to mutate." };
|
|
545
|
+
}
|
|
546
|
+
if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) {
|
|
547
|
+
return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` };
|
|
548
|
+
}
|
|
549
|
+
if (!current.taskList) {
|
|
550
|
+
return { ok: false, message: "The goal has no task list." };
|
|
551
|
+
}
|
|
552
|
+
const task = findTaskInTree(current.taskList.tasks, spec.taskId);
|
|
553
|
+
if (!task) {
|
|
554
|
+
return { ok: false, message: `Task "${spec.taskId}" not found.` };
|
|
555
|
+
}
|
|
556
|
+
if (spec.validate) {
|
|
557
|
+
const gate = spec.validate(task);
|
|
558
|
+
if (!gate.ok) return gate;
|
|
559
|
+
}
|
|
560
|
+
const updated = spec.update(task);
|
|
561
|
+
if (typeof updated === "object" && "ok" in updated && !updated.ok) return updated;
|
|
562
|
+
const updatedTask = updated as GoalTask;
|
|
563
|
+
const updatedTasks = updateTaskInTree(current.taskList.tasks, spec.taskId, () => updatedTask);
|
|
564
|
+
const mutated = sanitizeGoalPaths(ctx, {
|
|
565
|
+
...current,
|
|
566
|
+
taskList: { ...current.taskList, tasks: updatedTasks },
|
|
567
|
+
// Execution focus (§8): start sets it explicitly; completing or
|
|
568
|
+
// skipping the current task clears it.
|
|
569
|
+
currentTaskId: resolveUpdatedCurrentTaskId(spec, current.currentTaskId, updatedTask),
|
|
570
|
+
updatedAt: nowIso(),
|
|
571
|
+
revision: (current.revision ?? 0) + 1,
|
|
572
|
+
});
|
|
573
|
+
if (spec.ledger) {
|
|
574
|
+
try {
|
|
575
|
+
this.turn.ledger.push(...spec.ledger(mutated, updatedTask));
|
|
576
|
+
} catch (err) {
|
|
577
|
+
this.ref.onDiagnostic({
|
|
578
|
+
severity: "warning",
|
|
579
|
+
source: "ledger",
|
|
580
|
+
goalId: spec.taskId,
|
|
581
|
+
message: `Ledger spec error during task update: ${String(err)}`,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
this.turn.goal = mutated;
|
|
586
|
+
this.ref.setFocused(mutated);
|
|
587
|
+
return { ok: true, goal: mutated, task: updatedTask };
|
|
588
|
+
}
|
|
589
|
+
return this.updateTaskAttempt(ctx, spec, 1);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Ordered all-or-nothing batch. Each validator sees earlier changes in the clone. */
|
|
593
|
+
updateTasks(ctx: GoalServiceContext, specs: GoalTaskUpdateSpec[]): GoalMutationOutcome {
|
|
594
|
+
if (!this.reconcileFocused(ctx)) return {ok: false, message: "No focused goal to mutate."};
|
|
595
|
+
const current = this.ref.getFocused();
|
|
596
|
+
if (!current?.taskList) return {ok: false, message: "The goal has no task list."};
|
|
597
|
+
let lock: GoalLock | undefined;
|
|
598
|
+
try {
|
|
599
|
+
let base = current;
|
|
600
|
+
if (!this.turn.active) {
|
|
601
|
+
lock = acquireGoalLock(ctx, current.id);
|
|
602
|
+
const fresh = this.readFreshDiskGoal(ctx, current);
|
|
603
|
+
if (!fresh || (fresh.revision ?? 0) !== (current.revision ?? 0)) return {ok: false, message: "The goal changed in another process; no updates applied. Refresh and retry."};
|
|
604
|
+
if (fresh.status !== "active" || !fresh.taskList) return {ok: false, message: "The persisted goal is no longer active with tasks; no updates applied."};
|
|
605
|
+
base = {...fresh, usage: current.usage};
|
|
606
|
+
}
|
|
607
|
+
const next = cloneGoal(base);
|
|
608
|
+
const locations = new Map<string, {tasks: GoalTask[]; index: number}>();
|
|
609
|
+
const indexTasks = (tasks: GoalTask[]): void => {
|
|
610
|
+
for (let i = 0; i < tasks.length; i++) { const task = tasks[i]!; locations.set(task.id, {tasks, index: i}); if (task.subtasks) indexTasks(task.subtasks); }
|
|
611
|
+
};
|
|
612
|
+
const removeTasks = (tasks: GoalTask[]): void => {
|
|
613
|
+
for (const task of tasks) { locations.delete(task.id); if (task.subtasks) removeTasks(task.subtasks); }
|
|
614
|
+
};
|
|
615
|
+
indexTasks(next.taskList!.tasks);
|
|
616
|
+
const events: GoalLedgerEvent[] = [];
|
|
617
|
+
for (const spec of specs) {
|
|
618
|
+
if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) return {ok: false, message: "The focused goal changed; no updates applied."};
|
|
619
|
+
const location = locations.get(spec.taskId);
|
|
620
|
+
const task = location?.tasks[location.index];
|
|
621
|
+
if (!task) return {ok: false, message: `Task "${spec.taskId}" not found.`};
|
|
622
|
+
const valid = spec.validate?.(task);
|
|
623
|
+
if (valid && !valid.ok) return valid;
|
|
624
|
+
const updated = spec.update(task);
|
|
625
|
+
if ("ok" in updated && !updated.ok) return updated;
|
|
626
|
+
const updatedTask = updated as GoalTask;
|
|
627
|
+
location!.tasks[location!.index] = updatedTask;
|
|
628
|
+
if (updatedTask.id !== task.id) { locations.delete(task.id); locations.set(updatedTask.id, location!); }
|
|
629
|
+
if (updatedTask.subtasks !== task.subtasks) {
|
|
630
|
+
if (task.subtasks) removeTasks(task.subtasks);
|
|
631
|
+
if (updatedTask.subtasks) indexTasks(updatedTask.subtasks);
|
|
632
|
+
}
|
|
633
|
+
next.currentTaskId = resolveUpdatedCurrentTaskId(spec, next.currentTaskId, updatedTask);
|
|
634
|
+
next.updatedAt = nowIso();
|
|
635
|
+
if (spec.ledger) events.push(...spec.ledger(next, updatedTask));
|
|
636
|
+
}
|
|
637
|
+
if (this.turn.active) return this.apply(ctx, {reconcile: false, expectedGoalId: current.id, focusToken: specs[0]?.focusToken, mutate: () => next, ledger: events});
|
|
638
|
+
const written = writeActiveGoalFile(ctx, {...next, revision: (base.revision ?? 0) + 1});
|
|
639
|
+
this.appendLedgerEventsBestEffort(ctx, events);
|
|
640
|
+
this.trackBaseline(written.id, written.usage);
|
|
641
|
+
this.ref.setFocused(written);
|
|
642
|
+
return {ok: true, goal: written, previousGoalId: current.id, goalId: written.id, focusChanged: false};
|
|
643
|
+
} finally { lock?.release(); }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Disk-fresh single-task transaction (follow-up Stage 4 adds the per-goal
|
|
648
|
+
* lock + optimistic revision check). Pipeline:
|
|
649
|
+
* 1. reconcile the focused record;
|
|
650
|
+
* 2. validate focus token/id;
|
|
651
|
+
* 3. acquire the per-goal lock and re-read the authoritative file;
|
|
652
|
+
* 4. a stale writer gets a typed conflict; it retries ONCE with the fresh
|
|
653
|
+
* state — the transition validation re-checks that the same task and
|
|
654
|
+
* relevant status/structure are unchanged, so a genuinely concurrent
|
|
655
|
+
* edit is rejected rather than silently merged;
|
|
656
|
+
* 5. load the fresh task, validate the transition, update only that task;
|
|
657
|
+
* 6. write with an incremented revision, append the ledger, and commit.
|
|
658
|
+
*/
|
|
659
|
+
private updateTaskAttempt(ctx: GoalServiceContext, spec: GoalTaskUpdateSpec, retriesLeft: number): GoalTaskUpdateOutcome {
|
|
660
|
+
if (!this.reconcileFocused(ctx)) {
|
|
661
|
+
return { ok: false, message: "The focused goal was lost during reconciliation; the task was not updated." };
|
|
662
|
+
}
|
|
663
|
+
const current = this.ref.getFocused();
|
|
664
|
+
if (!current) {
|
|
665
|
+
return { ok: false, message: "No focused goal to mutate." };
|
|
666
|
+
}
|
|
667
|
+
if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) {
|
|
668
|
+
return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` };
|
|
669
|
+
}
|
|
670
|
+
const capturedRevision = current.revision ?? 0;
|
|
671
|
+
const lock = acquireGoalLock(ctx, current.id);
|
|
672
|
+
try {
|
|
673
|
+
const freshDisk = this.readFreshDiskGoal(ctx, current);
|
|
674
|
+
if (!freshDisk) {
|
|
675
|
+
return { ok: false, message: `Goal ${current.id} was deleted or archived by another process while the task update was in progress; the task was not updated.` };
|
|
676
|
+
}
|
|
677
|
+
const diskRevision = freshDisk.revision ?? 0;
|
|
678
|
+
if (diskRevision !== capturedRevision) {
|
|
679
|
+
if (retriesLeft > 0) {
|
|
680
|
+
// Retry once against the fresh state; the transition validation
|
|
681
|
+
// below is the guard for task status/structure changes.
|
|
682
|
+
return this.updateTaskAttempt(ctx, spec, retriesLeft - 1);
|
|
683
|
+
}
|
|
684
|
+
return { ok: false, message: `Goal ${current.id} was modified by another process (revision ${capturedRevision} -> ${diskRevision}); current revision is ${diskRevision}. The task was not updated.` };
|
|
685
|
+
}
|
|
686
|
+
const base = mergeGoalPromptFromDisk(ctx, current);
|
|
687
|
+
if (!base.taskList) {
|
|
688
|
+
return { ok: false, message: "The goal has no task list." };
|
|
689
|
+
}
|
|
690
|
+
const task = findTaskInTree(base.taskList.tasks, spec.taskId);
|
|
691
|
+
if (!task) {
|
|
692
|
+
return { ok: false, message: `Task "${spec.taskId}" not found.` };
|
|
693
|
+
}
|
|
694
|
+
if (spec.validate) {
|
|
695
|
+
const gate = spec.validate(task);
|
|
696
|
+
if (!gate.ok) return gate;
|
|
697
|
+
}
|
|
698
|
+
const updated = spec.update(task);
|
|
699
|
+
if (typeof updated === "object" && "ok" in updated && !updated.ok) return updated;
|
|
700
|
+
const updatedTask = updated as GoalTask;
|
|
701
|
+
const updatedTasks = updateTaskInTree(base.taskList.tasks, spec.taskId, () => updatedTask);
|
|
702
|
+
const mutated = {
|
|
703
|
+
...base,
|
|
704
|
+
taskList: { ...base.taskList, tasks: updatedTasks },
|
|
705
|
+
currentTaskId: resolveUpdatedCurrentTaskId(spec, base.currentTaskId, updatedTask),
|
|
706
|
+
updatedAt: nowIso(),
|
|
707
|
+
revision: capturedRevision + 1,
|
|
708
|
+
};
|
|
709
|
+
const written = writeActiveGoalFile(ctx, mutated);
|
|
710
|
+
if (spec.ledger) {
|
|
711
|
+
try {
|
|
712
|
+
this.appendLedgerEventsBestEffort(ctx, spec.ledger(written, updatedTask));
|
|
713
|
+
} catch (err) {
|
|
714
|
+
// Unexpected ledger-spec error after the authoritative write keeps
|
|
715
|
+
// the successful state transition.
|
|
716
|
+
this.ref.onDiagnostic({
|
|
717
|
+
severity: "warning",
|
|
718
|
+
source: "ledger",
|
|
719
|
+
goalId: spec.taskId,
|
|
720
|
+
message: `Ledger spec error during task update: ${String(err)}`,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
this.ref.setFocused(written);
|
|
725
|
+
return { ok: true, goal: written, task: updatedTask };
|
|
726
|
+
} finally {
|
|
727
|
+
lock.release();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Persist the focused goal: bump updatedAt, merge objective from disk, write
|
|
733
|
+
* active or archive. Serialized by the per-goal lock with a short bounded
|
|
734
|
+
* budget. If another process bumped the revision meanwhile, the persist
|
|
735
|
+
* does not clobber the disk's authoritative fields — instead the session's
|
|
736
|
+
* additive usage/accounting delta since the last baseline is merged onto
|
|
737
|
+
* the disk record (concurrent work is never silently dropped).
|
|
738
|
+
*/
|
|
739
|
+
persist(ctx: GoalServiceContext): GoalRecord | null {
|
|
740
|
+
const current = this.ref.getFocused();
|
|
741
|
+
if (!current) return null;
|
|
742
|
+
// P1-3: within a turn the persist is buffered; the flush at turn end
|
|
743
|
+
// performs the single write + ledger batch.
|
|
744
|
+
if (this.turn.active) {
|
|
745
|
+
const next = { ...current, updatedAt: nowIso() };
|
|
746
|
+
this.turn.goal = next;
|
|
747
|
+
this.ref.setFocused(next);
|
|
748
|
+
return next;
|
|
749
|
+
}
|
|
750
|
+
const capturedRevision = current.revision ?? 0;
|
|
751
|
+
let lock: GoalLock;
|
|
752
|
+
try {
|
|
753
|
+
lock = acquireGoalLock(ctx, current.id, { attempts: 4, retryMs: 25 }); // P1-5: 100ms bound
|
|
754
|
+
} catch {
|
|
755
|
+
// Another writer holds the goal lock; skip this persist tick.
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const freshDisk = this.readFreshDiskGoal(ctx, current);
|
|
760
|
+
if (!freshDisk) return null;
|
|
761
|
+
if ((freshDisk.revision ?? 0) !== capturedRevision) {
|
|
762
|
+
// Revision moved concurrently: merge only the additive usage
|
|
763
|
+
// delta from this session onto the disk record and advance its
|
|
764
|
+
// revision. All other fields stay authoritative from disk.
|
|
765
|
+
const { tokens, seconds } = this.usageDelta(current, freshDisk);
|
|
766
|
+
if (tokens === 0 && seconds === 0) return null;
|
|
767
|
+
const merged = mergeGoalPromptFromDisk(ctx, {
|
|
768
|
+
...freshDisk,
|
|
769
|
+
usage: {
|
|
770
|
+
tokensUsed: freshDisk.usage.tokensUsed + tokens,
|
|
771
|
+
activeSeconds: freshDisk.usage.activeSeconds + seconds,
|
|
772
|
+
},
|
|
773
|
+
updatedAt: nowIso(),
|
|
774
|
+
revision: (freshDisk.revision ?? 0) + 1,
|
|
775
|
+
});
|
|
776
|
+
const written = merged.status === "complete" ? archiveGoalFile(ctx, merged) : writeActiveGoalFile(ctx, merged);
|
|
777
|
+
this.trackBaseline(written.id, written.usage);
|
|
778
|
+
this.ref.setFocused(written);
|
|
779
|
+
return written;
|
|
780
|
+
}
|
|
781
|
+
const merged = mergeGoalPromptFromDisk(ctx, { ...current, updatedAt: nowIso(), revision: capturedRevision + 1 });
|
|
782
|
+
const written = merged.status === "complete" ? archiveGoalFile(ctx, merged) : writeActiveGoalFile(ctx, merged);
|
|
783
|
+
this.trackBaseline(written.id, written.usage);
|
|
784
|
+
this.ref.setFocused(written);
|
|
785
|
+
return written;
|
|
786
|
+
} finally {
|
|
787
|
+
lock.release();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Create a goal: write active file → ledger → memory/focus commit. */
|
|
792
|
+
create(ctx: GoalServiceContext, spec: { goal: GoalRecord; ledger?: GoalLedgerEvent[] }): GoalMutationResult {
|
|
793
|
+
const previousGoalId = this.ref.getFocused()?.id ?? null;
|
|
794
|
+
const written = writeActiveGoalFile(ctx, spec.goal);
|
|
795
|
+
if (spec.ledger && spec.ledger.length > 0) {
|
|
796
|
+
this.appendLedgerEventsBestEffort(ctx, spec.ledger);
|
|
797
|
+
}
|
|
798
|
+
this.ref.setFocused(written);
|
|
799
|
+
this.trackBaseline(written.id, written.usage);
|
|
800
|
+
const focusChanged = previousGoalId !== written.id;
|
|
801
|
+
if (focusChanged) this.ref.onFocusChanged(previousGoalId, written.id);
|
|
802
|
+
return { ok: true, goal: written, previousGoalId, goalId: written.id, focusChanged };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Audit outcomes can bypass the current turn without flushing unrelated work. */
|
|
806
|
+
appendEvents(ctx: GoalServiceContext, events: GoalLedgerEvent[], opts: {immediate?: boolean} = {}): boolean {
|
|
807
|
+
if (this.turn.active && !opts.immediate && this.turnContext && goalStorageRoot(this.turnContext) === goalStorageRoot(ctx)) {
|
|
808
|
+
this.turn.ledger.push(...events);
|
|
809
|
+
return true;
|
|
810
|
+
}
|
|
811
|
+
return this.appendLedgerEventsBestEffort(ctx, events);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Detach an outgoing session's buffer after its final persistence attempt. */
|
|
815
|
+
endSession(ctx?: GoalServiceContext): void {
|
|
816
|
+
try {
|
|
817
|
+
const storage = this.turnContext ?? ctx;
|
|
818
|
+
if (storage) this.flushTurn(storage);
|
|
819
|
+
} finally {
|
|
820
|
+
if (this.turn.active) this.ref.onDiagnostic({severity: "warning", source: "storage", message: this.flushError ?? "Outgoing goal changes could not be persisted before session replacement."});
|
|
821
|
+
this.turn = {active: false, goalId: null, goal: null, archive: false, ledger: []};
|
|
822
|
+
this.turnContext = null;
|
|
823
|
+
this.turnBase = null;
|
|
824
|
+
this.lastPersistedUsage = null;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Diagnostic write for the debug widget toggle (separate debug dir; not a goal mutation). */
|
|
829
|
+
writeDebugFile(ctx: GoalServiceContext, relPath: string, content: string): void {
|
|
830
|
+
const gfc: GoalFileContext = ctx;
|
|
831
|
+
ensureDirectory(gfc, ".pi/goals/debug");
|
|
832
|
+
atomicWriteGoalFile(gfc, ".pi/goals/debug", relPath, content);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Diagnostic removal for the debug widget toggle. */
|
|
836
|
+
removeDebugFile(ctx: GoalServiceContext, relPath: string): void {
|
|
837
|
+
try {
|
|
838
|
+
safeUnlinkGoalFile(ctx, ".pi/goals/debug", relPath);
|
|
839
|
+
} catch {
|
|
840
|
+
// Debug file removal is best-effort.
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|