@mjasnikovs/pi-task 0.38.15 → 0.38.16

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.
Files changed (62) hide show
  1. package/dist/shared/child-process.js +9 -16
  2. package/dist/task/accept-debt.d.ts +7 -5
  3. package/dist/task/accept-debt.js +16 -13
  4. package/dist/task/auto-orchestrator.js +38 -36
  5. package/dist/task/autofix-ledger.d.ts +113 -0
  6. package/dist/task/autofix-ledger.js +152 -0
  7. package/dist/task/boot-probe.d.ts +63 -1
  8. package/dist/task/boot-probe.js +98 -2
  9. package/dist/task/child-runner.d.ts +50 -6
  10. package/dist/task/child-runner.js +48 -69
  11. package/dist/task/command-run.d.ts +49 -6
  12. package/dist/task/command-run.js +154 -18
  13. package/dist/task/external-context.d.ts +9 -12
  14. package/dist/task/external-context.js +5 -5
  15. package/dist/task/failure-classifier.d.ts +9 -1
  16. package/dist/task/failure-classifier.js +9 -0
  17. package/dist/task/final-gate-fix.d.ts +22 -26
  18. package/dist/task/final-gate-fix.js +2 -7
  19. package/dist/task/final-gate.d.ts +10 -2
  20. package/dist/task/final-gate.js +49 -88
  21. package/dist/task/gate-deps.js +20 -13
  22. package/dist/task/orchestrator.d.ts +33 -24
  23. package/dist/task/orchestrator.js +66 -44
  24. package/dist/task/phases.d.ts +58 -34
  25. package/dist/task/phases.js +140 -113
  26. package/dist/task/plan-orchestrator.js +2 -2
  27. package/dist/task/repo-health-check.d.ts +21 -21
  28. package/dist/task/repo-health-check.js +43 -112
  29. package/dist/task/run-end.d.ts +77 -0
  30. package/dist/task/run-end.js +37 -0
  31. package/dist/task/run-final-gate.js +71 -79
  32. package/dist/task/task-gates.d.ts +8 -0
  33. package/dist/task/task-gates.js +23 -4
  34. package/dist/task/terminal-outcome.d.ts +1 -1
  35. package/dist/task/terminal-outcome.js +12 -0
  36. package/dist/workers/brave-search.d.ts +7 -0
  37. package/dist/workers/brave-search.js +36 -55
  38. package/dist/workers/ddg-search.d.ts +1 -1
  39. package/dist/workers/ddg-search.js +27 -47
  40. package/dist/workers/exa-search.d.ts +2 -2
  41. package/dist/workers/exa-search.js +53 -68
  42. package/dist/workers/html-clean.js +67 -88
  43. package/dist/workers/http-request.d.ts +74 -0
  44. package/dist/workers/http-request.js +103 -0
  45. package/dist/workers/npm-version.js +37 -42
  46. package/dist/workers/pi-worker-core.d.ts +13 -2
  47. package/dist/workers/pi-worker-core.js +12 -17
  48. package/dist/workers/pi-worker-docs.d.ts +1 -1
  49. package/dist/workers/pi-worker-docs.js +49 -68
  50. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  51. package/dist/workers/pi-worker-fetch.js +20 -21
  52. package/dist/workers/pi-worker-search.js +6 -4
  53. package/dist/workers/pi-worker.js +5 -4
  54. package/dist/workers/search-core.d.ts +1 -1
  55. package/dist/workers/search-core.js +36 -42
  56. package/dist/workers/search-types.d.ts +13 -0
  57. package/dist/workers/search-types.js +27 -0
  58. package/dist/workers/shared.d.ts +51 -11
  59. package/dist/workers/shared.js +0 -0
  60. package/dist/workers/worker-channels.d.ts +60 -0
  61. package/dist/workers/worker-channels.js +98 -0
  62. package/package.json +1 -1
@@ -23,10 +23,11 @@ import { spawn, spawnSync } from 'node:child_process';
23
23
  import { existsSync, readFileSync } from 'node:fs';
24
24
  import * as net from 'node:net';
25
25
  import * as path from 'node:path';
26
+ import { runRenderCheck } from './render-check.js';
26
27
  import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
27
28
  import { outputTail } from './command-run.js';
28
29
  import { packageScripts, makeHasTarget } from './launch-manifest.js';
