@mjasnikovs/pi-task 0.38.15 → 0.38.17
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/config/config.d.ts +26 -0
- package/dist/config/config.js +68 -17
- package/dist/shared/child-process.js +9 -16
- package/dist/task/accept-debt.d.ts +7 -5
- package/dist/task/accept-debt.js +18 -14
- package/dist/task/artifact-closure.js +18 -63
- package/dist/task/auto-orchestrator.js +211 -218
- package/dist/task/autofix-ledger.d.ts +113 -0
- package/dist/task/autofix-ledger.js +152 -0
- package/dist/task/boot-probe.d.ts +109 -1
- package/dist/task/boot-probe.js +139 -23
- 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/coverage-loop.d.ts +11 -0
- package/dist/task/coverage-loop.js +16 -0
- 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 +16 -31
- package/dist/task/final-gate.d.ts +10 -2
- package/dist/task/final-gate.js +55 -89
- package/dist/task/fix-child.d.ts +64 -0
- package/dist/task/fix-child.js +66 -0
- package/dist/task/gate-deps.js +20 -13
- package/dist/task/lint-fix.d.ts +7 -0
- package/dist/task/lint-fix.js +45 -9
- package/dist/task/orchestrator.d.ts +33 -24
- package/dist/task/orchestrator.js +75 -46
- package/dist/task/phases.d.ts +120 -34
- package/dist/task/phases.js +221 -134
- package/dist/task/plan-orchestrator.js +2 -2
- package/dist/task/plan-rounds.d.ts +86 -0
- package/dist/task/plan-rounds.js +105 -0
- package/dist/task/plan-session.d.ts +31 -21
- package/dist/task/plan-session.js +97 -120
- package/dist/task/qa-transcript.d.ts +100 -0
- package/dist/task/qa-transcript.js +99 -0
- package/dist/task/question-source.d.ts +117 -0
- package/dist/task/question-source.js +174 -0
- 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/serve-entry.js +6 -57
- package/dist/task/shipped-source.d.ts +67 -0
- package/dist/task/shipped-source.js +144 -0
- package/dist/task/task-gates.d.ts +9 -1
- package/dist/task/task-gates.js +27 -6
- package/dist/task/terminal-outcome.d.ts +1 -1
- package/dist/task/terminal-outcome.js +12 -0
- package/dist/task/verify-work.d.ts +46 -0
- package/dist/task/verify-work.js +51 -3
- 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/docs-core.d.ts +71 -1
- package/dist/workers/docs-core.js +131 -71
- 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 +35 -25
- 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
|
@@ -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>;
|
package/dist/task/command-run.js
CHANGED
|
@@ -27,24 +27,156 @@
|
|
|
27
27
|
* carried nine injectable probes for the gate's boot half while its command half
|
|
28
28
|
* had none.
|
|
29
29
|
*/
|
|
30
|
-
import {
|
|
30
|
+
import { spawn } from 'node:child_process';
|
|
31
31
|
import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
/**
|
|
33
|
+
* How much of ONE stream may be held in the HOST process, and how it is split.
|
|
34
|
+
*
|
|
35
|
+
* `spawnSync` bounded this at its 1 MB default `maxBuffer`. The async runner had
|
|
36
|
+
* no bound at all: two strings grew in the TUI's own process for as long as a
|
|
37
|
+
* command under the 900s cap kept talking.
|
|
38
|
+
*
|
|
39
|
+
* BOTH ENDS are kept, because both are read. `isCommandNotFound` and the two gap
|
|
40
|
+
* regexes match wording a runner prints FIRST; `outputTail` and every failure
|
|
41
|
+
* reason take the LAST 400 characters. A single-ended cap loses one of them.
|
|
42
|
+
*/
|
|
43
|
+
const OUTPUT_HEAD_CAP = 256 * 1024;
|
|
44
|
+
const OUTPUT_TAIL_CAP = 768 * 1024;
|
|
45
|
+
/** One stream, bounded, keeping its head and its tail with the middle elided. */
|
|
46
|
+
class BoundedOutput {
|
|
47
|
+
head = '';
|
|
48
|
+
tail = '';
|
|
49
|
+
total = 0;
|
|
50
|
+
push(chunk) {
|
|
51
|
+
this.total += chunk.length;
|
|
52
|
+
let rest = chunk;
|
|
53
|
+
if (this.head.length < OUTPUT_HEAD_CAP) {
|
|
54
|
+
const room = OUTPUT_HEAD_CAP - this.head.length;
|
|
55
|
+
this.head += rest.slice(0, room);
|
|
56
|
+
rest = rest.slice(room);
|
|
57
|
+
}
|
|
58
|
+
if (rest.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
this.tail = (this.tail + rest).slice(-OUTPUT_TAIL_CAP);
|
|
61
|
+
}
|
|
62
|
+
toString() {
|
|
63
|
+
const elided = this.total - this.head.length - this.tail.length;
|
|
64
|
+
return elided > 0 ?
|
|
65
|
+
`${this.head}\n…[${elided} characters elided]…\n${this.tail}`
|
|
66
|
+
: this.head + this.tail;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* After the child EXITS, how long its pipes may still deliver buffered data
|
|
71
|
+
* before the run is reported. Not a wait for the pipes to CLOSE — that is the
|
|
72
|
+
* bug below — just the turn or two the reader needs to hand over what it has.
|
|
73
|
+
*/
|
|
74
|
+
const DRAIN_MS = 50;
|
|
75
|
+
/**
|
|
76
|
+
* The real runner: one bounded child, output collected, never rejects.
|
|
77
|
+
*
|
|
78
|
+
* A kill — by the wall clock or by the caller's cancel — reads as `status: null`,
|
|
79
|
+
* which the gap ladder already treats as "nothing was observed".
|
|
80
|
+
*
|
|
81
|
+
* THE RUN SETTLES ON THE CHILD, NOT ON THE PIPE. `close` fires only once every
|
|
82
|
+
* stdio pipe has reached EOF, and a backgrounded grandchild INHERITS stdout: a
|
|
83
|
+
* seed script that starts a daemon, a build that leaves a watcher, a launch
|
|
84
|
+
* script. Waiting for `close` there is waiting for the grandchild, which no
|
|
85
|
+
* timeout can reach — SIGKILL goes to the direct child and the inherited pipe
|
|
86
|
+
* survives it. So `exit` settles the run, and the deadline settles it itself.
|
|
87
|
+
*/
|
|
88
|
+
export const spawnCommand = spec => new Promise(resolve => {
|
|
89
|
+
const out = new BoundedOutput();
|
|
90
|
+
const err = new BoundedOutput();
|
|
91
|
+
let settled = false;
|
|
92
|
+
let exitStatus = null;
|
|
93
|
+
let exited = false;
|
|
94
|
+
let endedStreams = 0;
|
|
95
|
+
let drain;
|
|
96
|
+
const child = spawn(spec.bin, spec.args, {
|
|
35
97
|
cwd: spec.cwd,
|
|
36
|
-
|
|
37
|
-
|
|
98
|
+
// stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
|
|
99
|
+
// stdio is a live pipe nobody ever ends, so a check that reads stdin —
|
|
100
|
+
// a `cat`-style pipeline, a tool that prompts, a pager — blocked until
|
|
101
|
+
// the kill timer: 600s for repo-health, 900s for a gate command.
|
|
102
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
103
|
...(spec.env ? { env: spec.env } : {})
|
|
39
104
|
});
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
105
|
+
const done = (status, failure) => {
|
|
106
|
+
if (settled)
|
|
107
|
+
return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
clearTimeout(drain);
|
|
111
|
+
spec.signal?.removeEventListener('abort', killAndSettle);
|
|
112
|
+
resolve({
|
|
113
|
+
failedToStart: failure !== undefined,
|
|
114
|
+
...(failure === undefined ? {} : { failureMessage: failure }),
|
|
115
|
+
status,
|
|
116
|
+
stdout: out.toString(),
|
|
117
|
+
stderr: err.toString()
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
const expectedStreams = (child.stdout ? 1 : 0) + (child.stderr ? 1 : 0);
|
|
121
|
+
const settleIfDrained = () => {
|
|
122
|
+
if (exited && endedStreams >= expectedStreams)
|
|
123
|
+
done(exitStatus);
|
|
124
|
+
};
|
|
125
|
+
const kill = () => {
|
|
126
|
+
try {
|
|
127
|
+
child.kill('SIGKILL');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* already gone */
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* The deadline and the cancel both END the run. The kill only reaches the
|
|
135
|
+
* direct child, so this cannot wait to observe its effect — it kills, gives
|
|
136
|
+
* the pipes one drain, and reports `status: null` regardless.
|
|
137
|
+
*/
|
|
138
|
+
const killAndSettle = () => {
|
|
139
|
+
kill();
|
|
140
|
+
clearTimeout(drain);
|
|
141
|
+
drain = setTimeout(() => done(null), DRAIN_MS);
|
|
46
142
|
};
|
|
47
|
-
|
|
143
|
+
// NOT unref'd. With `spawnSync`'s own `timeout` gone this timer is the only
|
|
144
|
+
// bound left on every gate command, repo-health command and ACCEPT-debt
|
|
145
|
+
// re-run — and an unref'd timer is MEASURED in this repo never to fire at
|
|
146
|
+
// all on Windows (0/20s), which would leave all of them unbounded. It is
|
|
147
|
+
// cleared the moment the run settles, so it holds the loop open only while
|
|
148
|
+
// a command the caller is awaiting anyway is still running.
|
|
149
|
+
const timer = setTimeout(killAndSettle, spec.timeoutMs);
|
|
150
|
+
if (spec.signal) {
|
|
151
|
+
if (spec.signal.aborted)
|
|
152
|
+
killAndSettle();
|
|
153
|
+
else
|
|
154
|
+
spec.signal.addEventListener('abort', killAndSettle, { once: true });
|
|
155
|
+
}
|
|
156
|
+
child.stdout?.on('data', (d) => out.push(d.toString()));
|
|
157
|
+
child.stderr?.on('data', (d) => err.push(d.toString()));
|
|
158
|
+
child.stdout?.on('end', () => {
|
|
159
|
+
endedStreams++;
|
|
160
|
+
settleIfDrained();
|
|
161
|
+
});
|
|
162
|
+
child.stderr?.on('end', () => {
|
|
163
|
+
endedStreams++;
|
|
164
|
+
settleIfDrained();
|
|
165
|
+
});
|
|
166
|
+
child.on('error', (e) => done(null, e.message));
|
|
167
|
+
child.on('exit', (code) => {
|
|
168
|
+
exited = true;
|
|
169
|
+
exitStatus = code;
|
|
170
|
+
// Both ends of the same question: settle now if the pipes are already
|
|
171
|
+
// at EOF, otherwise settle after one short drain rather than waiting on
|
|
172
|
+
// whoever else is holding them.
|
|
173
|
+
settleIfDrained();
|
|
174
|
+
if (!settled) {
|
|
175
|
+
clearTimeout(drain);
|
|
176
|
+
drain = setTimeout(() => done(exitStatus), DRAIN_MS);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
48
180
|
/**
|
|
49
181
|
* A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
|
|
50
182
|
* a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
|
|
@@ -116,14 +248,17 @@ export function outputTail(stdout, stderr, limit = 400) {
|
|
|
116
248
|
* `gapPatterns` are the EXTRA output shapes this particular command may treat as
|
|
117
249
|
* an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
|
|
118
250
|
*/
|
|
119
|
-
export function classifyCommandRun(run, gapPatterns = []) {
|
|
251
|
+
export function classifyCommandRun(run, gapPatterns = [], opts = {}) {
|
|
120
252
|
// A clean exit is a pass before any gap shape is consulted: gap patterns
|
|
121
253
|
// describe output, and passing output can legitimately mention a database or
|
|
122
254
|
// a browser.
|
|
123
255
|
if (!run.failedToStart && run.status === 0)
|
|
124
256
|
return { outcome: 'pass' };
|
|
125
257
|
const output = `${run.stdout}\n${run.stderr}`;
|
|
258
|
+
const runtimeGap = opts.runtimeGap ?? true;
|
|
126
259
|
for (const rule of GAP_RULES) {
|
|
260
|
+
if (rule.id === 'missing-runtime' && !runtimeGap)
|
|
261
|
+
continue;
|
|
127
262
|
if (rule.applies(run, output, gapPatterns)) {
|
|
128
263
|
return { outcome: 'gap', gap: rule.id, detail: rule.detail(run) };
|
|
129
264
|
}
|
|
@@ -160,19 +295,20 @@ function leadingBin(line) {
|
|
|
160
295
|
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
161
296
|
* debt exactly as open as it was.
|
|
162
297
|
*/
|
|
163
|
-
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
|
|
298
|
+
export async function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
|
|
164
299
|
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
165
|
-
run = spawnCommand) {
|
|
300
|
+
run = spawnCommand, signal) {
|
|
166
301
|
const bin = leadingBin(line);
|
|
167
302
|
const runner = bin === null ? null : resolveRunner(bin);
|
|
168
303
|
// A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
|
|
169
304
|
// redirects are all ordinary there — so the runner spawns `sh -c`.
|
|
170
|
-
const verdict = classifyCommandRun(run({
|
|
305
|
+
const verdict = classifyCommandRun(await run({
|
|
171
306
|
cwd,
|
|
172
307
|
bin: 'sh',
|
|
173
308
|
args: ['-c', line],
|
|
174
309
|
timeoutMs,
|
|
175
|
-
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
310
|
+
env: runner ? runnerEnv(runner) : { ...process.env },
|
|
311
|
+
...(signal === undefined ? {} : { signal })
|
|
176
312
|
}),
|
|
177
313
|
// Infrastructure counts as a gap on EVERY debt re-run, not only on
|
|
178
314
|
// request: an unreachable database cannot tell us whether the code is
|
|
@@ -139,3 +139,14 @@ export interface ScoredPlan {
|
|
|
139
139
|
*/
|
|
140
140
|
judgeMissing: string[];
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Normalise a missing-area string for cross-round identity — lowercased alnum
|
|
144
|
+
* words, punctuation and quote-wrapping collapsed. Used only to tell whether an
|
|
145
|
+
* adopted plan introduced a NEW gap versus re-surfacing the same one (the bonus
|
|
146
|
+
* round); intentionally coarse, so trivial rewording of the same area does not
|
|
147
|
+
* read as new and buy an extra round.
|
|
148
|
+
*
|
|
149
|
+
* Lives here rather than in `auto-orchestrator.ts` because its only consumer is
|
|
150
|
+
* `CoverageLedger.consider`, which is the adoption rule this file owns.
|
|
151
|
+
*/
|
|
152
|
+
export declare function normMissingArea(s: string): string;
|
|
@@ -290,3 +290,19 @@ export function decideAdoption(current, retry, hasRequirements) {
|
|
|
290
290
|
}
|
|
291
291
|
return { adopt: true, reason: 'count floor met, no coverage regression', dropped: [] };
|
|
292
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Normalise a missing-area string for cross-round identity — lowercased alnum
|
|
295
|
+
* words, punctuation and quote-wrapping collapsed. Used only to tell whether an
|
|
296
|
+
* adopted plan introduced a NEW gap versus re-surfacing the same one (the bonus
|
|
297
|
+
* round); intentionally coarse, so trivial rewording of the same area does not
|
|
298
|
+
* read as new and buy an extra round.
|
|
299
|
+
*
|
|
300
|
+
* Lives here rather than in `auto-orchestrator.ts` because its only consumer is
|
|
301
|
+
* `CoverageLedger.consider`, which is the adoption rule this file owns.
|
|
302
|
+
*/
|
|
303
|
+
export function normMissingArea(s) {
|
|
304
|
+
return s
|
|
305
|
+
.toLowerCase()
|
|
306
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
307
|
+
.trim();
|
|
308
|
+
}
|
|
@@ -19,12 +19,16 @@
|
|
|
19
19
|
* {@link ExternalContextLookups} — an adapter, expressible only since the
|
|
20
20
|
* focused-extractor seam landed.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
23
|
-
import { fetchRaw } from '../workers/fetch-core.js';
|
|
24
|
-
import { npmVersionLookup, type NpmVersionInfo } from '../workers/npm-version.js';
|
|
22
|
+
import { type NpmVersionInfo } from '../workers/npm-version.js';
|
|
25
23
|
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
26
24
|
import type { PhaseDeps } from './child-runner.js';
|
|
27
|
-
|
|
25
|
+
/**
|
|
26
|
+
* What assembling the block needs off the phase's deps: where it runs, when to
|
|
27
|
+
* stop, where to trail a sub-step — and, for the research binding, the four
|
|
28
|
+
* lookup seams. They are fields on `PhaseDeps` (not a second bag) because the
|
|
29
|
+
* bag was a trailing parameter no production caller could reach.
|
|
30
|
+
*/
|
|
31
|
+
type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep' | 'docsRaw' | 'fetchRaw' | 'npmVersionLookup' | 'searchFn'>;
|
|
28
32
|
/** What a target lookup contributes to the block. */
|
|
29
33
|
export interface ExternalTargetResult {
|
|
30
34
|
/** Emitted as an `### npm:` block ahead of every body. Absent for url targets. */
|
|
@@ -89,13 +93,6 @@ export interface ExternalContextPolicy {
|
|
|
89
93
|
* is nothing to enrich (no targets, or every lookup failed).
|
|
90
94
|
*/
|
|
91
95
|
export declare function buildExternalContext(source: string, deps: GatherDeps, lookups: ExternalContextLookups, policy?: ExternalContextPolicy): Promise<string>;
|
|
92
|
-
/** Injectable workers so enrichment is testable without spawning real lookups. */
|
|
93
|
-
export interface ExternalContextDeps {
|
|
94
|
-
docsRaw?: typeof docsRaw;
|
|
95
|
-
fetchRaw?: typeof fetchRaw;
|
|
96
|
-
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
97
|
-
npmVersionLookup?: typeof npmVersionLookup;
|
|
98
|
-
}
|
|
99
96
|
/**
|
|
100
97
|
* The RESEARCH-phase binding: raw workers, no caps, live versions for every
|
|
101
98
|
* named dep, truncated bodies, timed, and short-circuited when there is nothing
|
|
@@ -103,5 +100,5 @@ export interface ExternalContextDeps {
|
|
|
103
100
|
*
|
|
104
101
|
* Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''`.
|
|
105
102
|
*/
|
|
106
|
-
export declare function gatherExternalContext(refined: string, deps: GatherDeps
|
|
103
|
+
export declare function gatherExternalContext(refined: string, deps: GatherDeps): Promise<string>;
|
|
107
104
|
export {};
|