pi-goal-list-loop-audit 0.28.5 → 0.28.7
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
|
|
@@ -839,7 +897,12 @@ export interface EffectiveAggressiveSettings {
|
|
|
839
897
|
stuckMaxInterventions: number;
|
|
840
898
|
/** 0 = wedge alerts off. */
|
|
841
899
|
wedgeAlertMinutes: number;
|
|
842
|
-
|
|
900
|
+
/** Tri-state: true = always auto-resume; false = never; undefined =
|
|
901
|
+
* DEFAULT (hold on human session loads, resume on reload/fork).
|
|
902
|
+
* v0.28.7: must stay tri-state here — coercing unset→false broke the
|
|
903
|
+
* restore gate's default branch (the 0.28.3 regression the behavioral
|
|
904
|
+
* harness caught). */
|
|
905
|
+
autoResume: boolean | undefined;
|
|
843
906
|
aggressiveMode: boolean;
|
|
844
907
|
}
|
|
845
908
|
|
|
@@ -860,7 +923,7 @@ export function resolveEffectiveAggressiveSettings(s: {
|
|
|
860
923
|
stuckMaxInterventions:
|
|
861
924
|
s.stuckMaxInterventions ?? (aggressiveMode ? AGGRESSIVE_STUCK_MAX_INTERVENTIONS : BASE_STUCK_MAX_INTERVENTIONS),
|
|
862
925
|
wedgeAlertMinutes: s.wedgeAlertMinutes ?? (aggressiveMode ? 0 : 30),
|
|
863
|
-
autoResume: s.autoResume ?? aggressiveMode,
|
|
926
|
+
autoResume: s.autoResume ?? (aggressiveMode ? true : undefined),
|
|
864
927
|
};
|
|
865
928
|
}
|
|
866
929
|
|
|
@@ -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;
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -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,
|
|
@@ -221,6 +224,13 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
|
|
|
221
224
|
notifyExternal(ctx, `glla: extension api stale — restart pi. (${where})`);
|
|
222
225
|
}
|
|
223
226
|
|
|
227
|
+
/** TEST-ONLY hook (tests/harness): the stale flag is process-terminal in
|
|
228
|
+
* production — only a pi restart clears it — so behavioral tests reset it
|
|
229
|
+
* between stale scenarios. Never called by production code. */
|
|
230
|
+
export function __testOnlyResetStaleFlag(): void {
|
|
231
|
+
extensionApiStale = false;
|
|
232
|
+
}
|
|
233
|
+
|
|
224
234
|
/** v0.28.1 (S3): side-effect-free staleness probe — getSessionName()
|
|
225
235
|
* routes through pi's assertActive() and throws the stale signature iff
|
|
226
236
|
* pi invalidated this factory handle (session replacement). A positive
|
|
@@ -740,9 +750,29 @@ function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "
|
|
|
740
750
|
|
|
741
751
|
function persistState(ctx: ExtensionContext): void {
|
|
742
752
|
appendLedger(ctx.cwd, "state", { goal: state.goal, list: state.list ?? [], loop: state.loop ?? null });
|
|
753
|
+
notifyPersistenceState(ctx); // v0.28.6 (E1): loud on the first failure, all-clear on recovery
|
|
743
754
|
refreshUI(ctx); // every state transition flows through here → the TUI is always current
|
|
744
755
|
}
|
|
745
756
|
|
|
757
|
+
// v0.28.6 (E1): persistence-degradation notify — once per failure streak,
|
|
758
|
+
// once per recovery. The TUI flag (buildWidgetLines) carries the standing
|
|
759
|
+
// state; these notifies are the LOUD part.
|
|
760
|
+
let persistenceDegradedNotified = false;
|
|
761
|
+
function notifyPersistenceState(ctx: ExtensionContext): void {
|
|
762
|
+
if (isPersistenceDegraded() && !persistenceDegradedNotified) {
|
|
763
|
+
persistenceDegradedNotified = true;
|
|
764
|
+
const err = lastPersistenceFailure();
|
|
765
|
+
ctx.ui.notify(
|
|
766
|
+
`⚠ 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.`,
|
|
767
|
+
"warning",
|
|
768
|
+
);
|
|
769
|
+
notifyExternal(ctx, "pi-goal-list-loop-audit: persistence degraded — .pi-glla writes failing.");
|
|
770
|
+
} else if (!isPersistenceDegraded() && persistenceDegradedNotified) {
|
|
771
|
+
persistenceDegradedNotified = false;
|
|
772
|
+
ctx.ui.notify("Persistence recovered — .pi-glla writes are landing again.", "info");
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
746
776
|
function setGoal(goal: Goal, ctx: ExtensionContext): void {
|
|
747
777
|
state = { goal, list: state.list ?? [] }; // preserve the list!
|
|
748
778
|
const file = writeGoalMd(ctx.cwd, goal);
|
|
@@ -765,9 +795,16 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
|
|
|
765
795
|
ensureDirs(ctx.cwd);
|
|
766
796
|
const target = archivedGoalPath(ctx.cwd, goal.id);
|
|
767
797
|
const md = renderGoalMarkdown({ ...goal, status, stopReason });
|
|
768
|
-
|
|
769
|
-
//
|
|
770
|
-
|
|
798
|
+
// v0.28.6 (E1): guarded — and the active md is only removed when the
|
|
799
|
+
// archive actually LANDED (degraded mode must not destroy the only copy).
|
|
800
|
+
const archived = runPersistStep("archiveCurrentGoal", () => {
|
|
801
|
+
ensureDirs(ctx.cwd);
|
|
802
|
+
fs.writeFileSync(target, md);
|
|
803
|
+
return true;
|
|
804
|
+
}) === true;
|
|
805
|
+
if (archived) {
|
|
806
|
+
try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
|
|
807
|
+
}
|
|
771
808
|
state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
|
|
772
809
|
appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
|
|
773
810
|
persistState(ctx);
|
|
@@ -2493,11 +2530,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2493
2530
|
pi.registerTool(defineTool({
|
|
2494
2531
|
name: "complete_task",
|
|
2495
2532
|
label: "Complete task",
|
|
2496
|
-
description: "Mark a task in the active goal's task list as complete (does not stop the turn).",
|
|
2497
|
-
parameters: Type.Object({
|
|
2533
|
+
description: "Mark a task in the active goal's task list as complete (does not stop the turn).", parameters: Type.Object({
|
|
2498
2534
|
id: Type.String({ description: "Task id to complete" }),
|
|
2499
2535
|
}),
|
|
2500
|
-
async execute(_id, params) {
|
|
2536
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
2537
|
+
const foreign7 = foreignToolGuard(execCtx);
|
|
2538
|
+
if (foreign7) return { content: [{ type: "text", text: foreign7 }], details: {} };
|
|
2501
2539
|
const p = params as { id: string };
|
|
2502
2540
|
if (!state.goal || !state.goal.taskList) {
|
|
2503
2541
|
return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
|
|
@@ -2525,7 +2563,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2525
2563
|
id: Type.String(),
|
|
2526
2564
|
status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("complete")]),
|
|
2527
2565
|
}),
|
|
2528
|
-
async execute(_id, params) {
|
|
2566
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
2567
|
+
const foreign8 = foreignToolGuard(execCtx);
|
|
2568
|
+
if (foreign8) return { content: [{ type: "text", text: foreign8 }], details: {} };
|
|
2529
2569
|
const p = params as { id: string; status: "pending" | "in_progress" | "complete" };
|
|
2530
2570
|
if (!state.goal || !state.goal.taskList) {
|
|
2531
2571
|
return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
|
|
@@ -2982,6 +3022,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
2982
3022
|
})),
|
|
2983
3023
|
}),
|
|
2984
3024
|
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
3025
|
+
const foreign9 = foreignToolGuard(execCtx);
|
|
3026
|
+
if (foreign9) return { content: [{ type: "text", text: foreign9 }], details: {} };
|
|
2985
3027
|
if (!state.goal || state.goal.status !== "active") {
|
|
2986
3028
|
return { content: [{ type: "text", text: "No active goal to break down." }], details: {} };
|
|
2987
3029
|
}
|
|
@@ -3149,7 +3191,9 @@ async function promptSettingsMenu(
|
|
|
3149
3191
|
* Same handlers as v0.27.0's if/else chain — only the trigger changed from
|
|
3150
3192
|
* `startsWith(label)` strings to stable ids.
|
|
3151
3193
|
*/
|
|
3152
|
-
|
|
3194
|
+
// v0.28.7 (T4): exported for the behavioral settings-editor tests
|
|
3195
|
+
// (tests/settings-editors.test.ts drives each editor class end-to-end).
|
|
3196
|
+
export async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
|
|
3153
3197
|
switch (id) {
|
|
3154
3198
|
case "autoResume": {
|
|
3155
3199
|
const v = await ctx.ui.select("Auto-resume goals/loops on session start", [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.7",
|
|
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",
|