@mjasnikovs/pi-task 0.18.36 → 0.18.38

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.
@@ -77,7 +77,8 @@ export function parseAcceptDebts(raw) {
77
77
  ...((origin === 'enforce-revert'
78
78
  || origin === 'frozen-blocked'
79
79
  || origin === 'cross-task-deletion'
80
- || origin === 'yolo-accepted') ?
80
+ || origin === 'yolo-accepted'
81
+ || origin === 'final-gate') ?
81
82
  { origin: origin }
82
83
  : {})
83
84
  });
@@ -187,6 +188,21 @@ export async function recordYoloAcceptDebt(cwd, taskId, reason) {
187
188
  origin: 'yolo-accepted'
188
189
  });
189
190
  }
191
+ /**
192
+ * Record a FINAL-GATE UNOBSERVED debt (mx5 run 14): the final gate demoted one of its
193
+ * OWN checks after two tree-changing fix attempts returned an identical ranked-first
194
+ * failure — unfalsifiable in this environment, so the gate stopped paying for it and
195
+ * converged on the remaining checks. Durable so the NEXT run's gate re-checks it: it
196
+ * is model-/environment-judged, so the re-check surfaces it (never auto-closes it).
197
+ * The taskId is the run's parent id — the demotion is a run-level decision.
198
+ */
199
+ export async function recordFinalGateUnobservedDebt(cwd, taskId, reason) {
200
+ await appendDebt(cwd, {
201
+ taskId: taskId.trim(),
202
+ reason: normaliseReason(reason),
203
+ origin: 'final-gate'
204
+ });
205
+ }
190
206
  /**
191
207
  * The deleted path a cross-task-deletion debt names (the fixed shape
192
208
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -341,5 +357,8 @@ export function describeDebt(d) {
341
357
  if (d.origin === 'yolo-accepted') {
342
358
  return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
343
359
  }
360
+ if (d.origin === 'final-gate') {
361
+ return 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)';
362
+ }
344
363
  return 'accepted despite verify-FAIL';
345
364
  }
@@ -30,7 +30,8 @@ import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
30
30
  import { runGatesForTask } from './task-gates.js';
31
31
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
32
32
  import { runFinalIntegrationGate } from './final-gate.js';
33
- import { describeDebt } from './accept-debt.js';
33
+ import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
34
+ import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
34
35
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
35
36
  import { getConfig } from '../config/config.js';
36
37
  import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
@@ -1027,7 +1028,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1027
1028
  let fixAttempts = 0;
1028
1029
  // Sub-fixes a non-converging autofix attempt left uncommitted.
1029
1030
  // Refreshed after every attempt; drives the picker note and the
1030
- // accept-time commit (mx5 run 13 PROMPT 4 item 3).
1031
+ // terminal commit (mx5 run 13 PROMPT 4 item 3, run 14 item 2b).
1031
1032
  let stranded = [];
1032
1033
  const refreshStranded = async () => {
1033
1034
  if (!deps.pendingChanges)
@@ -1040,6 +1041,42 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1040
1041
  stranded = [];
1041
1042
  }
1042
1043
  };
1044
+ // NON-PROGRESS / UNFALSIFIABLE-CHECK state (mx5 run 14 item 2a).
1045
+ // `prevFailSig` is the previous attempt's normalized ranked-first
1046
+ // failure; `demoted` holds the signatures already carried as debt,
1047
+ // so a re-run that still reports them does not re-fail the gate.
1048
+ let prevFailSig = null;
1049
+ const demoted = new Set();
1050
+ // Set when a write-guard rejected an attempt whose edits could NOT
1051
+ // be discarded: REJECTED edits are then sitting in the tree and
1052
+ // must never be committed by the terminal paths below.
1053
+ let rejectedEditsInTree = false;
1054
+ // Commit whatever guard-clean repairs the fix passes left, on ANY
1055
+ // terminal non-converged outcome. Run 14 ended on LEAVE with 13
1056
+ // real repairs dirty in the tree after an unattended run — the
1057
+ // next checkout would have destroyed them silently.
1058
+ const commitStranded = async (outcome) => {
1059
+ if (stranded.length === 0)
1060
+ return;
1061
+ if (rejectedEditsInTree) {
1062
+ await recGate(`final-gate: NOT committing ${stranded.length} working-tree change(s) — a `
1063
+ + `write-guard rejected an attempt and its edits could not be discarded, `
1064
+ + `so the tree holds REJECTED edits: ${stranded.slice(0, 8).join(', ')}`);
1065
+ return;
1066
+ }
1067
+ try {
1068
+ const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id, outcome));
1069
+ await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
1070
+ + `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
1071
+ }
1072
+ catch (err) {
1073
+ // Never break the terminal path over this — but say so, so
1074
+ // the changes are not silently lost.
1075
+ await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
1076
+ + `change(s) (${err instanceof Error ? err.message : String(err)}) — `
1077
+ + `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
1078
+ }
1079
+ };
1043
1080
  while (!fin.ok) {
1044
1081
  const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
1045
1082
  // The picker question shows the debts (the HUMAN weighs them);
@@ -1099,20 +1136,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1099
1136
  // it fixed. Commit it as its own, named commit — the
1100
1137
  // ACCEPT is a decision about the FAILING gate, never an
1101
1138
  // instruction to throw away work (mx5 run 13 item 3).
1102
- if (stranded.length > 0) {
1103
- try {
1104
- const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id));
1105
- await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
1106
- + `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
1107
- }
1108
- catch (err) {
1109
- // Never break the completion path over this — but
1110
- // say so, so the changes are not silently lost.
1111
- await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
1112
- + `change(s) (${err instanceof Error ? err.message : String(err)}) — `
1113
- + `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
1114
- }
1115
- }
1139
+ await commitStranded('accepted');
1116
1140
  active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`
1117
1141
  + (stranded.length > 0 ?
1118
1142
  ` ${stranded.length} uncommitted fix-pass change(s) committed separately.`
@@ -1135,15 +1159,69 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1135
1159
  break;
1136
1160
  }
1137
1161
  await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
1162
+ // A guard that rejected an attempt WITHOUT discarding leaves
1163
+ // rejected edits behind: the terminal paths must not commit
1164
+ // the tree after that (the cheat guard stays intact).
1165
+ if (fix.guardTripped === true && fix.editsDiscarded !== true) {
1166
+ rejectedEditsInTree = true;
1167
+ }
1138
1168
  // The attempt's edits survive a non-convergence (only a
1139
1169
  // guard trip discards). Find out what they are NOW, so
1140
- // the next picker shows them and an ACCEPT can commit them.
1170
+ // the next picker shows them and a terminal outcome commits them.
1141
1171
  await refreshStranded();
1142
1172
  if (stranded.length > 0) {
1143
1173
  await recGate(`final-gate: autofix attempt ${fixAttempts} left ${stranded.length} `
1144
1174
  + `uncommitted change(s) — ${stranded.slice(0, 8).join(', ')}`);
1145
1175
  }
1146
1176
  active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
1177
+ // NON-PROGRESS CLASSIFIER (mx5 run 14 item 2a). An attempt
1178
+ // that changed the tree, re-ran the gate, and got back the
1179
+ // SAME ranked-first failure as the previous such attempt is
1180
+ // evidence about the CHECK, not the fix: run 14 burned all
1181
+ // three attempts on a boot probe that could not observe a
1182
+ // listener in that sandbox at all. Demote that one check to
1183
+ // UNOBSERVED-with-debt and let the REMAINING checks decide.
1184
+ const detail = rankedFirstFailure({
1185
+ reason: fix.gateReason,
1186
+ failures: fix.gateFailures
1187
+ });
1188
+ const edited = fix.gateReason !== undefined && stranded.length > 0;
1189
+ if (detail !== null
1190
+ && isNonProgress({
1191
+ previousSignature: prevFailSig,
1192
+ currentDetail: detail,
1193
+ edited
1194
+ })) {
1195
+ demoted.add(normalizeFailureDetail(detail));
1196
+ prevFailSig = null;
1197
+ const debtReason = unobservedDebtReason(detail);
1198
+ await recordFinalGateUnobservedDebt(cwd, id, debtReason);
1199
+ await recGate(`final-gate: check DEMOTED to UNOBSERVED after ${fixAttempts} tree-changing `
1200
+ + `attempts returned an identical failure — carried as debt (origin final-gate) `
1201
+ + `and re-checked by the next run's gate: ${detail.slice(0, 240)}`);
1202
+ active.ui.notify(`${id}: final-gate check is unfalsifiable in this environment — carried as debt; `
1203
+ + 'the remaining checks decide convergence.', 'warning');
1204
+ }
1205
+ else {
1206
+ prevFailSig =
1207
+ detail !== null ? normalizeFailureDetail(detail) : null;
1208
+ }
1209
+ // Convergence on the REMAINING checks: a demoted signature no
1210
+ // longer counts against the gate. Nothing left ⇒ the run
1211
+ // converges carrying the demotion as debt, and the fix passes'
1212
+ // repairs are committed rather than stranded.
1213
+ if (demoted.size > 0 && fix.gateReason !== undefined) {
1214
+ const remaining = applyDemotions(fix.gateFailures ?? [fix.gateReason], demoted);
1215
+ if (remaining.length === 0) {
1216
+ await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
1217
+ const converged = `converged on all remaining checks; ${demoted.size} check(s) `
1218
+ + 'carried as UNOBSERVED debt (unfalsifiable in this environment)';
1219
+ await recGate(`final-gate: ${converged}`);
1220
+ active.ui.notify(`${id}: final integration gate converged — ${converged}.`, 'warning');
1221
+ fin = { ok: true, reason: converged };
1222
+ break;
1223
+ }
1224
+ }
1147
1225
  // Work from the FRESH gate failure when the fix pass got
1148
1226
  // as far as re-running the gate; otherwise keep the last.
1149
1227
  // The full ranked list rides along (and is re-trailed
@@ -1151,10 +1229,22 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1151
1229
  // still carry every entry, not just the first. The debt
1152
1230
  // note is carried so the next picker still shows the
1153
1231
  // open claims (the seed never includes it).
1232
+ // Demoted checks are stripped from what rides forward, so the
1233
+ // next picker and the next fix seed target only what is still
1234
+ // falsifiable — never re-aiming the child at the check the
1235
+ // classifier just proved it cannot move.
1236
+ const freshFailures = fix.gateReason !== undefined ? fix.gateFailures : fin.failures;
1237
+ const carried = freshFailures !== undefined ?
1238
+ applyDemotions(freshFailures, demoted)
1239
+ : undefined;
1154
1240
  fin = {
1155
1241
  ok: false,
1156
- reason: fix.gateReason ?? fin.reason,
1157
- failures: fix.gateReason !== undefined ? fix.gateFailures : fin.failures,
1242
+ reason: (demoted.size > 0
1243
+ && carried !== undefined
1244
+ && carried.length > 0) ?
1245
+ carried[0]
1246
+ : (fix.gateReason ?? fin.reason),
1247
+ failures: carried,
1158
1248
  debtNote: fin.debtNote
1159
1249
  };
1160
1250
  if (fix.gateReason !== undefined
@@ -1168,18 +1258,16 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1168
1258
  await recGate(yoloFinal !== null ?
1169
1259
  `final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
1170
1260
  : 'final-gate: left failed (user)');
1171
- // Leaving the run failed hands the working tree back to the
1172
- // user, so uncommitted fix-pass edits are theirs to keep or
1173
- // drop — but they must be VISIBLE, not discovered later by a
1174
- // stray `git status` (mx5 run 13 item 3).
1175
- if (stranded.length > 0) {
1176
- await recGate(`final-gate: ${stranded.length} uncommitted fix-pass change(s) left in the `
1177
- + `working tree — ${stranded.slice(0, 8).join(', ')}`);
1178
- }
1261
+ // Leaving the run failed is TERMINAL for an unattended run, so
1262
+ // the fix passes' guard-clean repairs are committed here too —
1263
+ // run 14 left 13 of them dirty for a `git checkout` to destroy
1264
+ // (mx5 run 13 item 3, run 14 item 2b). The user still owns the
1265
+ // outcome; they own it with the work in HEAD, named in the trail.
1266
+ await commitStranded('left-failed');
1179
1267
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1180
1268
  announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`
1181
1269
  + (stranded.length > 0 ?
1182
- ` NOTE: ${stranded.length} uncommitted fix-pass change(s) are in your working tree (${stranded.slice(0, 4).join(', ')}).`
1270
+ ` NOTE: ${stranded.length} fix-pass change(s) were committed separately (${stranded.slice(0, 4).join(', ')}).`
1183
1271
  : ''), 'error');
1184
1272
  return;
1185
1273
  }
@@ -12,6 +12,8 @@ import { childBaseArgs } from '../shared/child-extensions.js';
12
12
  import { LoopDetector } from './loop-detector.js';
13
13
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
14
14
  import { readSection, setTaskSection } from './task-io.js';
15
+ import { streamStallCause } from '../shared/stream-watchdog.js';
16
+ import { getConfig } from '../config/config.js';
15
17
  // ─── Loop detection constants ────────────────────────────────────────────────
16
18
  // Defined here (not in phases.ts) to avoid a circular dependency:
17
19
  // phases.ts → child-runner.ts → phases.ts
@@ -77,6 +79,11 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
77
79
  let loopHit;
78
80
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
79
81
  mode: 'json-events',
82
+ // A hung model stream reports nothing at all, so without this the
83
+ // phase child waits forever (mx5 run 14: ~2.9h of dead air). The kill
84
+ // is reported below as a connection-class cause, which routes it into
85
+ // the retry/backoff path this file already has for a LOUD disconnect.
86
+ streamInactivityMs: getConfig().streamInactivityMs,
80
87
  onLine,
81
88
  onContextUsage,
82
89
  onToolCall: call => {
@@ -95,12 +102,21 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
95
102
  // fails with the unhelpful "X child produced no output" — the raw
96
103
  // stdout/stderr that might contain the real error is discarded.
97
104
  const text = result.text || result.stdout.trim();
105
+ // The stream watchdog's kill leaves no provider error to report (that is the
106
+ // whole failure mode), so name it here rather than letting it surface as the
107
+ // meaningless "produced no output". Never overwrite a real reported cause.
108
+ const modelError = result.modelError
109
+ ?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
98
110
  return {
99
111
  text,
100
- exitCode: result.exitCode,
112
+ // WE killed this child, so its exit status describes our own SIGTERM, not
113
+ // the child's verdict. Report 0 and let `modelError` carry the cause —
114
+ // otherwise the wrappers' `exitCode !== 0` guard throws a bare "child
115
+ // failed" before the connection-error retry ever gets to look.
116
+ exitCode: result.streamStalled ? 0 : result.exitCode,
101
117
  stderr: result.stderr.trim(),
102
118
  loopHit,
103
- modelError: result.modelError,
119
+ modelError,
104
120
  // A tool call the model wrote as text (wrong dialect) never executed and
105
121
  // sailed past the structured-event guards above; flag it so the wrappers
106
122
  // can re-prompt instead of accepting the unexecuted call. Only meaningful
@@ -75,9 +75,18 @@ export declare function parseFinalFixMarker(text: string): {
75
75
  *
76
76
  * The rule: a partial fix is either committed (its own commit, named in the trail) or
77
77
  * explicitly surfaced — never silently stranded.
78
+ *
79
+ * mx5 run 14 proved the committing half only ever ran on the ACCEPT path. The run
80
+ * ended on LEAVE (YOLO, budget spent) and 13 real repairs — the dev script, the
81
+ * teardown bug, test serialization, the migrate script: the changes that make that
82
+ * app work today — were left dirty in the tree, one `git checkout` from gone, after
83
+ * an UNATTENDED run nobody was watching. So EVERY terminal non-converged outcome
84
+ * commits them now, not just ACCEPT. Only guard-CLEAN edits qualify: an attempt a
85
+ * write-guard rejected without discarding leaves REJECTED edits in the tree, and
86
+ * those are never committed (the guard is not weakened to make committing easier).
78
87
  */
