pi-goal-list-loop-audit 0.35.65 → 0.35.67

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.67 — in-band provider-result recovery (2026-08-26)
4
+
5
+ ### Fixed
6
+ Repeated successful tool transports that carry a strong 503/429/network
7
+ provider pane no longer enter loop stuck or plateau accounting as ordinary
8
+ work. After the same tool/result fingerprint repeats, the turn is routed
9
+ through the existing provider-recovery envelope; one-off status text in a
10
+ searched document remains ordinary output.
11
+
12
+ ### Tests
13
+ Coverage spans provider-marker classification, repeated-pane detection,
14
+ loop-turn exemption, durable recovery parking, and the unchanged real-error
15
+ and repetition paths. The full release gate remains green.
16
+
17
+ ## 0.35.66 — compiled-host auditor launcher (2026-08-26)
18
+
19
+ ### Fixed
20
+ Detached completion audits no longer launch the worker with a compiled Pi
21
+ executable. The process layer preserves explicit runtime overrides, keeps
22
+ Node/Node.js/Bun/Deno `process.execPath` values, and falls back to `node` for
23
+ compiled hosts that would otherwise parse worker flags such as `--job-dir`.
24
+
25
+ ### Tests
26
+ Runtime-resolution coverage includes Unix, Windows, Node.js aliases, Bun,
27
+ Deno, compiled Pi paths, and bare executable names. The existing explicit
28
+ runtime-override transport regression remains covered.
29
+
3
30
  ## 0.35.65 — status surfaces and worker liveness (2026-08-26)
4
31
 
5
32
  ### Added
