pi-goal-list-loop-audit 0.29.22 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/loops/goal.ts +123 -13
- package/package.json +1 -1
package/extensions/loops/goal.ts
CHANGED
|
@@ -225,6 +225,72 @@ let extensionApiStale = false;
|
|
|
225
225
|
* here stranded goals until manual /goal resume (hegemon/sraaal shape).
|
|
226
226
|
* sendContinuation's extensionApiStale guard already stops further sends
|
|
227
227
|
* in this doomed process; the next fresh session auto-resumes. */
|
|
228
|
+
/** v0.30.0: rebind-first session-replacement survival. pi's sanctioned
|
|
229
|
+
* pattern (docs/extensions.md lifecycle + the stale error text itself):
|
|
230
|
+
* session_shutdown → cleanup, session_start → re-establish with the NEW
|
|
231
|
+
* ctx. glla used to treat every stale handle as terminal ("run /reload"),
|
|
232
|
+
* but three replacement shapes need three responses:
|
|
233
|
+
* (a) switch (resume/new/fork): pi rebinds THIS module to the new
|
|
234
|
+
* session — session_start delivers a fresh ctx. No user action, no
|
|
235
|
+
* warning; reset the stale flag via a re-probe and continue.
|
|
236
|
+
* (b) /reload: pi re-imports the extension modules — a SUCCESSOR
|
|
237
|
+
* instance owns this cwd in the same process. The old module stands
|
|
238
|
+
* down silently (owner-file check) instead of screaming + injecting
|
|
239
|
+
* /reload (v0.29.22's injection is right for orphans, wrong here).
|
|
240
|
+
* (c) orphan: the session died with NO replacement (hegemon 2026-07-31:
|
|
241
|
+
* handle dead ~06:03, zero ledger events for 5h). Only a rebuild
|
|
242
|
+
* revives extension function — goStaleTerminal's warning + self-heal
|
|
243
|
+
* stays for this case ONLY.
|
|
244
|
+
* session_shutdown is now ledgered with pi's reason, so the next
|
|
245
|
+
* unexplained disposal is attributable from the ledger alone. */
|
|
246
|
+
const SESSION_REBIND_GRACE_MS = 60_000;
|
|
247
|
+
let sessionReplacementUntil = 0;
|
|
248
|
+
const instanceStartedAt = Date.now();
|
|
249
|
+
const instanceId = `${process.pid}:${instanceStartedAt}`;
|
|
250
|
+
let zombieStoodDown = false;
|
|
251
|
+
|
|
252
|
+
function ownerFilePath(cwd: string): string {
|
|
253
|
+
return path.join(cwd, ".pi-glla", "owner.json");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function writeOwnerFile(cwd: string): void {
|
|
257
|
+
try {
|
|
258
|
+
fs.mkdirSync(path.join(cwd, ".pi-glla"), { recursive: true });
|
|
259
|
+
fs.writeFileSync(ownerFilePath(cwd), JSON.stringify({ instanceId, pid: process.pid, at: Date.now() }));
|
|
260
|
+
} catch {
|
|
261
|
+
/* owner file is advisory — never block activation on it */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readOwnerFile(cwd: string): { instanceId?: string; pid?: number; at?: number } | null {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(fs.readFileSync(ownerFilePath(cwd), "utf8")) as { instanceId?: string; pid?: number; at?: number };
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A stale probe is terminal only for ORPHANS. Returns true when the
|
|
274
|
+
* stale sighting was absorbed (a rebind window is open, or a successor
|
|
275
|
+
* instance owns this cwd and we stand down silently), false when the
|
|
276
|
+
* caller should go terminal (orphan — no replacement came). */
|
|
277
|
+
function absorbStaleIfSuperseded(ctx: ExtensionContext): boolean {
|
|
278
|
+
if (Date.now() < sessionReplacementUntil) {
|
|
279
|
+
appendLedger(ctx.cwd, "stale_awaiting_rebind", {});
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
const owner = readOwnerFile(ctx.cwd);
|
|
283
|
+
if (owner && owner.pid === process.pid && typeof owner.instanceId === "string" && owner.instanceId !== instanceId) {
|
|
284
|
+
appendLedger(ctx.cwd, "zombie_stood_down", { owner: owner.instanceId });
|
|
285
|
+
zombieStoodDown = true;
|
|
286
|
+
extensionApiStale = true; // silence the send paths WITHOUT the terminal theatre
|
|
287
|
+
clearLoopTimer();
|
|
288
|
+
if (continuationTimer) { clearTimeout(continuationTimer); continuationTimer = null; }
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
228
294
|
function goStaleTerminal(ctx: ExtensionContext, where: string): void {
|
|
229
295
|
if (extensionApiStale) return; // already terminal — don't re-spam
|
|
230
296
|
extensionApiStale = true;
|
|
@@ -325,6 +391,13 @@ function probeExtensionApiStale(): boolean {
|
|
|
325
391
|
* and must NOT claim work started (S3's "created — starting now" lie). */
|
|
326
392
|
function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
|
|
327
393
|
if (!probeExtensionApiStale()) return false;
|
|
394
|
+
// v0.30.0: a successor may already own this session (e.g. /reload
|
|
395
|
+
// re-imported the modules) — the user's command belongs to the fresh
|
|
396
|
+
// instance; say so softly instead of demanding a reload.
|
|
397
|
+
if (absorbStaleIfSuperseded(ctx)) {
|
|
398
|
+
ctx.ui.notify(`glla: a refreshed instance owns this session — ${what} is handled there; nothing to do.`, "info");
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
328
401
|
appendLedger(ctx.cwd, "extension_api_stale", { where: `entry probe (${what})` });
|
|
329
402
|
ctx.ui.notify(
|
|
330
403
|
`glla: this session's extension handle is stale (pi session replacement) — ${what} can't send continuations in this process. State is safe in .pi-glla/ — run /reload (extensions rebuild in place), then /glla resume. Restart pi only if /reload fails.`,
|
|
@@ -633,9 +706,9 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
|
|
|
633
706
|
appendLedger(ctx.cwd, "send_rearm_escalated", { kind, afterMinutes: mins, silentMinutes: silent });
|
|
634
707
|
if (kind === "loop" && isLoopActive()) {
|
|
635
708
|
clearLoopTimer();
|
|
636
|
-
state.loop = { ...state.loop!, active: false, stopReason: `send-retry storm: ${mins}m of re-arms with no session activity for ${silent}m — the session is wedged.
|
|
709
|
+
state.loop = { ...state.loop!, active: false, stopReason: `send-retry storm: ${mins}m of re-arms with no session activity for ${silent}m — the session is wedged. Press Escape to cancel the stuck run (pi's own rate-limit retry holds it; pi prints "escape to cancel"), then /loop resume — the loop holds on restore. If still wedged: /reload rebuilds extensions in place, then /loop resume again. Restart pi only if /reload itself fails.` };
|
|
637
710
|
persistState(ctx);
|
|
638
|
-
ctx.ui.notify(`Loop stopped: send-retry storm (${mins}m, session silent ${silent}m).
|
|
711
|
+
ctx.ui.notify(`Loop stopped: send-retry storm (${mins}m, session silent ${silent}m). Escape cancels the stuck run, then /loop resume (the loop holds on restore). /reload if it persists; restart pi only if /reload fails.`, "warning");
|
|
639
712
|
notifyExternal(ctx, "Loop stopped: send-retry storm.");
|
|
640
713
|
return;
|
|
641
714
|
}
|
|
@@ -651,7 +724,7 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
|
|
|
651
724
|
// + junk-runner: "complete ending in a pause retry storm"). The audit
|
|
652
725
|
// lifecycle owns its own pauses (quota etc.).
|
|
653
726
|
appendLedger(ctx.cwd, "send_rearm_escalated_suppressed", { reason: "audit-lifecycle" });
|
|
654
|
-
ctx.ui.notify("Send-retry storm during the completion audit — NOT pausing; the auditor's silence is expected. If pi is wedged,
|
|
727
|
+
ctx.ui.notify("Send-retry storm during the completion audit — NOT pausing; the auditor's silence is expected. If pi is wedged, Escape cancels the stuck run; the stored claim survives.", "info");
|
|
655
728
|
return;
|
|
656
729
|
}
|
|
657
730
|
if (state.goal && state.goal.status === "active") {
|
|
@@ -659,9 +732,9 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
|
|
|
659
732
|
status: "paused",
|
|
660
733
|
pauseKind: "error",
|
|
661
734
|
pauseReason: `send-retry storm: ${mins}m of re-arms with no session activity for ${silent}m — the session never went idle for the continuation`,
|
|
662
|
-
pauseSuggestedAction: "The session produced no events while the send retried (wedged queue).
|
|
735
|
+
pauseSuggestedAction: "The session produced no events while the send retried (wedged queue — often pi's own rate-limit retry holding the run; pi prints 'escape to cancel'). Press Escape, then /goal resume. If still wedged: /reload rebuilds extensions in place, then /goal resume again. Restart pi only if /reload fails.",
|
|
663
736
|
}, ctx);
|
|
664
|
-
ctx.ui.notify(`${goalNoun()} paused: send-retry storm (${mins}m, session silent ${silent}m).
|
|
737
|
+
ctx.ui.notify(`${goalNoun()} paused: send-retry storm (${mins}m, session silent ${silent}m). Escape cancels the stuck run, then /goal resume. /reload if it persists; restart pi only if /reload fails.`, "warning");
|
|
665
738
|
notifyExternal(ctx, `${goalNoun()} paused: send-retry storm.`);
|
|
666
739
|
}
|
|
667
740
|
}
|
|
@@ -672,9 +745,9 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
|
|
|
672
745
|
appendLedger(ctx.cwd, "stall_escalated", { threshold, kind: isLoopActive() ? "loop" : "goal" });
|
|
673
746
|
if (isLoopActive()) {
|
|
674
747
|
clearLoopTimer();
|
|
675
|
-
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${threshold} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API).
|
|
748
|
+
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${threshold} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Press Escape to cancel any stuck run, then /loop resume — the loop holds on restore. If the handle is stale, /reload rebuilds extensions in place; restart pi only if /reload fails.` };
|
|
676
749
|
persistState(ctx);
|
|
677
|
-
ctx.ui.notify(`Loop stopped: ${threshold} refires produced no turn — the continuation is not landing.
|
|
750
|
+
ctx.ui.notify(`Loop stopped: ${threshold} refires produced no turn — the continuation is not landing. Escape cancels a stuck run, then /loop resume (the loop holds on restore). /reload if stale; restart pi only if /reload fails.`, "warning");
|
|
678
751
|
notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
|
|
679
752
|
return true;
|
|
680
753
|
}
|
|
@@ -683,9 +756,9 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
|
|
|
683
756
|
status: "paused",
|
|
684
757
|
pauseKind: "error",
|
|
685
758
|
pauseReason: `stalled: ${threshold} continuation refires landed no turn`,
|
|
686
|
-
pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API).
|
|
759
|
+
pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Press Escape to cancel any stuck run, then /goal resume. If the handle is stale, /reload rebuilds extensions in place — restart pi only if /reload fails.",
|
|
687
760
|
}, ctx);
|
|
688
|
-
ctx.ui.notify(`${goalNoun()} paused: ${threshold} refires produced no turn.
|
|
761
|
+
ctx.ui.notify(`${goalNoun()} paused: ${threshold} refires produced no turn. Escape cancels a stuck run, then /goal resume. /reload if stale; restart pi only if /reload fails.`, "warning");
|
|
689
762
|
notifyExternal(ctx, `${goalNoun()} paused: stalled (continuation not landing).`);
|
|
690
763
|
return true;
|
|
691
764
|
}
|
|
@@ -693,6 +766,7 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
|
|
|
693
766
|
}
|
|
694
767
|
|
|
695
768
|
function heartbeatTick(): void {
|
|
769
|
+
if (zombieStoodDown) return; // v0.30.0: a superseded instance stays silent forever
|
|
696
770
|
const ctx = freshCtx();
|
|
697
771
|
if (!ctx) return;
|
|
698
772
|
let idle = false;
|
|
@@ -717,7 +791,12 @@ function heartbeatTick(): void {
|
|
|
717
791
|
// on: refiring into a dead process is misleading, and worse, the stall
|
|
718
792
|
// escalation would PAUSE the goal — silently cancelling the
|
|
719
793
|
// interruptedAt → hold-on-restart promise the footer shows.
|
|
720
|
-
if (probeExtensionApiStale()) {
|
|
794
|
+
if (probeExtensionApiStale()) {
|
|
795
|
+
// v0.30.0: stale ≠ terminal — absorb the rebind-window and
|
|
796
|
+
// successor-instance cases; only orphans go terminal.
|
|
797
|
+
if (!absorbStaleIfSuperseded(ctx)) goStaleTerminal(ctx, "heartbeat probe");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
721
800
|
// v0.29.16: zombie-run watchdog. pi reports BUSY (a run is "active") but
|
|
722
801
|
// zero stream events for 20 min = the provider stream hung silently —
|
|
723
802
|
// queued continuations can't land, and every other watchdog stays quiet
|
|
@@ -1392,7 +1471,7 @@ function fireReviewer(
|
|
|
1392
1471
|
// to still count as "proposed" in the report + notify. Now the
|
|
1393
1472
|
// failure is LOUD and the proposal goes uncounted.
|
|
1394
1473
|
ctx.ui.notify(
|
|
1395
|
-
`Postaudit /goal proposal NOT delivered: ${err instanceof Error ? err.message : String(err)} — the follow-up never reached the session.
|
|
1474
|
+
`Postaudit /goal proposal NOT delivered: ${err instanceof Error ? err.message : String(err)} — the follow-up never reached the session. Run /reload if the session was just replaced (extensions rebuild in place), then retry.`,
|
|
1396
1475
|
"warning",
|
|
1397
1476
|
);
|
|
1398
1477
|
return false;
|
|
@@ -1528,7 +1607,7 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
|
|
|
1528
1607
|
if (isStaleApiError(err)) {
|
|
1529
1608
|
extensionApiStale = true;
|
|
1530
1609
|
appendLedger(ctx.cwd, "extension_api_stale", { where: "startDrafting seed" });
|
|
1531
|
-
ctx.ui.notify("glla: can't start the drafting interview — this session's extension handle is stale (pi session replacement).
|
|
1610
|
+
ctx.ui.notify("glla: can't start the drafting interview — this session's extension handle is stale (pi session replacement). Run /reload (extensions rebuild in place), then re-run the command. Restart pi only if /reload fails.", "warning");
|
|
1532
1611
|
} else {
|
|
1533
1612
|
ctx.ui.notify(`glla: couldn't start the drafting interview (${err instanceof Error ? err.message : String(err)}) — try again.`, "warning");
|
|
1534
1613
|
}
|
|
@@ -1648,7 +1727,7 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
|
|
|
1648
1727
|
// v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
|
|
1649
1728
|
// fresh session LOADS it (held by default since v0.28.21), and tell the truth instead of "starting now".
|
|
1650
1729
|
updateGoal({ interruptedAt: nowIso(), interruptedReason: "created in a stale session" }, ctx);
|
|
1651
|
-
ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations.
|
|
1730
|
+
ctx.ui.notify(`Goal saved: ${shortObj(goal.objective)} — safe in .pi-glla/, but this stale process can't send continuations. Run /reload (extensions rebuild in place, state survives), then /goal resume. Restart pi only if /reload itself fails.`, "warning");
|
|
1652
1731
|
return;
|
|
1653
1732
|
}
|
|
1654
1733
|
ctx.ui.notify(`Goal started: ${shortObj(goal.objective)} — the auditor will verify on completion.`, "info");
|
|
@@ -5493,12 +5572,43 @@ export default function (pi: ExtensionAPI): void {
|
|
|
5493
5572
|
}
|
|
5494
5573
|
});
|
|
5495
5574
|
|
|
5575
|
+
pi.on("session_shutdown", async (event: any, ctx: ExtensionContext) => {
|
|
5576
|
+
if (isForeignCtx(ctx)) return;
|
|
5577
|
+
// v0.30.0: attribution + rebind window. pi announces WHY the session
|
|
5578
|
+
// is being replaced (reload/resume/new/fork/quit) — the ledger can
|
|
5579
|
+
// now answer "what killed the handle?" without guesswork (hegemon's
|
|
5580
|
+
// 5-hour orphan silence 2026-07-31 was unattributable). The window
|
|
5581
|
+
// tells the stale probe that a rebind (session_start) is imminent.
|
|
5582
|
+
const shutdownReason = typeof event?.reason === "string" ? event.reason : "unknown";
|
|
5583
|
+
appendLedger(ctx.cwd, "session_shutdown", { reason: shutdownReason });
|
|
5584
|
+
sessionReplacementUntil = Date.now() + SESSION_REBIND_GRACE_MS;
|
|
5585
|
+
});
|
|
5586
|
+
|
|
5496
5587
|
pi.on("session_start", async (event: any, ctx: ExtensionContext) => {
|
|
5497
5588
|
rememberCtx(ctx);
|
|
5498
5589
|
// v0.23.8: subagent sessions (pi-subagents binds extensions there too)
|
|
5499
5590
|
// are workers — never run the restore gate or reschedule the loop from
|
|
5500
5591
|
// a foreign session.
|
|
5501
5592
|
if (isForeignCtx(ctx)) return;
|
|
5593
|
+
// v0.30.0: rebind bookkeeping — claim ownership, close any replacement
|
|
5594
|
+
// window, and reset a stale flag left over from the PREVIOUS session's
|
|
5595
|
+
// invalidation. pi rebinds THIS module to the new session (switch) or
|
|
5596
|
+
// re-imports it (/reload — fresh module, flag already false); either
|
|
5597
|
+
// way the fresh ctx makes the old poison flag wrong. Re-probe to
|
|
5598
|
+
// confirm the new handle actually works.
|
|
5599
|
+
writeOwnerFile(ctx.cwd);
|
|
5600
|
+
sessionReplacementUntil = 0;
|
|
5601
|
+
zombieStoodDown = false;
|
|
5602
|
+
const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
|
|
5603
|
+
appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
|
|
5604
|
+
if (extensionApiStale) {
|
|
5605
|
+
extensionApiStale = false; // fresh ctx delivered — re-probe
|
|
5606
|
+
const stillStale = probeExtensionApiStale();
|
|
5607
|
+
appendLedger(ctx.cwd, "stale_flag_reset_on_rebind", { reason: startReason, stillStale });
|
|
5608
|
+
if (stillStale) {
|
|
5609
|
+
ctx.ui.notify("glla: session rebound but the extension handle is still stale — run /reload (extensions rebuild in place), then /glla resume.", "warning");
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5502
5612
|
state = readState(ctx.cwd);
|
|
5503
5613
|
// v0.28.14: snapshot carryover BEFORE any restore logic mutates state —
|
|
5504
5614
|
// a paused goal, waiting list items, or a loop that was live/held when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
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",
|