import { cloneGoal, nowIso, type GoalFocusReason, type GoalRecord, type GoalTask, type GoalUsage } from "./goal-record.ts"; import { appendGoalEvent, appendGoalEvents, type GoalLedgerEvent } from "./goal-ledger.ts"; import { findTaskInTree, updateTaskInTree } from "./goal-policy.ts"; import { GOALS_DIR, archiveGoalFile, atomicWriteGoalFile, ensureDirectory, mergeGoalPromptFromDisk, parseGoalFile, readActiveGoalPool, readActiveGoalPoolView, resolveGoalPath, safeUnlinkGoalFile, sanitizeGoalPaths, writeActiveGoalFile, type GoalFileContext, } from "./storage/goal-files.ts"; import { acquireGoalLock, type GoalLock } from "./storage/goal-lock.ts"; import { mergeFocusedGoalWithDisk } from "./goal-pool.ts"; /** * Session state access + runtime glue hooks that the GoalService needs. * The extension (`extensions/goal.ts`) wires these to its closure state so the * service stays the sole mutation boundary while the runtime effects * (continuation queue, accounting, nudge state, tools, UI) remain in the * extension's event handlers. */ export interface GoalDiagnostic { severity: "warning"; source: "ledger" | "storage"; goalId?: string; eventType?: string; message: string; } export interface GoalServiceRef { getFocused(): GoalRecord | null; /** Mirror of the `state.goal` setter: pool upsert + focus assignment. */ setFocused(goal: GoalRecord | null): void; getPool(): Map; replacePool(pool: Map): void; getFocusedGoalId(): string | null; /** Assigns the session focus; bumps the focus revision when it changes. */ assignFocusedGoalId(goalId: string | null): void; focusToken(goalId: string): { goalId: string; revision: number }; isTokenCurrent(token: { goalId: string; revision: number }): boolean; appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void; /** The focused goal vanished during reconciliation (external clear/archive/delete). */ onFocusedGoalLost(lostGoalId: string | null, ctx: GoalServiceContext): void; /** A reconciled goal is now focused; clear continuation/accounting as its status requires. */ onReconciled(goal: GoalRecord): void; /** The session focus changed; clear continuation/accounting/nudge state. */ onFocusChanged(from: string | null, to: string | null): void; /** Observable diagnostic sink for non-fatal failures (ledger appends). */ onDiagnostic(diagnostic: GoalDiagnostic): void; } export type GoalServiceContext = GoalFileContext; export interface GoalMutationSpec { /** If provided, the focused goal id must equal this or the mutation is rejected. */ expectedGoalId?: string | null; /** If provided, the token must still be current (same goal id + focus revision). */ focusToken?: { goalId: string; revision: number }; /** Skip the leading disk reconciliation (used when the caller already reconciled or must not). */ reconcile?: boolean; /** Merge the authoritative objective body from disk before mutating. */ refreshFromDisk?: boolean; /** Mutate a clone of the focused goal. May ignore its input to produce a fixed record. */ mutate: (goal: GoalRecord) => GoalRecord; /** Ledger events appended best-effort AFTER the authoritative file write. */ ledger?: GoalLedgerEvent[] | ((written: GoalRecord) => GoalLedgerEvent[]); /** Write the archived goal file instead of the active file (complete/clear/abort). */ archive?: boolean; /** Commit the written record as the focused in-memory goal. Default true. */ commitFocused?: boolean; } export interface GoalMutationResult { ok: true; goal: GoalRecord; previousGoalId: string | null; goalId: string | null; focusChanged: boolean; } export interface GoalMutationFailure { ok: false; message: string; } export type GoalMutationOutcome = GoalMutationResult | GoalMutationFailure; /** * GoalService — the extension's sole mutation boundary for goal records. * * Ordered pipeline for every focused-goal mutation (mirrors TECH.md storage * section): * * 1. safe focused record reconciliation from disk; * 2. expected goal id + focus revision check; * 3. mutation on a clone; * 4. active-file write (or archival for complete/clear/abort); * 5. ledger append best effort; * 6. in-memory pool/focus commit; * 7. runtime/UI effects (returned and signalled through the ref hooks). * * If the active-file write fails it throws, so memory/ledger/focus/archive are * never committed. A failed ledger append after the authoritative write keeps * the successful state transition and reports diagnostics via the hook, matching * the current best-effort ledger semantics. */ export interface GoalTaskUpdateSpec { /** If provided, the token must still be current (same goal id + focus revision). */ focusToken?: { goalId: string; revision: number }; /** The task to update, loaded fresh from the disk record. */ taskId: string; /** Optional transition validation against the FRESH task; a failure aborts with a typed message. */ validate?(task: GoalTask): { ok: true } | { ok: false; message: string }; /** Transform the FRESH task. A failure aborts; only this task's path changes. */ update(task: GoalTask): GoalTask | { ok: false; message: string }; /** * §8.1: explicit execution focus — set the persisted currentTaskId to this * task id (start). Replaces any previous current task. Otherwise focus is * cleared automatically when the updated task becomes terminal * (complete/skipped) and was the current task (§8.2/§8.3). */ setCurrentTaskId?: string; /** Ledger events appended best-effort AFTER the authoritative file write. */ ledger?(written: GoalRecord, updatedTask: GoalTask): GoalLedgerEvent[]; } export type GoalTaskUpdateOutcome = { ok: true; goal: GoalRecord; task: GoalTask } | { ok: false; message: string }; /** * §8 execution-focus resolution for a task update: an explicit start sets the * currentTaskId (replacing any previous); otherwise completing or skipping the * current task clears it; otherwise focus is untouched. */ function resolveUpdatedCurrentTaskId(spec: GoalTaskUpdateSpec, current: string | undefined, updatedTask: GoalTask): string | undefined { if (spec.setCurrentTaskId !== undefined) return spec.setCurrentTaskId; const terminal = updatedTask.status === "complete" || updatedTask.status === "skipped"; if (terminal && current === spec.taskId) return undefined; return current; } export class GoalService { private readonly ref: GoalServiceRef; /** * The usage this session last wrote to disk for a goal (or the disk usage * the session's accounting is relative to after load/reconcile). The * baseline for additive usage merging: on a persist revision conflict the * delta since this baseline is merged onto the disk record instead of the * local usage being dropped. */ private lastPersistedUsage: { goalId: string; tokensUsed: number; activeSeconds: number } | null = null; /** * Per-turn transaction buffer (P1-3): task/status/usage mutations and * ledger events accumulate in memory during a turn and flush once — one * lock acquire, one goal-file write, one ledger batch — at turn end or at * an explicit flush boundary (audit dispatch, pause/stop, focus change, * session reload). Reads during the turn overlay the buffered goal so * in-turn mutations are always visible; the optimistic revision check runs * at flush time. */ private flushError: string | null = null; private turnBase: GoalRecord | null = null; private turn: { active: boolean; goalId: string | null; goal: GoalRecord | null; archive: boolean; ledger: GoalLedgerEvent[] } = { active: false, goalId: null, goal: null, archive: false, ledger: [], }; /** Open (or reopen) the per-turn transaction for the focused goal. */ beginTurn(ctx: GoalServiceContext, goalId: string | null): void { if (this.turn.active) { this.flushTurn(ctx); // Lock contention must not discard the previous transaction. Keep it // open so the next turn boundary retries the same buffered write. if (this.turn.active) return; } this.turn = { active: true, goalId, goal: null, archive: false, ledger: [] }; const focused = this.ref.getFocused(); this.turnBase = focused ? {...focused, usage: {...focused.usage}} : null; this.flushError = null; } /** * Flush the buffered transaction: one lock acquire, one (archived or * active) goal-file write with a revision bump, one batched ledger append. * No-op when nothing was buffered. Returns the written record or null. */ flushTurn(ctx: GoalServiceContext): GoalRecord | null { if (!this.turn.active) return null; this.flushError = null; const goal = this.turn.goal; if (!goal) { this.turn.active = false; return null; } let lock: GoalLock; try { lock = acquireGoalLock(ctx, goal.id); } catch { this.flushError = "Goal storage is locked; pending changes have not been persisted."; // Another writer holds the lock; preserve the buffer so a later turn // boundary can retry it instead of silently losing the mutation. return null; } try { const freshDisk = this.readFreshDiskGoal(ctx, goal); const expected = this.turnBase ?? goal; if (!freshDisk || (freshDisk.revision ?? 0) !== (expected.revision ?? 0)) { this.flushError = `Goal ${goal.id} changed in another process; buffered changes were rejected. Refresh and retry.`; // Reject the speculative transaction, never overwrite another writer. this.turn.active = false; this.turn.goal = null; this.turn.ledger = []; if (freshDisk && this.ref.getFocusedGoalId() === goal.id) { const tokens = Math.max(0, goal.usage.tokensUsed - expected.usage.tokensUsed); const seconds = Math.max(0, goal.usage.activeSeconds - expected.usage.activeSeconds); this.ref.setFocused({ ...freshDisk, usage: {tokensUsed: freshDisk.usage.tokensUsed + tokens, activeSeconds: freshDisk.usage.activeSeconds + seconds} }); this.trackBaseline(freshDisk.id, freshDisk.usage); } else if (!freshDisk) { this.ref.getPool().delete(goal.id); if (this.ref.getFocusedGoalId() === goal.id) { this.ref.assignFocusedGoalId(null); this.ref.onFocusedGoalLost(goal.id, ctx); } } this.ref.onDiagnostic({ severity: "warning", source: "storage", goalId: goal.id, message: this.flushError }); return null; } const base = freshDisk; const next = { ...goal, revision: (base.revision ?? 0) + 1 }; const written = this.turn.archive || next.status === "complete" ? archiveGoalFile(ctx, next) : writeActiveGoalFile(ctx, next); this.turn.active = false; this.appendLedgerEventsBestEffort(ctx, this.turn.ledger); this.trackBaseline(written.id, written.usage); if (this.ref.getFocusedGoalId() === written.id) this.ref.setFocused(written); else this.ref.getPool().set(written.id, written); return written; } finally { lock.release(); } } /** Completion must never audit state that failed to flush. */ flushForAudit(ctx: GoalServiceContext): string | null { this.flushTurn(ctx); return this.flushError; } /** End the turn: flush any pending transaction (safe to call always). */ endTurn(ctx: GoalServiceContext): void { this.flushTurn(ctx); } /** True while the transaction buffer is open. */ isTurnBuffered(): boolean { return this.turn.active; } constructor(ref: GoalServiceRef) { this.ref = ref; } /** Record the usage value the focused goal's in-memory accounting builds on. */ private trackBaseline(goalId: string, usage: GoalUsage): void { this.lastPersistedUsage = { goalId, tokensUsed: usage.tokensUsed, activeSeconds: usage.activeSeconds }; } /** * Session-local usage delta since the last recorded baseline (clamped at * zero so usage is never reduced). Falls back to the disk usage when this * session has no baseline for the goal (never persisted/reconciled it). */ private usageDelta(current: GoalRecord, disk: GoalRecord): { tokens: number; seconds: number } { const baseline = this.lastPersistedUsage?.goalId === current.id ? this.lastPersistedUsage : { goalId: current.id, tokensUsed: disk.usage.tokensUsed, activeSeconds: disk.usage.activeSeconds }; return { tokens: Math.max(0, current.usage.tokensUsed - baseline.tokensUsed), seconds: Math.max(0, current.usage.activeSeconds - baseline.activeSeconds), }; } /** * Append ledger events without making them part of the authoritative state * transaction. A batch is the common path; individual retries preserve the * existing best-effort behavior and keep failures observable per event. */ private appendLedgerEventsBestEffort(ctx: GoalServiceContext, events: GoalLedgerEvent[]): void { if (events.length === 0) return; if (events.length > 1 && appendGoalEvents(ctx, events).ok) return; for (const event of events) { const append = appendGoalEvent(ctx, event); if (!append.ok) { this.ref.onDiagnostic({ severity: "warning", source: "ledger", goalId: "goalId" in event ? event.goalId : undefined, eventType: event.type, message: `Ledger append failed for ${event.type}${"goalId" in event ? ` (goal ${event.goalId})` : ""}: ${String(append.error)}`, }); } } } /** Safe focused record reconciliation from disk. */ reconcileFocused(ctx: GoalServiceContext, opts: { preserveMemoryUsage?: boolean } = {}): boolean { const current = this.ref.getFocused(); const source = readActiveGoalPoolView(ctx); const focused = this.ref.getFocusedGoalId(); const view = this.ref.getPool(); const buffered = this.turn.active ? this.turn.goal : null; let unchanged = source.size === view.size && (!focused || source.has(focused)); if (unchanged) for (const [id, goal] of source) { if (view.get(id) !== (buffered?.id === id ? buffered : goal)) { unchanged = false; break; } } if (unchanged) { const same = focused ? view.get(focused) : undefined; if (same) this.ref.onReconciled(same); return true; } const fresh = new Map(source); // P1-3: overlay the buffered goal so in-turn mutations are visible to // every read (reconcile, get_goal, prompts) without a disk round trip. if (this.turn.active && this.turn.goal) fresh.set(this.turn.goal.id, this.turn.goal); const focusedGoalId = this.ref.getFocusedGoalId(); if (!focusedGoalId) { this.ref.replacePool(fresh); return true; } const diskGoal = fresh.get(focusedGoalId) ?? null; if (!diskGoal) { if (current && !current.activePath) { this.ref.replacePool(fresh); fresh.set(current.id, current); this.ref.assignFocusedGoalId(current.id); return true; } const lostGoalId = current?.id ?? null; this.ref.replacePool(fresh); this.ref.assignFocusedGoalId(null); this.ref.onFocusedGoalLost(lostGoalId, ctx); return false; } const reconciled = current && opts.preserveMemoryUsage ? mergeFocusedGoalWithDisk({ memoryGoal: current, diskGoal }) : diskGoal; this.ref.replacePool(fresh); fresh.set(reconciled.id, reconciled); this.ref.assignFocusedGoalId(reconciled.id); this.ref.onReconciled(reconciled); this.trackBaseline(reconciled.id, reconciled.usage); return true; } /** * Read the goal's authoritative active file directly (no complete-status * filter, unlike the pool reader) for the optimistic revision check under * the per-goal lock. Returns null when the file is gone (external * archive/delete) or the goal has no active path. */ private readFreshDiskGoal(ctx: GoalServiceContext, current: GoalRecord): GoalRecord | null { if (!current.activePath) return null; try { return parseGoalFile(resolveGoalPath(ctx, GOALS_DIR, current.activePath)); } catch { return null; } } /** * The single ordered mutation pipeline for a focused goal. * Returns the written record plus focus-change effects; the extension maps * failures to user-facing results. * * Cross-process control (follow-up Stage 4): after the session-local * validation, an exclusive per-goal lock is acquired (bounded, with stale * recovery) and the authoritative file is re-read under the lock. If the * persisted revision differs from the one captured at reconciliation, the * mutation returns a typed conflict carrying the current revision instead * of overwriting blindly. On success the revision is incremented, the file * is written atomically, ledger events are appended best-effort, memory is * committed, and the lock is released in a finally block. */ apply(ctx: GoalServiceContext, spec: GoalMutationSpec): GoalMutationOutcome { // 1. reconcile (unless the caller opts out — e.g. the tweak path, which // must not clobber the authoritative new objective with the old file). if (spec.reconcile !== false && !this.reconcileFocused(ctx)) { return { ok: false, message: "The focused goal was lost during reconciliation; the mutation was not applied." }; } const current = this.ref.getFocused(); if (!current) { return { ok: false, message: "No focused goal to mutate." }; } // 2. expected goal id + focus revision validation. if (spec.expectedGoalId != null && current.id !== spec.expectedGoalId) { return { ok: false, message: `Mutation rejected: expected goal ${spec.expectedGoalId} but the focused goal is ${current.id}.` }; } if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) { return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` }; } // 2b. P1-3: during a turn the mutation is buffered in memory (no lock, no // disk write yet); the flush acquires the lock, writes, and appends // the ledger once per turn. Outside a turn the immediate path below // (lock + optimistic revision check) is unchanged. if (this.turn.active) { if (this.turn.goalId !== null && current.id !== this.turn.goalId) { // Focus changed mid-turn: persist the previous buffer first. this.flushTurn(ctx); if (this.turn.active) return {ok: false, message: this.flushError ?? "Previous goal changes are still awaiting persistence."}; this.turn.active = true; this.turn.goalId = current.id; this.turn.goal = current; this.turnBase = current; this.turn.archive = false; this.turn.ledger = []; } if (!this.turn.goal) { this.turn.goal = current; this.turn.goalId = current.id; if (this.turnBase?.id !== current.id) this.turnBase = {...current, usage: {...current.usage}}; } const base = current; const mutated = sanitizeGoalPaths(ctx, { ...spec.mutate(cloneGoal(base)), revision: (current.revision ?? 0) + 1, }); if (spec.ledger) { try { const events = typeof spec.ledger === "function" ? spec.ledger(mutated) : spec.ledger; this.turn.ledger.push(...events); } catch { // Ledger spec error: nothing appended (same swallow as the write path). } } this.turn.goal = mutated; this.turn.archive = spec.archive === true; const previousGoalId = current.id; const commitFocused = spec.commitFocused !== false; if (commitFocused) { this.ref.setFocused(mutated); this.trackBaseline(mutated.id, mutated.usage); } const goalId = commitFocused ? mutated.id : this.ref.getFocusedGoalId(); const focusChanged = commitFocused && previousGoalId !== mutated.id; if (focusChanged) this.ref.onFocusChanged(previousGoalId, mutated.id); return { ok: true, goal: mutated, previousGoalId, goalId, focusChanged }; } // 2b. exclusive per-goal lock + optimistic revision check (follow-up Stage 4). const capturedRevision = current.revision ?? 0; const lock = acquireGoalLock(ctx, current.id); try { const freshDisk = this.readFreshDiskGoal(ctx, current); if (!freshDisk) { 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.` }; } const diskRevision = freshDisk.revision ?? 0; if (diskRevision !== capturedRevision) { 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.` }; } // 3. mutation on a clone (after an optional authoritative objective merge). const base = spec.refreshFromDisk ? mergeGoalPromptFromDisk(ctx, current) : current; const mutated = { ...spec.mutate(cloneGoal(base)), revision: capturedRevision + 1, }; // 4. authoritative file write (active or archive). A failure here throws // and prevents any memory/ledger/focus/archive commit. const written = spec.archive ? archiveGoalFile(ctx, mutated) : writeActiveGoalFile(ctx, mutated); // 5. ledger append best effort. if (spec.ledger) { let events: GoalLedgerEvent[]; try { events = typeof spec.ledger === "function" ? spec.ledger(written) : spec.ledger; } catch { events = []; } this.appendLedgerEventsBestEffort(ctx, events); } // 6. in-memory pool/focus commit. const previousGoalId = current.id; const commitFocused = spec.commitFocused !== false; if (commitFocused) { this.ref.setFocused(written); this.trackBaseline(written.id, written.usage); } const goalId = commitFocused ? written.id : this.ref.getFocusedGoalId(); const focusChanged = commitFocused && previousGoalId !== written.id; // 7. runtime/UI effects. if (focusChanged) this.ref.onFocusChanged(previousGoalId, written.id); return { ok: true, goal: written, previousGoalId, goalId, focusChanged }; } finally { lock.release(); } } /** * Disk-fresh single-task transaction. Pipeline: * 1. reconcile the focused record; * 2. validate focus token/id; * 3. load the fresh task from the cloned disk record; * 4. validate the requested transition against that fresh task; * 5. update only that task's path; * 6. write the active file, append the ledger, and commit. * Expected races (removed task, removed task list) return typed failures * instead of throwing. */ updateTask(ctx: GoalServiceContext, spec: GoalTaskUpdateSpec): GoalTaskUpdateOutcome { // P1-3: during a turn, apply to the buffered goal in memory (validation // still runs against the current in-turn state); the flush writes once. if (this.turn.active) { const current = this.turn.goal ?? this.ref.getFocused(); if (!current) { return { ok: false, message: "No focused goal to mutate." }; } if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) { return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` }; } if (!current.taskList) { return { ok: false, message: "The goal has no task list." }; } const task = findTaskInTree(current.taskList.tasks, spec.taskId); if (!task) { return { ok: false, message: `Task "${spec.taskId}" not found.` }; } if (spec.validate) { const gate = spec.validate(task); if (!gate.ok) return gate; } const updated = spec.update(task); if (typeof updated === "object" && "ok" in updated && !updated.ok) return updated; const updatedTask = updated as GoalTask; const updatedTasks = updateTaskInTree(current.taskList.tasks, spec.taskId, () => updatedTask); const mutated = sanitizeGoalPaths(ctx, { ...current, taskList: { ...current.taskList, tasks: updatedTasks }, // Execution focus (§8): start sets it explicitly; completing or // skipping the current task clears it. currentTaskId: resolveUpdatedCurrentTaskId(spec, current.currentTaskId, updatedTask), updatedAt: nowIso(), revision: (current.revision ?? 0) + 1, }); if (spec.ledger) { try { this.turn.ledger.push(...spec.ledger(mutated, updatedTask)); } catch (err) { this.ref.onDiagnostic({ severity: "warning", source: "ledger", goalId: spec.taskId, message: `Ledger spec error during task update: ${String(err)}`, }); } } this.turn.goal = mutated; this.ref.setFocused(mutated); return { ok: true, goal: mutated, task: updatedTask }; } return this.updateTaskAttempt(ctx, spec, 1); } /** Ordered all-or-nothing batch. Each validator sees earlier changes in the clone. */ updateTasks(ctx: GoalServiceContext, specs: GoalTaskUpdateSpec[]): GoalMutationOutcome { if (!this.reconcileFocused(ctx)) return {ok: false, message: "No focused goal to mutate."}; const current = this.ref.getFocused(); if (!current?.taskList) return {ok: false, message: "The goal has no task list."}; let lock: GoalLock | undefined; try { let base = current; if (!this.turn.active) { lock = acquireGoalLock(ctx, current.id); const fresh = this.readFreshDiskGoal(ctx, current); if (!fresh || (fresh.revision ?? 0) !== (current.revision ?? 0)) return {ok: false, message: "The goal changed in another process; no updates applied. Refresh and retry."}; if (fresh.status !== "active" || !fresh.taskList) return {ok: false, message: "The persisted goal is no longer active with tasks; no updates applied."}; base = {...fresh, usage: current.usage}; } const next = cloneGoal(base); const locations = new Map(); const indexTasks = (tasks: GoalTask[]): void => { 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); } }; const removeTasks = (tasks: GoalTask[]): void => { for (const task of tasks) { locations.delete(task.id); if (task.subtasks) removeTasks(task.subtasks); } }; indexTasks(next.taskList!.tasks); const events: GoalLedgerEvent[] = []; for (const spec of specs) { if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) return {ok: false, message: "The focused goal changed; no updates applied."}; const location = locations.get(spec.taskId); const task = location?.tasks[location.index]; if (!task) return {ok: false, message: `Task "${spec.taskId}" not found.`}; const valid = spec.validate?.(task); if (valid && !valid.ok) return valid; const updated = spec.update(task); if ("ok" in updated && !updated.ok) return updated; const updatedTask = updated as GoalTask; location!.tasks[location!.index] = updatedTask; if (updatedTask.id !== task.id) { locations.delete(task.id); locations.set(updatedTask.id, location!); } if (updatedTask.subtasks !== task.subtasks) { if (task.subtasks) removeTasks(task.subtasks); if (updatedTask.subtasks) indexTasks(updatedTask.subtasks); } next.currentTaskId = resolveUpdatedCurrentTaskId(spec, next.currentTaskId, updatedTask); next.updatedAt = nowIso(); if (spec.ledger) events.push(...spec.ledger(next, updatedTask)); } if (this.turn.active) return this.apply(ctx, {reconcile: false, expectedGoalId: current.id, focusToken: specs[0]?.focusToken, mutate: () => next, ledger: events}); const written = writeActiveGoalFile(ctx, {...next, revision: (base.revision ?? 0) + 1}); this.appendLedgerEventsBestEffort(ctx, events); this.trackBaseline(written.id, written.usage); this.ref.setFocused(written); return {ok: true, goal: written, previousGoalId: current.id, goalId: written.id, focusChanged: false}; } finally { lock?.release(); } } /** * Disk-fresh single-task transaction (follow-up Stage 4 adds the per-goal * lock + optimistic revision check). Pipeline: * 1. reconcile the focused record; * 2. validate focus token/id; * 3. acquire the per-goal lock and re-read the authoritative file; * 4. a stale writer gets a typed conflict; it retries ONCE with the fresh * state — the transition validation re-checks that the same task and * relevant status/structure are unchanged, so a genuinely concurrent * edit is rejected rather than silently merged; * 5. load the fresh task, validate the transition, update only that task; * 6. write with an incremented revision, append the ledger, and commit. */ private updateTaskAttempt(ctx: GoalServiceContext, spec: GoalTaskUpdateSpec, retriesLeft: number): GoalTaskUpdateOutcome { if (!this.reconcileFocused(ctx)) { return { ok: false, message: "The focused goal was lost during reconciliation; the task was not updated." }; } const current = this.ref.getFocused(); if (!current) { return { ok: false, message: "No focused goal to mutate." }; } if (spec.focusToken && !this.ref.isTokenCurrent(spec.focusToken)) { return { ok: false, message: `Mutation cancelled because goal ${spec.focusToken.goalId} is no longer focused in this session. The shared goal was not modified.` }; } const capturedRevision = current.revision ?? 0; const lock = acquireGoalLock(ctx, current.id); try { const freshDisk = this.readFreshDiskGoal(ctx, current); if (!freshDisk) { 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.` }; } const diskRevision = freshDisk.revision ?? 0; if (diskRevision !== capturedRevision) { if (retriesLeft > 0) { // Retry once against the fresh state; the transition validation // below is the guard for task status/structure changes. return this.updateTaskAttempt(ctx, spec, retriesLeft - 1); } return { ok: false, message: `Goal ${current.id} was modified by another process (revision ${capturedRevision} -> ${diskRevision}); current revision is ${diskRevision}. The task was not updated.` }; } const base = mergeGoalPromptFromDisk(ctx, current); if (!base.taskList) { return { ok: false, message: "The goal has no task list." }; } const task = findTaskInTree(base.taskList.tasks, spec.taskId); if (!task) { return { ok: false, message: `Task "${spec.taskId}" not found.` }; } if (spec.validate) { const gate = spec.validate(task); if (!gate.ok) return gate; } const updated = spec.update(task); if (typeof updated === "object" && "ok" in updated && !updated.ok) return updated; const updatedTask = updated as GoalTask; const updatedTasks = updateTaskInTree(base.taskList.tasks, spec.taskId, () => updatedTask); const mutated = { ...base, taskList: { ...base.taskList, tasks: updatedTasks }, currentTaskId: resolveUpdatedCurrentTaskId(spec, base.currentTaskId, updatedTask), updatedAt: nowIso(), revision: capturedRevision + 1, }; const written = writeActiveGoalFile(ctx, mutated); if (spec.ledger) { try { this.appendLedgerEventsBestEffort(ctx, spec.ledger(written, updatedTask)); } catch (err) { // Unexpected ledger-spec error after the authoritative write keeps // the successful state transition. this.ref.onDiagnostic({ severity: "warning", source: "ledger", goalId: spec.taskId, message: `Ledger spec error during task update: ${String(err)}`, }); } } this.ref.setFocused(written); return { ok: true, goal: written, task: updatedTask }; } finally { lock.release(); } } /** * Persist the focused goal: bump updatedAt, merge objective from disk, write * active or archive. Serialized by the per-goal lock with a short bounded * budget. If another process bumped the revision meanwhile, the persist * does not clobber the disk's authoritative fields — instead the session's * additive usage/accounting delta since the last baseline is merged onto * the disk record (concurrent work is never silently dropped). */ persist(ctx: GoalServiceContext): GoalRecord | null { const current = this.ref.getFocused(); if (!current) return null; // P1-3: within a turn the persist is buffered; the flush at turn end // performs the single write + ledger batch. if (this.turn.active) { const next = { ...current, updatedAt: nowIso() }; this.turn.goal = next; this.ref.setFocused(next); return next; } const capturedRevision = current.revision ?? 0; let lock: GoalLock; try { lock = acquireGoalLock(ctx, current.id, { attempts: 4, retryMs: 25 }); // P1-5: 100ms bound } catch { // Another writer holds the goal lock; skip this persist tick. return null; } try { const freshDisk = this.readFreshDiskGoal(ctx, current); if (!freshDisk) return null; if ((freshDisk.revision ?? 0) !== capturedRevision) { // Revision moved concurrently: merge only the additive usage // delta from this session onto the disk record and advance its // revision. All other fields stay authoritative from disk. const { tokens, seconds } = this.usageDelta(current, freshDisk); if (tokens === 0 && seconds === 0) return null; const merged = mergeGoalPromptFromDisk(ctx, { ...freshDisk, usage: { tokensUsed: freshDisk.usage.tokensUsed + tokens, activeSeconds: freshDisk.usage.activeSeconds + seconds, }, updatedAt: nowIso(), revision: (freshDisk.revision ?? 0) + 1, }); const written = merged.status === "complete" ? archiveGoalFile(ctx, merged) : writeActiveGoalFile(ctx, merged); this.trackBaseline(written.id, written.usage); this.ref.setFocused(written); return written; } const merged = mergeGoalPromptFromDisk(ctx, { ...current, updatedAt: nowIso(), revision: capturedRevision + 1 }); const written = merged.status === "complete" ? archiveGoalFile(ctx, merged) : writeActiveGoalFile(ctx, merged); this.trackBaseline(written.id, written.usage); this.ref.setFocused(written); return written; } finally { lock.release(); } } /** Create a goal: write active file → ledger → memory/focus commit. */ create(ctx: GoalServiceContext, spec: { goal: GoalRecord; ledger?: GoalLedgerEvent[] }): GoalMutationResult { const previousGoalId = this.ref.getFocused()?.id ?? null; const written = writeActiveGoalFile(ctx, spec.goal); if (spec.ledger && spec.ledger.length > 0) { this.appendLedgerEventsBestEffort(ctx, spec.ledger); } this.ref.setFocused(written); this.trackBaseline(written.id, written.usage); const focusChanged = previousGoalId !== written.id; if (focusChanged) this.ref.onFocusChanged(previousGoalId, written.id); return { ok: true, goal: written, previousGoalId, goalId: written.id, focusChanged }; } /** Append ledger events best-effort (audit flow / focus changes happen mid-turn, outside apply). */ appendEvents(ctx: GoalServiceContext, events: GoalLedgerEvent[]): void { // P1-3: within a turn the events join the transaction's ledger batch. if (this.turn.active) { this.turn.ledger.push(...events); return; } this.appendLedgerEventsBestEffort(ctx, events); } /** Diagnostic write for the debug widget toggle (separate debug dir; not a goal mutation). */ writeDebugFile(ctx: GoalServiceContext, relPath: string, content: string): void { const gfc: GoalFileContext = { cwd: ctx.cwd }; ensureDirectory(gfc, ".pi/goals/debug"); atomicWriteGoalFile(gfc, ".pi/goals/debug", relPath, content); } /** Diagnostic removal for the debug widget toggle. */ removeDebugFile(ctx: GoalServiceContext, relPath: string): void { try { safeUnlinkGoalFile({ cwd: ctx.cwd }, ".pi/goals/debug", relPath); } catch { // Debug file removal is best-effort. } } }