29
- import { collectProjectEnv, pinnedLocalPort } from './deep-render-check.js';
30
+ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
30
31
  /** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
31
32
  function commandTokens(member) {
32
33
  const t = member.trim().split(/\s+/).filter(Boolean);
@@ -791,7 +792,11 @@ export function bootSkipVerdict(args) {
791
792
  * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
792
793
  * to ourselves.
793
794
  */
794
- export async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
795
+ export async function recoverOrphanPort(cwd, boot, first,
796
+ /** One options object, not four trailing positionals — `bootGraceMs` and a
797
+ * boolean sat adjacent and swapped without a type error. */
798
+ opts) {
799
+ const { graceMs: bootGraceMs, deps, expectServer } = opts;
795
800
  if (first.port === null)
796
801
  return first;
797
802
  const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
@@ -804,3 +809,94 @@ export async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, exp
804
809
  await new Promise(r => setTimeout(r, 1_500));
805
810
  return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
806
811
  }
812
+ export async function runBootSection(cwd, opts = {}) {
813
+ const boot = discoverBootCommand(cwd);
814
+ const expectServer = detectsServedApp(cwd, opts.planText);
815
+ const warnings = [];
816
+ if (!boot) {
817
+ // Nothing to boot — but if the reason is that the project's only launch
818
+ // script was REJECTED as not-a-launch (2A), that is not the same thing as a
819
+ // project with no launch surface, and it must not degrade into silence.
820
+ const rejected = rejectedLaunchScript(cwd);
821
+ if (rejected && expectServer) {
822
+ return {
823
+ observed: false,
824
+ warnings,
825
+ unobservedNote: `boot check: this project's only launch script (\`${rejected.name}\`) is not a `
826
+ + `launch — ${rejected.reason} — so nothing was started and the app was never `
827
+ + 'observed to run.'
828
+ };
829
+ }
830
+ return { observed: false, warnings };
831
+ }
832
+ const label = `${boot[0]} ${boot[1].join(' ')}`;
833
+ // Render check (mx5 runs 8/11): for a served app, load the live page in a
834
+ // headless browser and judge the RENDERED DOM — curl can't run JS, so a
835
+ // blank-mount app passed every prior "renders" check. runRenderCheck
836
+ // env-gap-SKIPs when no browser exists, so a box without one never gets a
837
+ // false FAIL.
838
+ //
839
+ // Authenticated deep-render check (mx5 run 17): the page above renders, so now
840
+ // sign in with the account the project's own dotenv declares (the same
841
+ // ADMIN_PHONE/ADMIN_PASSWORD the launch contract's seed step consumes) and
842
+ // require the session to actually work. WEB-ONLY by construction — it hangs off
843
+ // the served-app branch and never runs for C++, Godot, CLI or library projects.
844
+ // It may only FAIL when the SERVER authenticated us; no browser, no
845
+ // credentials, an undrivable form or rejected credentials all skip as env gaps.
846
+ const deps = {
847
+ ...opts.deps,
848
+ renderProbe: opts.deps?.renderProbe ?? runRenderCheck,
849
+ deepRenderProbe: opts.deps?.deepRenderProbe ?? (url => runDeepRenderCheck(url, cwd)),
850
+ preferredPort: opts.deps?.preferredPort ?? (() => preferredDeclaredPort(cwd))
851
+ };
852
+ let b = await runBootCheck(cwd, boot, opts.graceMs, { expectServer, deps });
853
+ if (b.outcome === 'orphan-port') {
854
+ b = await recoverOrphanPort(cwd, boot, b, {
855
+ ...(opts.graceMs === undefined ? {} : { graceMs: opts.graceMs }),
856
+ deps,
857
+ expectServer
858
+ });
859
+ }
860
+ const verdict = {
861
+ attempted: boot[0],
862
+ observed: b.outcome !== 'skip',
863
+ warnings,
864
+ ...(b.outcome === 'skip' && b.spawnFailed ? { spawnFailedBin: boot[0] } : {})
865
+ };
866
+ const unobserved = bootSkipVerdict({ label, skipped: b.outcome === 'skip', expectServer });
867
+ if (unobserved !== null)
868
+ verdict.unobservedNote = unobserved;
869
+ if (b.outcome === 'fail') {
870
+ // OBSERVED (nexttask 19A). Every path that produces `fail` here is a probe
871
+ // that looked: the render judge saw an empty body, the deep session saw the
872
+ // authenticated half dead, the enumerator saw no listener, or the launch
873
+ // command itself exited non-zero. The one condition that means "we could not
874
+ // look" — no ss/netstat/lsof, mx5 run 14 — returns PASS stamped UNOBSERVED
875
+ // and never reaches here.
876
+ verdict.failure = { detail: `boot check: \`${label}\` ${b.detail}`, rank: 0, observed: true };
877
+ }
878
+ else if (b.outcome === 'orphan-port') {
879
+ // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
880
+ // FAIL: name the port and (when known) the process squatting on it. The
881
+ // holder lookup reads the SAME deps the boot ran under — it used to be a
882
+ // second reach into `BootDeps` from the gate, one layer away from the run.
883
+ const holder = b.port !== null ? (deps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
884
+ const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
885
+ : b.port !== null ? ` — port ${b.port} is held by another process`
886
+ : '';
887
+ verdict.failure = {
888
+ detail: `boot check: \`${label}\` could not bind: orphaned process / port already in `
889
+ + `use${who} (harness condition, not an app fault)`,
890
+ rank: 0,
891
+ observed: false
892
+ };
893
+ }
894
+ else if (b.outcome === 'pass') {
895
+ verdict.ranLabel = label;
896
+ // A listener that served, but whose page could not be OBSERVED to render
897
+ // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
898
+ if (b.renderNote)
899
+ warnings.push(b.renderNote);
900
+ }
901
+ return verdict;
902
+ }
@@ -7,6 +7,11 @@
7
7
  */
8
8
  import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from '../shared/child-process.js';
9
9
  import type { DebugLine } from './debug-log.js';
10
+ import type { RunWorkerInput, RunWorkerResult } from '../workers/pi-worker-core.js';
11
+ import type { docsRaw, docsFocused } from '../workers/docs-core.js';
12
+ import type { fetchRaw, fetchFocused } from '../workers/fetch-core.js';
13
+ import type { npmVersionLookup } from '../workers/npm-version.js';
14
+ import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
10
15
  export declare const LOOP_WINDOW = 20;
11
16
  export declare const LOOP_THRESHOLD = 5;
12
17
  export declare const MAX_LOOP_RESTARTS = 2;
@@ -139,6 +144,38 @@ interface PhaseDeps {
139
144
  * the rungs. This seam is for callers to whom the child is a premise.
140
145
  */
141
146
  runChild?: (name: string, tools: string, prompt: string) => Promise<string>;
147
+ /**
148
+ * Run ONE research worker. Absent (production) → the real `runWorker`.
149
+ *
150
+ * Every decision `runSpec` makes — the three Research retry gates, the
151
+ * fatal/runaway/empty classification, the marker choice, `postProcess` — is a
152
+ * pure function of the returned `RunWorkerResult`, but reaching any of them
153
+ * otherwise requires driving a fake process that emits JSON events.
154
+ *
155
+ * `label` is the worker's name — the same one `recordWorker` trails — because
156
+ * a substitute must answer differently per worker, and the only alternative
157
+ * is matching a marker sentence inside the prompt. Same reason `runChild`
158
+ * takes a name.
159
+ */
160
+ runWorker?: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
161
+ /** The project file inventory handed to every research worker's header. */
162
+ getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
163
+ /** RAW docs lookup — the research phase's EXTERNAL CONTEXT variant. */
164
+ docsRaw?: typeof docsRaw;
165
+ /** RAW url fetch — the research phase's EXTERNAL CONTEXT variant. */
166
+ fetchRaw?: typeof fetchRaw;
167
+ /** Live npm version lookup for the research phase's named deps. */
168
+ npmVersionLookup?: typeof npmVersionLookup;
169
+ /** FOCUSED docs lookup — the grill auto-answer's variant. */
170
+ docsFocused?: typeof docsFocused;
171
+ /** FOCUSED url fetch — the grill auto-answer's variant. */
172
+ fetchFocused?: typeof fetchFocused;
173
+ /**
174
+ * Live web search. ONE field, not two: the research phase and the auto-answer
175
+ * differ in the doc/url worker VARIANT (raw vs focused) and in POLICY, never
176
+ * in how they search — the two dep bags declared it identically.
177
+ */
178
+ searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
142
179
  }
143
180
  export type { PhaseDeps };
144
181
  /**
@@ -165,7 +202,7 @@ export type { PhaseDeps };
165
202
  * All three are checked BEFORE the triage ladder: we killed the child, so its
166
203
  * exit status describes our SIGTERM and says nothing about its verdict.
167
204
  */
168
- export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
205
+ export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string, opts?: PhaseChildOptions): Promise<string>;
169
206
  export declare function formatLoopHint(hit: LoopHit): string;
