pi-goal-list-loop-audit 0.37.1 → 0.37.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.37.3 — pi 0.84 loop stall fix — agent_start fallback for continuation start proof (2026-09-02)
4
+
5
+ ### Fixed
6
+ `continuation_start_unacknowledged` stall on every `/loop` iteration under pi >=0.84 — `before_agent_start` is not emitted for `sendMessage({ deliverAs: "followUp" })` continuations, so the start proof never arrived (10/10 `loop_turn_sent` unacknowledged in issue #40). `dispatchStartAcknowledged` now accepts `agent_start`/`turn_start` as fallback while keeping `before_agent_start`+marker as primary; owner/generation/foreign fences still apply so only an unrelated manual turn in the same session could falsely settle — strictly better than 0% success. Ledger records `startProofSource` as `agent_start`/`turn_start` when fallback fires.
7
+
8
+ Hardcoded 60s retry backoff after the first 30s start-proof window is now env-configurable (`GLLA_CONTINUATION_RETRY_BACKOFF_MS`, legacy `GLLA_CONTINUATION_START_RETRY_BACKOFF_MS`) independent of `GLLA_CONTINUATION_START_TIMEOUT_MS`, so slow local models can extend both windows (observed 5m + 60s hard cap in issue #40 secondary).
9
+
10
+ ### Added
11
+ Regression pins for the fallback path and env var (`tests/loop-start-proof-fallback.test.ts`, `tests/stale-api-terminal.test.ts`, `tests/stall-handling.test.ts`).
12
+
13
+ ## 0.37.2 — queued vs monitoring visuals and long-running daemon handling (2026-09-01)
14
+
15
+ ### Added
16
+ Monitor icon for long-running daemon/supervisor goals: goals matching
17
+ `daemon|supervisor|keep.*running|monitor|healthz` or with `total >1h` are
18
+ displayed as `👁 MONITORING` (dim) instead of `⏳ QUEUED` (accent), with
19
+ "next check" instead of "awaiting pi turn", and are scheduled with
20
+ `GLLA_MONITOR_INTERVAL_MS` (default 120s) instead of immediate continuation
21
+ to avoid constant queued churn.
22
+
23
+ ### Changed
24
+ `QUEUED` now renders as `⏳ QUEUED` in accent blue (distinct from `BUSY`
25
+ warning yellow) with `awaiting pi turn` detail, fixing the "mistook it
26
+ for stuck" report (note.md 2026-09-01, screenshots 190127/193438/193529).
27
+ `LIVE · WORKING` remains success-accent with signal.
28
+
29
+ ### Fixed
30
+ Stale `followUp` continuations that survived `archiveCurrentGoal` via Pi's
31
+ `followUpQueue` are sanitized at `message_end` (ExtensionRunner →
32
+ AgentSession) plus timer disarm at archival — `LENGTH_CONTINUE` and foreign
33
+ ctx are exempt.
34
+
3
35
  ## 0.37.1 — auditor watchdogs, continuous list handoff, and audit hardening (2026-09-01)
4
36
 
5
37
  ### Added
package/docs/INDEX.md CHANGED
@@ -18,7 +18,7 @@ For shipped docs, the relevant entry points are:
18
18
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
19
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
20
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
- v0.35.14–v0.37.1 continue through the supervisor freeze (`/glla pause`),
21
+ v0.35.14–v0.37.3 continue through the supervisor freeze (`/glla pause`),
22
22
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
23
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
24
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -47,6 +47,7 @@ import {
47
47
  isStaleApiError,
48
48
  supervisorPaused,
49
49
  objectiveIsUserSeeded,
50
+ isMonitorGoal,
50
51
  type Goal,
51
52
  type ObjectiveRepairTarget,
52
53
  } from "./goal-loop-core.js";
@@ -63,6 +64,12 @@ import {
63
64
  } from "./goal-loop-dispatch.js";
64
65
  import { BACKOFF_IDLE_RETRY_MS, HEARTBEAT_MAX_NUDGES } from "./goal-loop-backoff.js";
65
66
  import { LENGTH_CONTINUE_MAX, LENGTH_CONTINUE_TEXT } from "./length-continue.js";
67
+
68
+ const DEFAULT_MONITOR_CHECK_INTERVAL_MS = 120_000;
69
+ const configuredMonitorIntervalMs = Number(process.env.GLLA_MONITOR_INTERVAL_MS);
70
+ const MONITOR_CHECK_INTERVAL_MS = Number.isFinite(configuredMonitorIntervalMs) && configuredMonitorIntervalMs > 0
71
+ ? Math.max(1_000, configuredMonitorIntervalMs)
72
+ : DEFAULT_MONITOR_CHECK_INTERVAL_MS;
66
73
  import { VISION_ASSIST_GUIDANCE } from "./vision-assist.js";
67
74
  import { loadSettings } from "./goal-settings.js";
68
75
  import { clearLoopTimer, isLoopActive } from "./goal-loop.js";
@@ -197,7 +204,11 @@ export function createGoalContinuation(flagsArg: ContinuationFlags, d: Continuat
197
204
  // self-heal; only the second window failure declares unacknowledged (the
198
205
  // explicit /list|/goal|/loop resume fallback for genuine provider stalls).
199
206
  const CONTINUATION_START_TIMEOUT_MS = Number(process.env.GLLA_CONTINUATION_START_TIMEOUT_MS ?? 30_000);
200
- const NO_TURN_START_RETRY_BACKOFF_MS = 60_000;
207
+ const NO_TURN_START_RETRY_BACKOFF_MS = Number(
208
+ process.env.GLLA_CONTINUATION_RETRY_BACKOFF_MS ??
209
+ process.env.GLLA_CONTINUATION_START_RETRY_BACKOFF_MS ??
210
+ 60_000,
211
+ );
201
212
  let continuationStartTimeoutOverrideMs: number | null = null;
202
213
  let continuationRetryBackoffOverrideMs: number | null = null;
203
214
  function continuationStartTimeoutMs(): number {
@@ -561,21 +572,43 @@ export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string,
561
572
  }
562
573
  // A dispatch has one accepted window and one start proof. Once a start was
563
574
  // recorded, later low-level events must not re-settle it; before that,
564
- // events without the exact before_agent_start marker are unrelated manual
565
- // activity and cannot clear the watchdog.
575
+ // only a marker-carrying before_agent_start or the pi>=0.84 fallback
576
+ // (agent_start/turn_start without a prompt) may clear the watchdog.
566
577
  const pending = pendingContinuationDispatch;
567
578
  if (!pending || pending.phase !== "accepted") return false;
568
- if (source !== "before_agent_start" && !pending.startProofSource) return false;
579
+ // v0.37.3 (issue #40): pi >=0.84 does not emit before_agent_start for
580
+ // followUp continuations (sendMessage { deliverAs: "followUp" }) — the
581
+ // only delivery path for continuations. Gate on the strongest proof when
582
+ // available, but accept agent_start/turn_start as fallback so the
583
+ // continuation is not stuck at 0% success. Owner/generation/foreign
584
+ // checks below still fence the fallback to the same session/generation.
585
+ if (
586
+ source !== "before_agent_start" &&
587
+ source !== "agent_start" &&
588
+ source !== "turn_start" &&
589
+ !pending.startProofSource
590
+ )
591
+ return false;
569
592
  const record = pendingContinuationDispatch;
570
593
  if (!record || flags.sessionHandoffPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
571
594
  if (record.generation !== flags.sessionGeneration || isForeignCtx(ctx)) return false;
572
595
  if (!dispatchMatchesOwner(record, flags.sessionGeneration, sessionManagerId(ctx))) return false;
573
596
  if ((record.kind === "goal" || record.kind === "stall") && (!state.goal || state.goal.id !== record.goalId || state.goal.status !== "active")) return false;
574
- // before_agent_start is the required proof: it must carry this exact
575
- // dispatch marker. Older low-level events without a prompt are liveness
576
- // signals only; accepting them would let an unrelated manual turn settle
577
- // this dispatch and suppress its recovery watchdog.
578
- if (source !== "before_agent_start" || !dispatchPromptMatches(record, prompt)) return false;
597
+ // before_agent_start must carry this exact dispatch marker. The fallback
598
+ // (agent_start/turn_start) carries no prompt in pi >=0.84 for followUp
599
+ // turns owner/generation/foreign above already fence it to the same
600
+ // session, so an unrelated manual turn in the same session is the only
601
+ // remaining false-positive, strictly better than never acknowledging.
602
+ if (source === "before_agent_start") {
603
+ if (!dispatchPromptMatches(record, prompt)) return false;
604
+ } else if (source === "agent_start" || source === "turn_start") {
605
+ // fallback — no prompt to match
606
+ } else {
607
+ // message_update / agent_end / other liveness signals cannot settle
608
+ // without an existing proof, and pending.phase !== "accepted" already
609
+ // blocks re-settlement after the first proof.
610
+ return false;
611
+ }
579
612
  const settledAt = Date.now();
580
613
  const started: ContinuationDispatch = {
581
614
  ...transitionDispatch(record, "started"),
@@ -991,6 +1024,10 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
991
1024
  } catch {
992
1025
  return;
993
1026
  }
1027
+ // v0.37.x: monitor goals (daemon, long-running >1h) check less frequently to avoid constant QUEUED churn.
1028
+ if (delayMs === undefined && state.goal && isMonitorGoal(state.goal)) {
1029
+ delay = Math.max(delay, MONITOR_CHECK_INTERVAL_MS);
1030
+ }
994
1031
  // v0.34.104 ([Image-#1]): the post-list-completion settle window delays
995
1032
  // the first continuation after a queue auto-advance. Any real agent
996
1033
  // activity during the window clears `postCompletionSettleUntil`, so a
@@ -568,6 +568,19 @@ export function objectiveIsUserSeeded(goal: Pick<Goal, "objective" | "createdVia
568
568
  });
569
569
  }
570
570
 
571
+ /**
572
+ * A long-running goal whose next turn is a health check should not look like
573
+ * a wedged queue. Keep this predicate pure and shared by scheduling and both
574
+ * TUI surfaces so a goal cannot be throttled without receiving the matching
575
+ * monitoring icon (or vice versa).
576
+ */
577
+ export function isMonitorGoal(goal: Pick<Goal, "objective" | "createdAt">, now = Date.now()): boolean {
578
+ const objective = goal.objective.toLowerCase();
579
+ if (/daemon|supervisor|keep.*running|monitor|healthz|book-daemon/.test(objective)) return true;
580
+ const started = Date.parse(goal.createdAt);
581
+ return Number.isFinite(started) && now - started > 60 * 60 * 1000;
582
+ }
583
+
571
584
  /**
572
585
  * During a LIST drafting session the agent must not add items one by one
573
586
  * with list_add/list_activate — that bypasses the user's Confirm gate
@@ -13,7 +13,9 @@
13
13
  import { truncateToWidth as tuiTruncateToWidth, visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
14
14
 
15
15
  import type { DurableDeferRecommendationInput, Goal, MainModelRecovery, State } from "./goal-loop-core.js";
16
- import { buildDurableDeferRecommendation, compactDisplayText, formatMainModelRecoveryStatus, isPersistenceDegraded, lastPersistenceFailure, sanitizeDisplayText, sanitizeProviderAuditReport, sanitizeProviderDisplayText, stripThinkBlocks } from "./goal-loop-core.js";
16
+ import { buildDurableDeferRecommendation, compactDisplayText, formatMainModelRecoveryStatus, isMonitorGoal, isPersistenceDegraded, lastPersistenceFailure, sanitizeDisplayText, sanitizeProviderAuditReport, sanitizeProviderDisplayText, stripThinkBlocks } from "./goal-loop-core.js";
17
+
18
+ export { isMonitorGoal };
17
19
  import { HELD_ON_RESTORE, type LoopState } from "./goal-loop-forever.js";
18
20
  import { auditorSurfaceSuppressed } from "./loops/goal-auditor-surface.js";
19
21
 
@@ -95,7 +97,7 @@ export interface RecentActionDisplay {
95
97
  }
96
98
 
97
99
  /** v0.33.0: widget extras — the refire streak plus the recent-action feed. */
98
- export type GoalDisplayActivity = "active" | "awaiting-first-turn" | "working" | "busy" | "queued" | "idle";
100
+ export type GoalDisplayActivity = "active" | "awaiting-first-turn" | "working" | "busy" | "queued" | "monitoring" | "idle";
99
101
 
100
102
  export interface ModelProvenanceDisplay {
101
103
  /** The primary model reference selected for the supervised work. */
@@ -354,7 +356,8 @@ function activityStatusMarker(activity: GoalDisplayActivity | undefined, now: nu
354
356
  switch (activity) {
355
357
  case "working": return activityBadge("LIVE · WORKING", now, theme);
356
358
  case "busy": return activityStateBadge("BUSY", theme, "warning");
357
- case "queued": return activityStateBadge("QUEUED", theme, "warning");
359
+ case "queued": return activityStateBadge("QUEUED", theme, "accent");
360
+ case "monitoring": return activityStateBadge("👁 MONITORING", theme, "dim");
358
361
  case "idle": return activityStateBadge("IDLE", theme, "warning");
359
362
  case "awaiting-first-turn": return activityStateBadge("AWAITING FIRST TURN", theme, "warning");
360
363
  case "active": return activityStateBadge("ACTIVE", theme, "accent");
@@ -757,9 +760,11 @@ function lastAuditorTool(audit: AuditDisplayProgress | null | undefined): string
757
760
  return typeof name === "string" && name.trim() ? truncate(name, 30) : undefined;
758
761
  }
759
762
 
760
- function goalDisplayActivity(g: Goal, extras?: WidgetExtras): GoalDisplayActivity {
763
+ function goalDisplayActivity(g: Goal, extras?: WidgetExtras, now = Date.now()): GoalDisplayActivity {
761
764
  if (g.status !== "active") return "active";
762
- return extras?.activity ?? "active";
765
+ const activity = extras?.activity ?? "active";
766
+ if (activity === "queued" && isMonitorGoal(g, now)) return "monitoring";
767
+ return activity;
763
768
  }
764
769
 
765
770
  function hostLastActivity(extras: WidgetExtras | undefined, now: number): string {
@@ -1031,7 +1036,7 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1031
1036
  if (attention) {
1032
1037
  return withRecovery(`glla: ${paint(theme, attention.color, `⚠ ${attention.label}`)}${heldSuffix}`);
1033
1038
  }
1034
- const activity = goalDisplayActivity(g, extras);
1039
+ const activity = goalDisplayActivity(g, extras, now);
1035
1040
  // v0.34.97: while the post-compaction grace window is open, surface
1036
1041
  // "compacting…" so the user knows the session just shrank. The chip
1037
1042
  // survives reload because lastCompactionAt is persisted on State.
@@ -1074,11 +1079,14 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1074
1079
  const n = state.list?.length ?? 0;
1075
1080
  const live = activity === "working";
1076
1081
  const queued = activity === "queued";
1082
+ const monitoring = activity === "monitoring";
1077
1083
  const marker = live
1078
1084
  ? activityBadge("LIVE · WORKING", now, theme)
1079
- : queued
1080
- ? activityStateBadge("QUEUED", theme, "warning")
1081
- : activityStateBadge("ACTIVE", theme, "accent");
1085
+ : monitoring
1086
+ ? activityStateBadge("👁 MONITORING", theme, "dim")
1087
+ : queued
1088
+ ? activityStateBadge("⏳ QUEUED", theme, "accent")
1089
+ : activityStateBadge("ACTIVE", theme, "accent");
1082
1090
  // When recovery parks a queued goal, name the blocker — `[QUEUED] 12m
1083
1091
  // 26s` reads as a stalled queue with no WHY. State.mainModelRecovery is
1084
1092
  // the bounded envelope's parked state; the status line says what is
@@ -1096,8 +1104,9 @@ function buildStatusTextBase(state: State, audit?: AuditDisplayProgress | null,
1096
1104
  // v0.34.124: the QUEUED "why" — an accepted dispatch that pi has not
1097
1105
  // started, and the last real activity age. A ticking timer with no
1098
1106
  // freshness told the user nothing (note.md 221249).
1099
- queued && extras?.turnPending ? "turn pending" : "",
1100
- queued ? hostLastActivity(extras, now).replace(/^ · /, "") : "",
1107
+ queued && extras?.turnPending ? "awaiting pi turn" : "",
1108
+ monitoring ? "next check" : "",
1109
+ (queued || monitoring) ? hostLastActivity(extras, now).replace(/^ · /, "") : "",
1101
1110
  n > 0 ? `${n} queued` : "",
1102
1111
  ].filter(Boolean);
1103
1112
  return withRecovery(`glla: ${marker}${details.length > 0 ? ` ${details.join(" · ")}` : ""}${recoverySuffix}${heldSuffix}`);
@@ -671,6 +671,36 @@ function refuseForeignCommand(ctx: ExtensionContext): boolean {
671
671
  return true;
672
672
  }
673
673
 
674
+ export function __testOnlyClassifyStaleContinuation(content: string, cwd: string): string | null {
675
+ const goalIdMatch = content.match(/\[GOAL CHECKPOINT goalId=([^\]\s]+)\]/);
676
+ const loopMatch = content.match(/\[LOOP ITERATION (\d+)\]/);
677
+ const isStall = content.includes("[STALL WARNING");
678
+ const isLengthContinue = content.includes("Your previous response was cut off") || content.includes("Response hit the output-token cap");
679
+ if (isLengthContinue) return null;
680
+ if (goalIdMatch) {
681
+ const gid = goalIdMatch[1]!;
682
+ if (!state.goal) return `no active goal (expected ${gid})`;
683
+ if (state.goal.id !== gid) return `goal mismatch (expected ${gid}, active ${state.goal.id})`;
684
+ if (state.goal.status !== "active") return `goal ${gid} not active (status=${state.goal.status})`;
685
+ try {
686
+ const p = archivedGoalPath(cwd, gid);
687
+ if (fs.existsSync(p)) return `goal ${gid} already archived`;
688
+ } catch {}
689
+ if ((state.goal as any).stopReason) return `goal ${gid} terminal stopReason present`;
690
+ return null;
691
+ }
692
+ if (loopMatch) {
693
+ if (!state.loop?.active) return `loop not active (iteration ${loopMatch[1]})`;
694
+ return null;
695
+ }
696
+ if (isStall) {
697
+ if (!state.goal || state.goal.status !== "active") return "stall warning with no active goal";
698
+ return null;
699
+ }
700
+ if (!state.goal && !state.loop?.active) return "no active supervision for generic goal-event";
701
+ return null;
702
+ }
703
+
674
704
  export function registerGoalRuntime(pi: ExtensionAPI): void {
675
705
  // Factories run before lifecycle events, so observe readiness now; the
676
706
  // admitted session_start below decides which bus/generation may control a
@@ -973,6 +1003,37 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
973
1003
  draftingUserReplies++;
974
1004
  });
975
1005
 
1006
+ // v0.37.2: stale queued goal-event continuations (hidden display:false
1007
+ // custom messages) can survive goal archival via Pi's followUp queue.
1008
+ // Pi has no public clear queue API, so sanitize at delivery: replace
1009
+ // stale content before it is persisted or drives a turn.
1010
+ pi.on("message_end", async (event: any, ctx: ExtensionContext) => {
1011
+ const msg: any = event?.message;
1012
+ if (!msg || msg.role !== "custom" || msg.customType !== "goal-event") return;
1013
+ if (isForeignCtx(ctx)) return;
1014
+ let content = "";
1015
+ if (typeof msg.content === "string") content = msg.content;
1016
+ else if (Array.isArray(msg.content)) content = msg.content.map((c: any) => typeof c === "string" ? c : (c?.text ?? "")).join("\n");
1017
+ else if (msg.content != null) content = String(msg.content);
1018
+ const staleReason = __testOnlyClassifyStaleContinuation(content, ctx.cwd);
1019
+ if (!staleReason) return;
1020
+ const goalIdMatch = content.match(/\[GOAL CHECKPOINT goalId=([^\]\s]+)\]/);
1021
+ const loopMatch = content.match(/\[LOOP ITERATION (\d+)\]/);
1022
+ const sanitizedContent = `[GLLA: discarded stale continuation — ${staleReason}. No action required. Live state: ${state.goal ? `goal ${state.goal.id} (${state.goal.status})` : state.loop?.active ? `loop active iteration ${state.loop.iteration}` : "idle (no goal/loop)"}.]`;
1023
+ appendLedger(ctx.cwd, "stale_continuation_sanitized", {
1024
+ reason: staleReason,
1025
+ goalId: goalIdMatch?.[1],
1026
+ loopIteration: loopMatch?.[1],
1027
+ originalPreview: content.slice(0, 200),
1028
+ });
1029
+ const sanitized = {
1030
+ ...msg,
1031
+ content: sanitizedContent,
1032
+ display: false,
1033
+ };
1034
+ return { message: sanitized };
1035
+ });
1036
+
976
1037
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
977
1038
  // messages — count answered (non-cancelled) questionnaires as replies too.
978
1039
  pi.on("tool_result", async (event: any, eventCtx: ExtensionContext) => {
@@ -1237,6 +1237,9 @@ function archiveCurrentGoal(
1237
1237
  }
1238
1238
  releaseContinuationDispatchStandDown();
1239
1239
  clearDispatchRecord(ctx.cwd);
1240
+ clearContinuationTimer();
1241
+ clearContinuationStartWatchdog();
1242
+ clearQueueStuckProbe();
1240
1243
  postCompactResumeOwed = false; // v0.33.1: the dead goal's compact debt/resync dies with it
1241
1244
  postCompactResyncPending = false;
1242
1245
  // v0.34.120: archive is the durable history; a terminal goal must not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.37.1",
3
+ "version": "0.37.3",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",