79
- /** Commit subject for partial fixes committed alongside an accepted gate FAIL. */
80
- export declare const STRANDED_FIX_COMMIT: (runId: string) => string;
88
+ /** Commit subject for partial fixes committed alongside a terminal gate FAIL. */
89
+ export declare const STRANDED_FIX_COMMIT: (runId: string, outcome?: "accepted" | "left-failed") => string;
81
90
  /**
82
91
  * The picker/trail line describing what a non-converging fix pass left behind.
83
92
  * Empty string when the tree is clean — the caller then says nothing at all.
@@ -94,6 +103,13 @@ export interface FinalFixResult {
94
103
  /** The fresh gate's individual ranked failures (see FinalGateOutcome.failures),
95
104
  * so the caller can trail each entry — never just the first. */
96
105
  gateFailures?: string[];
106
+ /** A write-guard rejected this attempt (deletion / shrink / probe-gaming). */
107
+ guardTripped?: boolean;
108
+ /** …and its edits were discarded. When a guard tripped and this is false, the
109
+ * working tree still holds REJECTED edits, so the caller must NOT commit what
110
+ * it finds there (mx5 run 14 item 2b: commit surviving fix-pass edits — but
111
+ * only guard-CLEAN ones; the cheat guard is never weakened to ease committing). */
112
+ editsDiscarded?: boolean;
97
113
  }
