pi-goal-list-loop-audit 0.28.5 → 0.28.6

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.
@@ -498,10 +498,60 @@ export function ensureDirs(cwd: string): void {
498
498
  fs.mkdirSync(archiveDir(cwd), { recursive: true });
499
499
  }
500
500
 
501
+ // =================================================================
502
+ // Persistence degradation (v0.28.6, audit E1)
503
+ // =================================================================
504
+ // A disk failure (ENOSPC, EACCES, a wedged mount) used to THROW out of
505
+ // appendLedger/writeGoalMd mid-handler — killing the orchestrator turn and
506
+ // silently diverging RAM from disk. Now every persistence step runs
507
+ // through runPersistStep: failures are caught, the session-wide degraded
508
+ // flag latches (the TUI shows it; the first failure notifies loudly), RAM
509
+ // state stays authoritative, and the next SUCCESSFUL step auto-clears the
510
+ // flag (self-healing — the "dirty" marker that write-then-mutate ordering
511
+ // cannot otherwise provide).
512
+
513
+ export interface PersistenceFailure {
514
+ what: string;
515
+ error: string;
516
+ at: string;
517
+ }
518
+
519
+ let persistenceDegraded = false;
520
+ let lastFailure: PersistenceFailure | null = null;
521
+
522
+ export function isPersistenceDegraded(): boolean {
523
+ return persistenceDegraded;
524
+ }
525
+
526
+ export function lastPersistenceFailure(): PersistenceFailure | null {
527
+ return lastFailure;
528
+ }
529
+
530
+ /** Run one persistence step. On failure: latch the degraded flag, remember
531
+ * the error, return undefined (NEVER throw into an orchestrator handler).
532
+ * On success: clear the flag — a landing write means the disk is back. */
533
+ export function runPersistStep<T>(what: string, fn: () => T): T | undefined {
534
+ try {
535
+ const out = fn();
536
+ if (persistenceDegraded) {
537
+ persistenceDegraded = false;
538
+ lastFailure = null;
539
+ }
540
+ return out;
541
+ } catch (err) {
542
+ persistenceDegraded = true;
543
+ lastFailure = { what, error: err instanceof Error ? err.message : String(err), at: new Date().toISOString() };
544
+ return undefined;
545
+ }
546
+ }
547
+
501
548
  export function readState(cwd: string): State {
502
549
  const file = ledgerPath(cwd);
503
- if (!fs.existsSync(file)) return { ...DEFAULT_STATE };
504
- const lines = fs.readFileSync(file, "utf-8").split("\n").filter(Boolean);
550
+ // v0.28.6 (E1): an unreadable ledger (EACCES, EIO) degrades loudly
551
+ // instead of throwing out of session_start.
552
+ const raw = runPersistStep("readState", () => (fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : ""));
553
+ if (raw === undefined || raw === "") return { ...DEFAULT_STATE };
554
+ const lines = raw.split("\n").filter(Boolean);
505
555
  if (lines.length === 0) return { ...DEFAULT_STATE };
506
556
  let parsed: Partial<State> = {};
507
557
  for (const line of lines) {
@@ -509,7 +559,8 @@ export function readState(cwd: string): State {
509
559
  const evt = JSON.parse(line);
510
560
  if (evt.type === "state") parsed = { ...parsed, ...evt.value };
511
561
  } catch {
512
- // skip malformed lines
562
+ // skip malformed lines — a truncated trailing line (mid-write kill)
563
+ // must not lose the rest of the state
513
564
  }
514
565
  }
515
566
  return {
@@ -520,16 +571,23 @@ export function readState(cwd: string): State {
520
571
  }
521
572
 
522
573
  export function appendLedger(cwd: string, type: string, value: unknown): void {
523
- ensureDirs(cwd);
524
- const line = JSON.stringify({ type, value, at: new Date().toISOString() });
525
- fs.appendFileSync(ledgerPath(cwd), line + "\n");
574
+ // v0.28.6 (E1): guarded — a disk failure degrades loudly, never throws
575
+ // into an orchestrator handler.
576
+ runPersistStep("appendLedger", () => {
577
+ ensureDirs(cwd);
578
+ const line = JSON.stringify({ type, value, at: new Date().toISOString() });
579
+ fs.appendFileSync(ledgerPath(cwd), line + "\n");
580
+ });
526
581
  }
527
582
 
528
583
  export function writeGoalMd(cwd: string, goal: Goal): string {
529
- ensureDirs(cwd);
530
584
  const file = goalMdPath(cwd, goal.id);
531
- const md = renderGoalMarkdown(goal);
532
- fs.writeFileSync(file, md);
585
+ runPersistStep("writeGoalMd", () => {
586
+ ensureDirs(cwd);
587
+ fs.writeFileSync(file, renderGoalMarkdown(goal));
588
+ });
589
+ // Return the intended path even on failure so activePath stays sane —
590
+ // the degraded flag carries the truth that the write did not land.
533
591
  return file;
534
592
  }
535
593
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Goal, State } from "./goal-loop-core.js";
14
+ import { isPersistenceDegraded, lastPersistenceFailure } from "./goal-loop-core.js";
14
15
  import type { LoopState } from "./goal-loop-forever.js";
15
16
 
16
17
  // ---- formatters ----
@@ -185,6 +186,17 @@ function countTotal(g: Goal): number {
185
186
  * Returns undefined when nothing is worth showing.
186
187
  */
187
188
  export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
189
+ const inner = buildWidgetLinesInner(state, audit, now, theme, width, extras);
190
+ // v0.28.6 (E1): a persistence failure outranks everything — first line,
191
+ // on every render, until a write lands again.
192
+ if (inner && isPersistenceDegraded()) {
193
+ const err = lastPersistenceFailure();
194
+ return [paint(theme, "error", `⚠ persistence degraded — .pi-glla writes failing (${truncate(err?.error ?? "disk error", 40)}); state in RAM`), ...inner];
195
+ }
196
+ return inner;
197
+ }
198
+
199
+ function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
188
200
  if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
189
201
  const g = state.goal;
190
202
  if (!g) return undefined;
@@ -85,6 +85,9 @@ import {
85
85
  statusLabel,
86
86
  writeGoalMd,
87
87
  missingGllaTools,
88
+ runPersistStep,
89
+ isPersistenceDegraded,
90
+ lastPersistenceFailure,
88
91
  } from "../goal-loop-core.js";
89
92
  import {
90
93
  LENGTH_CONTINUE_MAX,
@@ -740,9 +743,29 @@ function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "
740
743
 
741
744
  function persistState(ctx: ExtensionContext): void {
742
745
  appendLedger(ctx.cwd, "state", { goal: state.goal, list: state.list ?? [], loop: state.loop ?? null });
746
+ notifyPersistenceState(ctx); // v0.28.6 (E1): loud on the first failure, all-clear on recovery
743
747
  refreshUI(ctx); // every state transition flows through here → the TUI is always current
744
748
  }
745
749
 
750
+ // v0.28.6 (E1): persistence-degradation notify — once per failure streak,
751
+ // once per recovery. The TUI flag (buildWidgetLines) carries the standing
752
+ // state; these notifies are the LOUD part.
753
+ let persistenceDegradedNotified = false;
754
+ function notifyPersistenceState(ctx: ExtensionContext): void {
755
+ if (isPersistenceDegraded() && !persistenceDegradedNotified) {
756
+ persistenceDegradedNotified = true;
757
+ const err = lastPersistenceFailure();
758
+ ctx.ui.notify(
759
+ `⚠ Persistence degraded: ${err?.what ?? "disk write"} failed (${(err?.error ?? "unknown").slice(0, 80)}). State lives in RAM and re-syncs on the next successful write — .pi-glla may be missing recent entries. Fix the disk (space/permissions) and it self-heals.`,
760
+ "warning",
761
+ );
762
+ notifyExternal(ctx, "pi-goal-list-loop-audit: persistence degraded — .pi-glla writes failing.");
763
+ } else if (!isPersistenceDegraded() && persistenceDegradedNotified) {
764
+ persistenceDegradedNotified = false;
765
+ ctx.ui.notify("Persistence recovered — .pi-glla writes are landing again.", "info");
766
+ }
767
+ }
768
+
746
769
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
747
770
  state = { goal, list: state.list ?? [] }; // preserve the list!
748
771
  const file = writeGoalMd(ctx.cwd, goal);
@@ -765,9 +788,16 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
765
788
  ensureDirs(ctx.cwd);
766
789
  const target = archivedGoalPath(ctx.cwd, goal.id);
767
790
  const md = renderGoalMarkdown({ ...goal, status, stopReason });
768
- fs.writeFileSync(target, md);
769
- // Remove active md file
770
- try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
791
+ // v0.28.6 (E1): guarded — and the active md is only removed when the
792
+ // archive actually LANDED (degraded mode must not destroy the only copy).
793
+ const archived = runPersistStep("archiveCurrentGoal", () => {
794
+ ensureDirs(ctx.cwd);
795
+ fs.writeFileSync(target, md);
796
+ return true;
797
+ }) === true;
798
+ if (archived) {
799
+ try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
800
+ }
771
801
  state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
772
802
  appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
773
803
  persistState(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.5",
3
+ "version": "0.28.6",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",