@@ -573,7 +573,7 @@ interface AuditorProgressFile {
573
573
  export type AuditorInfrastructureClass = "no-verdict" | "timeout" | "transport" | "provider";
574
574
 
575
575
  export interface AuditorProcessRuntime {
576
- /** Override the worker launcher command (normally process.execPath). */
576
+ /** Override the worker launcher command (normally resolved from process.execPath, with a JS-runtime fallback for compiled hosts). */
577
577
  command?: string;
578
578
  /** Override the worker module (normally scripts/goal-auditor-worker.mjs). */
579
579
  workerPath?: string;
@@ -761,6 +761,17 @@ function stampToken<T extends GoalAuditorResult>(result: T, capturedToken: GoalR
761
761
  return { ...result, goalRevision: capturedToken };
762
762
  }
763
763
 
764
+ const JAVASCRIPT_RUNTIME_BASENAMES = new Set(["node", "nodejs", "bun", "deno"]);
765
+
766
+ /** Resolve the runtime that can execute the extension-less worker module.
767
+ * In a normal Node/Bun/Deno host, process.execPath is already a JavaScript
768
+ * runtime. In a compiled Pi host it is the Pi executable, which would parse
769
+ * worker flags such as --job-dir itself and exit before the worker starts. */
770
+ export function resolveWorkerCommand(execPath: string): string {
771
+ const base = path.basename(execPath.replace(/\\/g, "/")).replace(/\.exe$/i, "").toLowerCase();
772
+ return JAVASCRIPT_RUNTIME_BASENAMES.has(base) ? execPath : "node";
773
+ }
774
+
764
775
  /**
765
776
  * Run one completion audit in a detached, extension-less child process.
766
777
  * Infrastructure failures never become semantic disapprovals and never fall
@@ -881,7 +892,7 @@ export async function runDetachedGoalCompletionAuditor(args: {
881
892
  args.onProgress?.(asProgress(initialProgress, startedAt));
882
893
 
883
894
  const workerPath = runtime.workerPath ?? defaultWorkerPath();
884
- const command = runtime.command ?? process.execPath;
895
+ const command = runtime.command ?? resolveWorkerCommand(process.execPath);
885
896
  const spawn = runtime.spawn ?? nodeSpawn;
886
897
  const env = { ...process.env, ...(runtime.env ?? {}) };
887
898
  if (runtime.piBinary) env.GLLA_PI_BINARY = runtime.piBinary;
@@ -113,8 +113,9 @@ export interface LoopState {
113
113
  recentPrints?: string[];
114
114
  /** v0.24.0: last few iteration texts (near-duplicate check + banned openings). */
115
115
  recentTexts?: string[];
116
- /** v0.24.0: rolling tool-result fingerprints {tool, hash, isError}. */
117
- recentToolResults?: { tool: string; hash: string; isError: boolean }[];
116
+ /** v0.24.0: rolling tool-result fingerprints {tool, hash, isError};
117
+ * providerFailure marks a repeated in-band provider/network pane. */
118
+ recentToolResults?: { tool: string; hash: string; isError: boolean; providerFailure?: boolean }[];
118
119
  /** v0.24.0: tool calls seen since the last completed iteration. */
119
120
  toolsThisTurn?: number;
120
121
  /** v0.24.0: consecutive iterations with zero tool calls. */
@@ -129,6 +129,18 @@ export interface ToolResultPrint {
129
129
  tool: string;
130
130
  hash: string;
131
131
  isError: boolean;
132
+ /** Successful tool transport can still contain a repeated provider failure. */
133
+ providerFailure?: boolean;
134
+ }
135
+
136
+ /** The repeated in-band provider pane is an outage signal, not loop work. */
137
+ export function repeatedInBandProviderFailure(results: ToolResultPrint[], repeat = REPETITION.toolResultRepeat): boolean {
138
+ if (repeat <= 0) return false;
139
+ const recent = results.slice(-repeat);
140
+ if (recent.length !== repeat || !recent[0]?.providerFailure) return false;
141
+ return recent.every((result) => result.providerFailure === true
142
+ && result.tool === recent[0]!.tool
143
+ && result.hash === recent[0]!.hash);
132
144
  }
133
145
 
134
146
  export interface LoopStuckInput {
@@ -179,6 +179,7 @@ import {
179
179
  } from "../length-continue.js";
180
180
  import { isSubagentProviderFailure } from "../quota-retry.js";
181
181
  import {
182
+ classifyInBandProviderFailure,
182
183
  classifyMainModelFailure,
183
184
  isMainModelFallbackFailure,
184
185
  requiresMainModelRecovery,
@@ -229,6 +230,7 @@ import {
229
230
  import {
230
231
  REPETITION,
231
232
  isActuallyStuck,
233
+ repeatedInBandProviderFailure,
232
234
  loopInterventionDirective,
233
235
  continueVariant,
234
236
  textFingerprint,
@@ -456,6 +458,13 @@ const ZOMBIE_PAUSE_REASON = "automatic zero-stream abort — no provider activit
456
458
  // the park standing for manual resume — an honest degradation.
457
459
  let zombieRetryStreak: ZombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
458
460
  let zombieRetryTimer: NodeJS.Timeout | null = null;
461
+ // An in-band provider pane is observed during tool_result and consumed at the
462
+ // matching agent_end. Keep the raw text only in memory; the durable ledger
463
+ // records the bounded classification, never the provider payload.
464
+ let inBandProviderFailureRaw: string | null = null;
465
+ function clearInBandProviderFailure(): void {
466
+ inBandProviderFailureRaw = null;
467
+ }
459
468
 
460
469
  /** Arm the one-shot automatic re-dispatch after a successful zombie abort.
461
470
  * Returns true when a retry was scheduled (the caller adjusts its user-facing
@@ -951,11 +960,29 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
951
960
  const loop = state.loop!;
952
961
  const out = event?.output ?? event?.result ?? event?.details ?? "";
953
962
  const text = typeof out === "string" ? out : JSON.stringify(out) ?? "";
963
+ const tool = String(event?.toolName ?? "?");
964
+ const inBandFailure = classifyInBandProviderFailure(text);
954
965
  loop.recentToolResults = pushRepetitionCapped(
955
966
  loop.recentToolResults ?? [],
956
- { tool: String(event?.toolName ?? "?"), hash: textFingerprint(text), isError: Boolean(event?.isError ?? event?.error) },
967
+ {
968
+ tool,
969
+ hash: textFingerprint(text),
970
+ isError: Boolean(event?.isError ?? event?.error) || !!inBandFailure,
971
+ ...(inBandFailure ? { providerFailure: true } : {}),
972
+ },
957
973
  REPETITION.toolWindow,
958
974
  );
975
+ // Successful transport is not proof of successful work: only a stable
976
+ // repeated provider pane becomes a loop-level recovery signal. One-off
977
+ // 503/429 text in a searched document remains ordinary tool output.
978
+ if (inBandFailure && repeatedInBandProviderFailure(loop.recentToolResults)) {
979
+ inBandProviderFailureRaw = text.slice(0, 800);
980
+ appendLedger(eventCtx.cwd, "loop_in_band_provider_failure", {
981
+ tool,
982
+ kind: inBandFailure.kind,
983
+ repeats: REPETITION.toolResultRepeat,
984
+ });
985
+ }
959
986
  // v0.25.1: file-write progress signal for the multi-signal stuck
960
987
  // gate — a loop that is WRITING files is shipping, not stuck.
961
988
  if (isLoopWriteTool(String(event?.toolName ?? ""))) {
@@ -1034,6 +1061,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1034
1061
  });
1035
1062
  }
1036
1063
  appendLedger(ctx.cwd, "session_shutdown", { reason: shutdownReason });
1064
+ clearInBandProviderFailure();
1037
1065
  markSessionOwnerShutdown(ctx.cwd, shutdownReason);
1038
1066
  writeSessionHandoff(ctx, shutdownReason);
1039
1067
  sessionReplacementUntil = Date.now() + SESSION_REBIND_GRACE_MS;
@@ -1136,6 +1164,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1136
1164
  const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
1137
1165
  initialSessionLoadPending = isBlankInitialStartup(ctx, startReason);
1138
1166
  rememberCtx(ctx);
1167
+ clearInBandProviderFailure();
1139
1168
  startHeartbeat();
1140
1169
  startUITicker();
1141
1170
  // v0.30.0: rebind bookkeeping — claim ownership, close any replacement
@@ -1744,10 +1773,10 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1744
1773
  // v0.27.3: enrich lastA with text + priorText for the smarter nudge
1745
1774
  // accounting below.
1746
1775
  const assistants = (event.messages as any[]).filter((m: any) => m.role === "assistant");
1747
- const rawLastA = assistants.length ? assistants[assistants.length - 1] : null;
1776
+ let rawLastA = assistants.length ? assistants[assistants.length - 1] : null;
1748
1777
  const rawPriorA = assistants.length >= 2 ? assistants[assistants.length - 2] : null;
1749
1778
  const extractText = (m: any): string => (m && Array.isArray(m.content)) ? m.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
1750
- const lastA = rawLastA ? { stopReason: rawLastA.stopReason, text: extractText(rawLastA), priorText: extractText(rawPriorA) } : null;
1779
+ let lastA = rawLastA ? { stopReason: rawLastA.stopReason, text: extractText(rawLastA), priorText: extractText(rawPriorA) } : null;
1751
1780
  // v0.34.19: pi-ai clamps max_tokens to the remaining context before the
1752
1781
  // provider call. At ~99% context that clamp can be 1 token, which the
1753
1782
  // provider reports as stopReason "length" — but this is NOT an overlong
@@ -1835,6 +1864,23 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1835
1864
  // never pass the same failure into main-goal recovery.
1836
1865
  return;
1837
1866
  }
1867
+ // A repeated provider pane arrived through a successful tool transport,
1868
+ // so pi reports an ordinary end_turn. Convert it into the same bounded
1869
+ // recovery envelope as a provider error before loop measurement/stuck
1870
+ // accounting can consume the dead turn.
1871
+ if (inBandProviderFailureRaw && isLoopActive()) {
1872
+ const raw = inBandProviderFailureRaw;
1873
+ clearInBandProviderFailure();
1874
+ const loop = state.loop!;
1875
+ // The normal loop error branch owns the consecutive-error counter and
1876
+ // its bounded recovery cap. Clearing the fingerprints here prevents the
1877
+ // same pane from being reclassified before that branch runs.
1878
+ loop.recentToolResults = [];
1879
+ rawLastA = { ...(rawLastA ?? {}), stopReason: "error", errorMessage: raw, content: [] };
1880
+ lastA = { stopReason: "error", text: raw, priorText: lastA?.priorText ?? "" };
1881
+ } else if (inBandProviderFailureRaw) {
1882
+ clearInBandProviderFailure();
1883
+ }
1838
1884
  if (await handleMainModelAgentEnd(ctx, rawLastA, lastA)) return;
1839
1885
  // v0.25.2: per-goal turn telemetry (/glla stats).
1840
1886
  if (state.goal && state.goal.status === "active") {
@@ -2292,6 +2338,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
2292
2338
  });
2293
2339
  pi.on("agent_start", (_event: any, ctx: ExtensionContext) => {
2294
2340
  rememberCtx(ctx);
2341
+ clearInBandProviderFailure();
2295
2342
  if (tryAbsorbHostSuccessor(ctx, "agent_start")) {
2296
2343
  ensureAgentToolsReady(ctx, true);
2297
2344
  return;
@@ -159,6 +159,20 @@ export function classifyMainModelFailure(error: string | undefined, opts?: { isC
159
159
  return { kind: "unknown", raw };
160
160
  }
161
161
 
162
+ /** A successful tool invocation can still carry a provider/network failure
163
+ * in its output. Only strong pane-shaped markers are eligible here; the loop
164
+ * caller additionally requires the same tool/result fingerprint to repeat
165
+ * before turning this into model recovery, so a one-off `503` in a searched
166
+ * document is not enough to park a loop. */
167
+ const IN_BAND_PROVIDER_FAILURE_PATTERN = /\b(?:http\s*)?(?:429|5\d\d)\b|rate[_ -]?limit|too many requests|network[_ -]?error|upstream(?:\s+(?:error|failure|unavailable))?|service unavailable|fetch failed|econn(?:reset|refused)|gateway(?:\s+(?:error|timeout))?/i;
168
+
169
+ export function classifyInBandProviderFailure(output: string | undefined): MainModelFailure | undefined {
170
+ const raw = typeof output === "string" ? output.trim() : "";
171
+ if (!raw || !IN_BAND_PROVIDER_FAILURE_PATTERN.test(raw)) return undefined;
172
+ const failure = classifyMainModelFailure(raw);
173
+ return failure.kind === "non-recoverable" ? undefined : failure;
174
+ }
175
+
162
176
  /** v0.34.116: detect when a length-context failure happened AFTER the
163
177
  * session_compact already failed. The classifier maps this to
164
178
  * `context-overflow` (rollback path: rotate to a larger-context ref). The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.35.65",
3
+ "version": "0.35.67",
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",