170
207
  /**
171
208
  * Terminal hint for the degrade attempt: the model has thrashed through the whole
@@ -178,11 +215,19 @@ export declare function formatLoopHint(hit: LoopHit): string;
178
215
  export declare function formatDegradeHint(hit: LoopHit): string;
179
216
  export declare function prependHint(hint: string | null, prompt: string): string;
180
217
  /**
181
- * Run a phase child with loop detection. On a detected loop, kill and re-spawn
182
- * with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
183
- * the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
218
+ * The two things a phase child can disagree about. Everything else the loop
219
+ * and stall detectors, the wall clock, the loop trail, the triage ladder and its
220
+ * budget — is the one loop's, because the two wrappers that used to differ
221
+ * disagreed on nothing else that was ever observable.
184
222
  */
185
- export interface LoopGuardOptions {
223
+ export interface PhaseChildOptions {
224
+ /**
225
+ * The wrapper's own word in the debug log for "we are going round again".
226
+ * An option rather than one word because it is the single externally visible
227
+ * difference between the two loops this collapsed, and the debug trail of a
228
+ * real run is read by a human who knows which phases restart and which retry.
229
+ */
230
+ verb?: 'retry' | 'restart';
186
231
  /**
187
232
  * When the strike budget is exhausted by loops, do NOT fail the phase. Run
188
233
  * ONE final attempt with NO tools and a terminal hint ordering the model to
@@ -194,7 +239,6 @@ export interface LoopGuardOptions {
194
239
  */
195
240
  degradeOnExhaustion?: boolean;
196
241
  }
197
- export declare function runPhaseWithLoopGuard(deps: PhaseDeps, name: string, tools: string, buildPrompt: (loopHint: string | null) => string, opts?: LoopGuardOptions): Promise<string>;
198
242
  /**
199
243
  * Run a child up to twice; the second attempt gets `emphasized=true` to escalate
200
244
  * the prompt. On success, return the validator's value; on two failures, throw
@@ -229,18 +229,18 @@ onToolResult) {
229
229
  * tool call. Callers own the loop, the prompt and the hint; this owns the
230
230
  * verdict, so a fix to any rung lands in every caller at once.
231
231
  *
232
- * `attempt` is the caller's 0-based counter (its attempt/strike), `budget` the
233
- * matching restart allowance (MAX_LEAK_RETRIES for runPhaseChild's leak budget,
234
- * MAX_LOOP_RESTARTS for runPhaseWithLoopGuard's strike budget) so both run
235
- * `budget + 1` attempts in total before a rung gives up and throws.
232
+ * `attempt` is the caller's 0-based counter, `budget` the matching restart
233
+ * allowance (MAX_LEAK_RETRIES, which is also MAX_LOOP_RESTARTS — the loop and
234
+ * leak budgets were separate constants at the same value and are shared by one
235
+ * loop now) — so a phase runs `budget + 1` attempts before a rung gives up.
236
236
  *
237
- * `verb` names the caller's restart in the debug log ("retry" for runPhaseChild,
238
- * "restart" for runPhaseWithLoopGuard). It is the only externally visible thing
239
- * that differs between the two, and the only way to tell from a debug log which
240
- * wrapper produced a given line — so it is passed in rather than hardcoded.
237
+ * `verb` names the restart in the debug log ("retry" by default, "restart" for
238
+ * refine and grill-gen). It is the only externally visible thing that differed
239
+ * between the two loops this collapsed, and the only way to tell from a debug
240
+ * log which phase produced a line — so it is passed in rather than hardcoded.
241
241
  *
242
- * A loop kill (`r.loopHit`) is NOT handled here: only runPhaseWithLoopGuard
243
- * detects loops, and it must consume the hit before calling this.
242
+ * A loop kill (`r.loopHit`) is NOT handled here: the caller detects loops and
243
+ * must consume the hit before calling this.
244
244
  */
245
245
  async function triageChildResult(deps, name, r, attempt, budget, verb) {
246
246
  if (r.exitCode !== 0) {
@@ -303,13 +303,17 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
303
303
  * All three are checked BEFORE the triage ladder: we killed the child, so its
304
304
  * exit status describes our SIGTERM and says nothing about its verdict.
305
305
  */
306
- export async function runPhaseChild(deps, name, tools, prompt) {
306
+ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
307
307
  if (deps.runChild)
308
308
  return await deps.runChild(name, tools, prompt);
309
+ const verb = opts.verb ?? 'retry';
309
310
  let hint = null;
310
311
  const loopHistory = [];
311
312
  const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
312
313
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
314
+ // A cancel between attempts must not buy another spawn.
315
+ if (deps.signal.aborted)
316
+ throw new Error(USER_CANCELLED);
313
317
  const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
314
318
  const stall = new StallDetector();
315
319
  const clock = phaseTimeout(deps.signal, budgetMs);
@@ -327,13 +331,23 @@ export async function runPhaseChild(deps, name, tools, prompt) {
327
331
  if (deps.signal.aborted)
328
332
  throw new Error(USER_CANCELLED);
329
333
  if (r.loopHit) {
334
+ const isLastStrike = attempt === MAX_LEAK_RETRIES;
330
335
  loopHistory.push(r.loopHit);
331
- if (attempt === MAX_LEAK_RETRIES)
336
+ await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, attempt + 1, isLastStrike ?
337
+ opts.degradeOnExhaustion ?
338
+ 'degraded — no-tools final attempt'
339
+ : 'phase failed'
340
+ : 'restarted with hint');
341
+ if (isLastStrike) {
342
+ if (opts.degradeOnExhaustion) {
343
+ return await runDegradedFinalAttempt(deps, name, prompt, r.loopHit, loopHistory);
344
+ }
332
345
  throw new LoopExhaustedError(name, loopHistory);
346
+ }
333
347
  deps.logDebug?.(r.loopHit.stall ?
334
348
  `${name}: stalled (${r.loopHit.stall}) on ${r.loopHit.call.name} — `
335
- + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`
336
- : `${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
349
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`
350
+ : `${name}: looped on ${r.loopHit.call.name} — ${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
337
351
  hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
338
352
  continue;
339
353
  }
@@ -342,11 +356,11 @@ export async function runPhaseChild(deps, name, tools, prompt) {
342
356
  throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
343
357
  }
344
358
  deps.logDebug?.(`${name}: exceeded its ${Math.round(budgetMs / 1000)}s budget — `
345
- + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
359
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
346
360
  hint = PHASE_TIMEOUT_HINT;
347
361
  continue;
348
362
  }
349
- const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, 'retry');
363
+ const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, verb);
350
364
  if (step.done)
351
365
  return step.text;
352
366
  if (step.hint !== undefined)
@@ -381,63 +395,28 @@ export function formatDegradeHint(hit) {
381
395
  export function prependHint(hint, prompt) {
382
396
  return hint === null ? prompt : `${hint}\n\n${prompt}`;
383
397
  }
398
+ /**
399
+ * Append one line to the task file's `loop events` section.
400
+ *
401
+ * Best-effort by contract: it runs for EVERY phase child now that there is one
402
+ * loop, and the six sites that used to reach the un-trailed wrapper do not all
403
+ * own a task file on disk (a scripted harness, a bare unit deps bag). A trail
404
+ * that cannot be written must cost the phase nothing — the loop kill itself is
405
+ * already reported through the debug log and the thrown LoopExhaustedError.
406
+ */
384
407
  async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
385
408
  const ts = new Date().toISOString();
386
409
  const argsStr = JSON.stringify(hit.call.args);
387
410
  const line = `- ${ts} ${phase} strike ${strike}/${MAX_LOOP_RESTARTS + 1} `
388
411
  + `${hit.call.name}(${argsStr}) ×${hit.count} in last ${hit.windowSize} calls → ${outcome}`;
389
- const existing = (await readSection(cwd, taskId, 'loop events')) ?? '';
390
- const next = existing ? `${existing}\n${line}` : line;
391
- await setTaskSection(cwd, taskId, 'loop events', next);
392
- }
393
- export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts = {}) {
394
- // The substitute stands in for the whole guarded run, so it is handed the
395
- // prompt the first strike would have used (no loop hint in flight yet).
396
- if (deps.runChild)
397
- return await deps.runChild(name, tools, buildPrompt(null));
398
- const loopHistory = [];
399
- // Carries the correction hint (loop OR leaked-tool-call) into the next strike.
400
- let nextHint = null;
401
- for (let strike = 0; strike <= MAX_LOOP_RESTARTS; strike++) {
402
- if (deps.signal.aborted)
403
- throw new Error(USER_CANCELLED);
404
- const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
405
- const stall = new StallDetector();
406
- const prompt = buildPrompt(nextHint);
407
- const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, snapshot => {
408
- stall.noteContext(snapshot.contextWindow);
409
- deps.onContextUsage?.(snapshot);
410
- }, call => detector.record(call) ?? stall.record(call), deps.spawn, undefined, (text, isError) => stall.noteResult(text, isError));
411
- if (deps.signal.aborted)
412
- throw new Error(USER_CANCELLED);
413
- if (r.loopHit) {
414
- const isLastStrike = strike === MAX_LOOP_RESTARTS;
415
- loopHistory.push(r.loopHit);
416
- const lastOutcome = opts.degradeOnExhaustion ? 'degraded — no-tools final attempt' : 'phase failed';
417
- await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, strike + 1, isLastStrike ? lastOutcome : 'restarted with hint');
418
- if (isLastStrike) {
419
- if (opts.degradeOnExhaustion) {
420
- return await runDegradedFinalAttempt(deps, name, buildPrompt, r.loopHit, loopHistory);
421
- }
422
- throw new LoopExhaustedError(name, loopHistory);
423
- }
424
- nextHint =
425
- r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
426
- continue;
427
- }
428
- // Everything past the loop kill is the shared ladder: exit code, model
429
- // error (connection-class restarts within the strike budget), empty
430
- // completion, leaked tool call. The strike budget is shared with the
431
- // loop restarts above — MAX_LOOP_RESTARTS+1 attempts across all causes.
432
- const step = await triageChildResult(deps, name, r, strike, MAX_LOOP_RESTARTS, 'restart');
433
- if (step.done)
434
- return step.text;
435
- // Only a leak produces a new correction hint; the other rungs have
436
- // nothing to correct and leave any loop hint already in flight alone.
437
- if (step.hint !== undefined)
438
- nextHint = step.hint;
412
+ try {
413
+ const existing = (await readSection(cwd, taskId, 'loop events')) ?? '';
414
+ const next = existing ? `${existing}\n${line}` : line;
415
+ await setTaskSection(cwd, taskId, 'loop events', next);
416
+ }
417
+ catch {
418
+ /* best-effort: a trail is never worth failing a phase for */
439
419
  }
440
- throw new LoopExhaustedError(name, loopHistory);
441
420
  }
442
421
  /**
443
422
  * Final degrade attempt after the loop budget is spent: re-spawn the child with
@@ -448,10 +427,10 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
448
427
  * to the original LoopExhaustedError so the phase still fails honestly when even
449
428
  * the degrade produces nothing.
450
429
  */
451
- async function runDegradedFinalAttempt(deps, name, buildPrompt, hit, loopHistory) {
430
+ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
452
431
  deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
453
432
  const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
454
- buildPrompt(formatDegradeHint(hit)), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
433
+ prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
455
434
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
456
435
  throw new LoopExhaustedError(name, loopHistory);
457
436
  }
@@ -45,18 +45,41 @@ export interface CommandSpec {
45
45
  args: string[];
46
46
  timeoutMs: number;
47
47
  /**
48
- * Replaces the child's environment wholesale. Passed explicitly because bun's
49
- * spawnSync resolves the binary against a startup snapshot of the environment
48
+ * Replaces the child's environment wholesale. Passed explicitly because a
49
+ * spawn resolves the binary against a startup snapshot of the environment
50
50
  * rather than the live `process.env`.
51
51
  */
52
52
  env?: Record<string, string | undefined>;
53
+ /** The caller's cancel. Kills the child; the run reads as `status: null`. */
54
+ signal?: AbortSignal;
53
55
  }
54
56
  /**
55
57
  * The injectable half. The gate takes one of these so its tests can script
56
58
  * verdicts instead of paying process-spawn cost for every classification case.
59
+ *
60
+ * ASYNC by contract. It was `(spec) => CommandRun`, so the only implementation
61
+ * could be `spawnSync`, and the run-end gate blocked the event loop end to end:
62
+ * repo-health under a 600s cap, then every lockfile/test/build/launch command
63
+ * under a 900s cap, then every ACCEPT-debt re-run under a 300s cap, with no
64
+ * loader able to paint through any of it. That freeze is MEASURED — 0 of 686
65
+ * expected 100ms ticks fired during a 69s run — and `repo-health-check.ts`'s own
66
+ * doc comment already told gate callers not to do it, while `final-gate.ts`'s
67
+ * repo-health call did exactly that.
68
+ */
69
+ export type CommandRunner = (spec: CommandSpec) => Promise<CommandRun>;
70
+ /**
71
+ * The real runner: one bounded child, output collected, never rejects.
72
+ *
73
+ * A kill — by the wall clock or by the caller's cancel — reads as `status: null`,
74
+ * which the gap ladder already treats as "nothing was observed".
75
+ *
76
+ * THE RUN SETTLES ON THE CHILD, NOT ON THE PIPE. `close` fires only once every
77
+ * stdio pipe has reached EOF, and a backgrounded grandchild INHERITS stdout: a
78
+ * seed script that starts a daemon, a build that leaves a watcher, a launch
79
+ * script. Waiting for `close` there is waiting for the grandchild, which no
80
+ * timeout can reach — SIGKILL goes to the direct child and the inherited pipe
81
+ * survives it. So `exit` settles the run, and the deadline settles it itself.
57
82
  */
58
- export type CommandRunner = (spec: CommandSpec) => CommandRun;
59
- /** The real runner. */
60
83
  export declare const spawnCommand: CommandRunner;
61
84
  /**
62
85
  * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
@@ -94,6 +117,26 @@ export type CommandVerdict =
94
117
  };
95
118
  /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
96
119
  export declare function outputTail(stdout: string, stderr: string, limit?: number): string;
120
+ /**
121
+ * Which gap rows may be claimed by a command's OUTPUT rather than by the fact
122
+ * that it never ran. Only these can be WRONG about a command that did run, which
123
+ * is why they are the ones a caller opts into.
124
+ */
125
+ export interface ClassifyOptions {
126
+ /**
127
+ * May this command's output claim a MISSING BROWSER/RUNTIME?
128
+ *
129
+ * True by default: the row exists for the gate's TEST commands, where a
130
+ * Playwright suite on a box with no browsers is an environment gap.
131
+ *
132
+ * False for the static ladder. `ENV_GAP_OUTPUT_RE` matches ordinary English
133
+ * — `browsers are not installed`, `wasn't installed` — and repo-health runs
134
+ * lint and typecheck only, which have no browsers to miss. A real lint report
135
+ * that happens to quote that wording would otherwise SKIP the static check
136
+ * and tell the gate the repo is healthy.
137
+ */
138
+ runtimeGap?: boolean;
139
+ }
97
140
  /**
98
141
  * Decide what one finished command proved. Pure — no spawning, no filesystem, no
99
142
  * clock — so every case is stateable as a literal.
@@ -101,7 +144,7 @@ export declare function outputTail(stdout: string, stderr: string, limit?: numbe
101
144
  * `gapPatterns` are the EXTRA output shapes this particular command may treat as
102
145
  * an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
103
146
  */
104
- export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[]): CommandVerdict;
147
+ export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[], opts?: ClassifyOptions): CommandVerdict;
105
148
  /**
106
149
  * How a re-run of ONE recorded VERIFY command line ended.
107
150
  * pass — it ran and exited 0. The ONLY outcome that may close a debt.
@@ -137,4 +180,4 @@ export type VerifyRerunOutcome = {
137
180
  */
138
181
  export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
139
182
  /** The spawner. Injected so a re-run's outcome can be tested without one. */
140
- run?: CommandRunner): VerifyRerunOutcome;
183
+ run?: CommandRunner, signal?: AbortSignal): Promise<VerifyRerunOutcome>;