@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.
- package/dist/shared/child-process.js +9 -16
- package/dist/task/accept-debt.d.ts +7 -5
- package/dist/task/accept-debt.js +16 -13
- package/dist/task/auto-orchestrator.js +38 -36
- package/dist/task/autofix-ledger.d.ts +113 -0
- package/dist/task/autofix-ledger.js +152 -0
- package/dist/task/boot-probe.d.ts +63 -1
- package/dist/task/boot-probe.js +98 -2
- package/dist/task/child-runner.d.ts +50 -6
- package/dist/task/child-runner.js +48 -69
- package/dist/task/command-run.d.ts +49 -6
- package/dist/task/command-run.js +154 -18
- package/dist/task/external-context.d.ts +9 -12
- package/dist/task/external-context.js +5 -5
- package/dist/task/failure-classifier.d.ts +9 -1
- package/dist/task/failure-classifier.js +9 -0
- package/dist/task/final-gate-fix.d.ts +22 -26
- package/dist/task/final-gate-fix.js +2 -7
- package/dist/task/final-gate.d.ts +10 -2
- package/dist/task/final-gate.js +49 -88
- package/dist/task/gate-deps.js +20 -13
- package/dist/task/orchestrator.d.ts +33 -24
- package/dist/task/orchestrator.js +66 -44
- package/dist/task/phases.d.ts +58 -34
- package/dist/task/phases.js +140 -113
- package/dist/task/plan-orchestrator.js +2 -2
- package/dist/task/repo-health-check.d.ts +21 -21
- package/dist/task/repo-health-check.js +43 -112
- package/dist/task/run-end.d.ts +77 -0
- package/dist/task/run-end.js +37 -0
- package/dist/task/run-final-gate.js +71 -79
- package/dist/task/task-gates.d.ts +8 -0
- package/dist/task/task-gates.js +23 -4
- package/dist/task/terminal-outcome.d.ts +1 -1
- package/dist/task/terminal-outcome.js +12 -0
- package/dist/workers/brave-search.d.ts +7 -0
- package/dist/workers/brave-search.js +36 -55
- package/dist/workers/ddg-search.d.ts +1 -1
- package/dist/workers/ddg-search.js +27 -47
- package/dist/workers/exa-search.d.ts +2 -2
- package/dist/workers/exa-search.js +53 -68
- package/dist/workers/html-clean.js +67 -88
- package/dist/workers/http-request.d.ts +74 -0
- package/dist/workers/http-request.js +103 -0
- package/dist/workers/npm-version.js +37 -42
- package/dist/workers/pi-worker-core.d.ts +13 -2
- package/dist/workers/pi-worker-core.js +12 -17
- package/dist/workers/pi-worker-docs.d.ts +1 -1
- package/dist/workers/pi-worker-docs.js +49 -68
- package/dist/workers/pi-worker-fetch.d.ts +1 -1
- package/dist/workers/pi-worker-fetch.js +20 -21
- package/dist/workers/pi-worker-search.js +6 -4
- package/dist/workers/pi-worker.js +5 -4
- package/dist/workers/search-core.d.ts +1 -1
- package/dist/workers/search-core.js +36 -42
- package/dist/workers/search-types.d.ts +13 -0
- package/dist/workers/search-types.js +27 -0
- package/dist/workers/shared.d.ts +51 -11
- package/dist/workers/shared.js +0 -0
- package/dist/workers/worker-channels.d.ts +60 -0
- package/dist/workers/worker-channels.js +98 -0
- package/package.json +1 -1
package/dist/task/boot-probe.js
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* the
|
|
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
|
|
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
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* `budget + 1` attempts
|
|
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
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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:
|
|
243
|
-
*
|
|
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
|
-
|
|
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
|
-
+
|
|
336
|
-
: `${name}: looped on ${r.loopHit.call.name} —
|
|
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
|
-
+
|
|
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,
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
49
|
-
*
|
|
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>;
|