98
114
  export interface FinalFixDeps {
99
115
  cwd: string;
@@ -178,9 +178,20 @@ export function parseFinalFixMarker(text) {
178
178
  *
179
179
  * The rule: a partial fix is either committed (its own commit, named in the trail) or
180
180
  * explicitly surfaced — never silently stranded.
181
+ *
182
+ * mx5 run 14 proved the committing half only ever ran on the ACCEPT path. The run
183
+ * ended on LEAVE (YOLO, budget spent) and 13 real repairs — the dev script, the
184
+ * teardown bug, test serialization, the migrate script: the changes that make that
185
+ * app work today — were left dirty in the tree, one `git checkout` from gone, after
186
+ * an UNATTENDED run nobody was watching. So EVERY terminal non-converged outcome
187
+ * commits them now, not just ACCEPT. Only guard-CLEAN edits qualify: an attempt a
188
+ * write-guard rejected without discarding leaves REJECTED edits in the tree, and
189
+ * those are never committed (the guard is not weakened to make committing easier).
181
190
  */
182
- /** Commit subject for partial fixes committed alongside an accepted gate FAIL. */
183
- export const STRANDED_FIX_COMMIT = (runId) => `FINAL GATE PARTIAL FIX (${runId}) — accepted with gate still failing`;
191
+ /** Commit subject for partial fixes committed alongside a terminal gate FAIL. */
192
+ export const STRANDED_FIX_COMMIT = (runId, outcome = 'accepted') => `FINAL GATE PARTIAL FIX (${runId}) — ${outcome === 'accepted' ?
193
+ 'accepted with gate still failing'
194
+ : 'run left failed, repairs preserved'}`;
184
195
  /**
185
196
  * The picker/trail line describing what a non-converging fix pass left behind.
186
197
  * Empty string when the tree is clean — the caller then says nothing at all.
@@ -190,9 +201,9 @@ export function strandedFixNote(paths) {
190
201
  return '';
191
202
  const shown = paths.slice(0, 8).join(', ');
192
203
  return (`\n\nUNCOMMITTED: the fix pass left ${paths.length} change(s) in the working tree `
193
- + `(${shown}${paths.length > 8 ? ', …' : ''}). These are NOT in HEAD. Accepting will `
194
- + `commit them as their own commit so they are not lost; leaving the run failed keeps `
195
- + `them in your working tree.`);
204
+ + `(${shown}${paths.length > 8 ? ', …' : ''}). These are NOT in HEAD. Either terminal `
205
+ + `choice commits them as their own, named commit so they cannot be lost to a later `
206
+ + `checkout — the run's outcome is recorded in the gate trail either way.`);
196
207
  }
197
208
  /**
198
209
  * Run one bounded final-gate fix attempt: snapshot discovery → child → write-guard
@@ -214,7 +225,9 @@ export async function runFinalGateAutofix(deps) {
214
225
  }
215
226
  const rejected = (what) => ({
216
227
  ok: false,
217
- reason: `${what} — edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}`
228
+ reason: `${what} — edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}`,
229
+ guardTripped: true,
230
+ editsDiscarded: deps.discard !== undefined
218
231
  });
219
232
  // (Diff capture — what the pass changed, durably — happens at the gate-deps
220
233
  // seam for every write-capable child; here only the guards act on it.)
@@ -0,0 +1,67 @@
1
+ /**
2
+ * final-gate-progress — the non-progress classifier for the final-gate autofix loop.
3
+ *
4
+ * The failure this closes (mx5 run 14, validated from TASK_AUTO_0001.md's gates
5
+ * trail): the gate's boot check asserted "the app never opened a listening socket"
6
+ * in a sandbox where NO tool the probe knows (`ss`, `lsof`) exists — the check was
7
+ * UNFALSIFIABLE there. Three autofix attempts each edited real files, re-ran, and
8
+ * came back with a byte-identical ranked-first failure; the budget was spent on a
9
+ * check no edit could ever move, the two checks that WERE fixable had converged by
10
+ * attempt 2, and the run ended `failed` with 13 genuinely repaired files sitting
11
+ * uncommitted in the working tree.
12
+ *
13
+ * The rule this encodes: an attempt that CHANGED the tree and re-ran the gate, and
14
+ * got the same first failure back as the previous attempt, is evidence about the
15
+ * CHECK, not about the fix. Two identical post-fix results ⇒ the check is
16
+ * env-shaped/unfalsifiable in this environment ⇒ stop paying for it: demote that
17
+ * one check to UNOBSERVED-with-debt (durable, so the NEXT run's gate re-checks it)
18
+ * and let the REMAINING checks decide whether the gate converged. Run 14 replay:
19
+ * checks 2 and 3 were fixed by attempts 1–2, so the run converges with the boot
20
+ * check carried as debt instead of failing with the repairs stranded.
21
+ *
22
+ * Deliberately conservative:
23
+ * - It never fires on attempt 1. The first repeat can be an ordinary
24
+ * didn't-fix-it-yet; only a SECOND identical post-fix result is evidence.
25
+ * - It never fires when the attempt changed nothing (a BLOCKED child, a
26
+ * guard-discarded attempt): with no edit there is nothing to conclude about
27
+ * falsifiability.
28
+ * - It demotes exactly the one repeated check. Every other failure still has to
29
+ * pass for real; the debt keeps the demoted one visible at run end and in the
30
+ * next run's gate.
31
+ */
32
+ /**
33
+ * Comparison key for a gate failure entry: lowercased, volatile substrings erased,
34
+ * whitespace collapsed. Two entries with the same key are "the same failure" for
35
+ * non-progress purposes.
36
+ */
37
+ export declare function normalizeFailureDetail(detail: string): string;
38
+ /** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
39
+ * first; a wiring without a list degrades to the single reason). */
40
+ export declare function rankedFirstFailure(outcome: {
41
+ reason?: string;
42
+ failures?: string[];
43
+ }): string | null;
44
+ export interface NonProgressInput {
45
+ /** Normalized ranked-first failure of the PREVIOUS attempt's gate re-run
46
+ * (null before any attempt has produced one — the classifier cannot fire). */
47
+ previousSignature: string | null;
48
+ /** This attempt's ranked-first failure, raw. */
49
+ currentDetail: string | null;
50
+ /** Did this attempt actually change the tree AND survive the guards? Only an
51
+ * attempt that edited and re-tested says anything about falsifiability. */
52
+ edited: boolean;
53
+ }
54
+ /**
55
+ * True when this attempt is evidence that the ranked-first check is unfalsifiable
56
+ * here: it edited the tree, the gate re-ran, and returned the same first failure
57
+ * as the previous attempt.
58
+ */
59
+ export declare function isNonProgress(input: NonProgressInput): boolean;
60
+ /**
61
+ * Drop every failure entry matching an already-demoted signature. What survives is
62
+ * what still has to pass for the gate to converge; empty ⇒ converged-with-debt.
63
+ */
64
+ export declare function applyDemotions(failures: string[], demoted: ReadonlySet<string>): string[];
65
+ /** The debt reason recorded for a demoted check, and the gate-trail wording. Kept
66
+ * in one place so the ledger line and the trail line cannot drift apart. */
67
+ export declare function unobservedDebtReason(detail: string): string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * final-gate-progress — the non-progress classifier for the final-gate autofix loop.
3
+ *
4
+ * The failure this closes (mx5 run 14, validated from TASK_AUTO_0001.md's gates
5
+ * trail): the gate's boot check asserted "the app never opened a listening socket"
6
+ * in a sandbox where NO tool the probe knows (`ss`, `lsof`) exists — the check was
7
+ * UNFALSIFIABLE there. Three autofix attempts each edited real files, re-ran, and
8
+ * came back with a byte-identical ranked-first failure; the budget was spent on a
9
+ * check no edit could ever move, the two checks that WERE fixable had converged by
10
+ * attempt 2, and the run ended `failed` with 13 genuinely repaired files sitting
11
+ * uncommitted in the working tree.
12
+ *
13
+ * The rule this encodes: an attempt that CHANGED the tree and re-ran the gate, and
14
+ * got the same first failure back as the previous attempt, is evidence about the
15
+ * CHECK, not about the fix. Two identical post-fix results ⇒ the check is
16
+ * env-shaped/unfalsifiable in this environment ⇒ stop paying for it: demote that
17
+ * one check to UNOBSERVED-with-debt (durable, so the NEXT run's gate re-checks it)
18
+ * and let the REMAINING checks decide whether the gate converged. Run 14 replay:
19
+ * checks 2 and 3 were fixed by attempts 1–2, so the run converges with the boot
20
+ * check carried as debt instead of failing with the repairs stranded.
21
+ *
22
+ * Deliberately conservative:
23
+ * - It never fires on attempt 1. The first repeat can be an ordinary
24
+ * didn't-fix-it-yet; only a SECOND identical post-fix result is evidence.
25
+ * - It never fires when the attempt changed nothing (a BLOCKED child, a
26
+ * guard-discarded attempt): with no edit there is nothing to conclude about
27
+ * falsifiability.
28
+ * - It demotes exactly the one repeated check. Every other failure still has to
29
+ * pass for real; the debt keeps the demoted one visible at run end and in the
30
+ * next run's gate.
31
+ */
32
+ /**
33
+ * Volatile substrings that differ between two runs of the SAME failing check —
34
+ * ports, timings, pids, temp paths, timestamps, hex ids. They must be erased
35
+ * before comparing, or the classifier never fires on a real repeat (a boot probe
36
+ * that prints the port it tried, a test runner that prints elapsed ms).
37
+ *
38
+ * Ordinary integers are NOT collapsed: "3 tests failed" → "1 test failed" is real
39
+ * progress and must stay visible as a difference.
40
+ */
41
+ const VOLATILE = [
42
+ // ISO-ish timestamps, then clock times.
43
+ [/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}(?:\.\d+)?z?/g, '<ts>'],
44
+ [/\b\d{1,2}:\d{2}:\d{2}(?:\.\d+)?\b/g, '<time>'],
45
+ // Temp dirs (posix + macOS + windows) up to the next whitespace/quote.
46
+ [/(?:\/tmp|\/private\/var\/folders|\/var\/folders)\/[^\s'"`)]+/g, '<tmp>'],
47
+ [/[a-z]:\\+(?:users\\+[^\s'"`)\\]+\\+)?appdata\\+local\\+temp\\+[^\s'"`)]+/g, '<tmp>'],
48
+ // Durations with a unit.
49
+ [/\b\d+(?:\.\d+)?\s?(?:ms|µs|us|ns|s|m|h|sec|secs|seconds?|minutes?|hours?)\b/g, '<dur>'],
50
+ // pids, addresses and ports (address first — it subsumes the port form).
51
+ [/\bpids?\s*[:=]?\s*\d+/g, '<pid>'],
52
+ [/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]):\d+/g, '<addr>'],
53
+ [/\bport\s*[:=]?\s*\d{2,5}\b/g, '<port>'],
54
+ [/:\d{2,5}\b/g, ':<port>'],
55
+ // Long hex / uuid-ish ids (sha, container id, request id).
56
+ [/\b0x[0-9a-f]+\b/g, '<hex>'],
57
+ [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, '<uuid>'],
58
+ [/\b[0-9a-f]{7,40}\b/g, '<hex>']
59
+ ];
60
+ /**
61
+ * Comparison key for a gate failure entry: lowercased, volatile substrings erased,
62
+ * whitespace collapsed. Two entries with the same key are "the same failure" for
63
+ * non-progress purposes.
64
+ */
65
+ export function normalizeFailureDetail(detail) {
66
+ let s = detail.toLowerCase();
67
+ for (const [re, repl] of VOLATILE)
68
+ s = s.replace(re, repl);
69
+ return s.replace(/\s+/g, ' ').trim();
70
+ }
71
+ /** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
72
+ * first; a wiring without a list degrades to the single reason). */
73
+ export function rankedFirstFailure(outcome) {
74
+ const first = outcome.failures?.[0] ?? outcome.reason;
75
+ const t = first?.trim();
76
+ return t && t.length > 0 ? t : null;
77
+ }
78
+ /**
79
+ * True when this attempt is evidence that the ranked-first check is unfalsifiable
80
+ * here: it edited the tree, the gate re-ran, and returned the same first failure
81
+ * as the previous attempt.
82
+ */
83
+ export function isNonProgress(input) {
84
+ if (!input.edited)
85
+ return false;
86
+ if (input.previousSignature === null || input.currentDetail === null)
87
+ return false;
88
+ return normalizeFailureDetail(input.currentDetail) === input.previousSignature;
89
+ }
90
+ /**
91
+ * Drop every failure entry matching an already-demoted signature. What survives is
92
+ * what still has to pass for the gate to converge; empty ⇒ converged-with-debt.
93
+ */
94
+ export function applyDemotions(failures, demoted) {
95
+ if (demoted.size === 0)
96
+ return [...failures];
97
+ return failures.filter(f => !demoted.has(normalizeFailureDetail(f)));
98
+ }
99
+ /** The debt reason recorded for a demoted check, and the gate-trail wording. Kept
100
+ * in one place so the ledger line and the trail line cannot drift apart. */
101
+ export function unobservedDebtReason(detail) {
102
+ return (`final gate check UNOBSERVED — ${detail.trim().slice(0, 200)} — `
103
+ + 'repeated identically across two fix attempts that both changed the tree and '
104
+ + 're-ran the gate; treated as unfalsifiable in this environment and carried as '
105
+ + 'debt (re-checked by the next run’s gate), not as a proven defect');
106
+ }
@@ -391,6 +391,11 @@ export function buildGateDeps(params) {
391
391
  // ceiling the main session uses, so one /task-config knob
392
392
  // covers implementation and gates alike.
393
393
  commandTimeoutMs: getConfig().requestTimeoutMs,
394
+ // Same reasoning one level up: a gate child with no
395
+ // wall-clock cap also needs the HUNG-STREAM bound, which
396
+ // the probe-based stall guard structurally cannot supply
397
+ // (a healthy endpoint reads as proof of life).
398
+ streamInactivityMs: getConfig().streamInactivityMs,
394
399
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
395
400
  onLine: line => {
396
401
  lastLine = line;
@@ -538,6 +543,9 @@ export function buildGateDeps(params) {
538
543
  // bash — wired anyway so a future tool grant can't quietly
539
544
  // re-open the hole.
540
545
  commandTimeoutMs: getConfig().requestTimeoutMs,
546
+ // Unbounded wall clock here too — the hung-stream
547
+ // bound is the only thing that ends a dead stream.
548
+ streamInactivityMs: getConfig().streamInactivityMs,
541
549
  // Exact-match loop guard only: pathThreshold Infinity
542
550
  // disables the path-revisit heuristic, so revisiting one
543
551
  // file (which IS this pass's job) never trips — only a
@@ -0,0 +1,32 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /**
3
+ * MAIN-SESSION adapter for the model-stream watchdog.
4
+ *
5
+ * WHY (mx5 run 14): three implementation turns died mid-turn — the session jsonl's
6
+ * last record is an ordinary assistant message, then silence forever, while the
7
+ * model container stayed Up(healthy). No error is ever thrown for this shape, so
8
+ * the connection-error retry (which needs a reported ModelError) cannot fire and
9
+ * the command watchdog, which only covers tool executions, never arms. The run sat
10
+ * dead for ~2.9h across the three until a human restarted it.
11
+ *
12
+ * HOW: pi's extension events ARE the stream. Any of them — a token delta, a
13
+ * thinking delta, a tool-call delta, the provider's response headers — resets the
14
+ * idle clock; only total silence for the configured window fires. On fire the turn
15
+ * is aborted and a follow-up user turn tells the model to CONTINUE from the
16
+ * transcript (its completed tool calls and results are already recorded, so a
17
+ * blind re-send would re-run them).
18
+ *
19
+ * ONE ABORT CHANNEL: the fire path goes through the command watchdog's existing
20
+ * {@link noteWatchdogAbort} flag and its WATCHDOG_CANCEL_MARKER, so
21
+ * steerUntilDone's already-fixed abort/steer race (b543d15) covers this watchdog
22
+ * too instead of racing a second, parallel abort mechanism.
23
+ *
24
+ * SUSPENDED DURING TOOLS: while a tool executes the model stream is legitimately
25
+ * idle — a 12-minute build emits nothing. That window belongs to the command
26
+ * watchdog (requestTimeoutMs); this one pauses between tool_execution_start and
27
+ * tool_execution_end so the two can never double-fire on the same silence.
28
+ *
29
+ * SCOPE: main session only. Children run `--no-extensions`, so their equivalent
30
+ * guard lives in runChild (shared/child-process.ts) and shares the same machine.
31
+ */
32
+ export declare function registerStreamWatchdog(pi: ExtensionAPI): void;