@mjasnikovs/pi-task 0.38.2 → 0.38.3
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 +7 -0
- package/dist/config/config.js +10 -4
- package/dist/config/register.d.ts +37 -0
- package/dist/config/register.js +89 -114
- package/dist/remote/events.js +0 -3
- package/dist/remote/register.js +12 -3
- package/dist/task/auto-orchestrator.js +119 -94
- package/dist/task/command-run.d.ts +104 -0
- package/dist/task/command-run.js +138 -0
- package/dist/task/coverage-loop.d.ts +45 -0
- package/dist/task/critique-probes.d.ts +82 -0
- package/dist/task/critique-probes.js +156 -0
- package/dist/task/enforce-guidelines.d.ts +14 -17
- package/dist/task/enforce-guidelines.js +44 -31
- package/dist/task/final-gate.d.ts +8 -10
- package/dist/task/final-gate.js +36 -74
- package/dist/task/gate-child.d.ts +104 -0
- package/dist/task/gate-child.js +177 -0
- package/dist/task/gate-deps.js +57 -205
- package/dist/task/orchestrator.js +13 -22
- package/dist/task/phases.js +109 -182
- package/dist/task/plan-session.d.ts +4 -22
- package/dist/task/plan-session.js +4 -33
- package/dist/task/question-dialog.d.ts +71 -0
- package/dist/task/question-dialog.js +89 -0
- package/dist/task/terminal-outcome.d.ts +67 -0
- package/dist/task/terminal-outcome.js +76 -0
- package/dist/task/type-only-answer.js +2 -3
- package/dist/workers/abstention.d.ts +71 -0
- package/dist/workers/abstention.js +108 -0
- package/dist/workers/docs-chunk.d.ts +74 -0
- package/dist/workers/docs-chunk.js +143 -0
- package/dist/workers/docs-core.d.ts +10 -1
- package/dist/workers/docs-core.js +22 -19
- package/dist/workers/docs-index.js +2 -69
- package/dist/workers/docs-project.d.ts +15 -1
- package/dist/workers/docs-project.js +27 -66
- package/dist/workers/fetch-core.d.ts +1 -1
- package/dist/workers/fetch-core.js +2 -1
- package/dist/workers/pi-worker-core.js +157 -86
- package/dist/workers/pi-worker-docs.js +5 -10
- package/dist/workers/pi-worker-fetch.js +8 -1
- package/dist/workers/typeonly-log.js +2 -10
- package/dist/workers/worker-failure.d.ts +91 -0
- package/dist/workers/worker-failure.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The critique phase's deterministic Probes, as a table.
|
|
3
|
+
*
|
|
4
|
+
* Each row is a defect a model does not self-discover reliably, found by a pure
|
|
5
|
+
* scanner over the composed spec, and forced into the rewrite. Together they are
|
|
6
|
+
* the deterministic half of critique: the triage child judges taste, these decide
|
|
7
|
+
* facts.
|
|
8
|
+
*
|
|
9
|
+
* Why a table. Every probe used to be written out three times inside
|
|
10
|
+
* `phaseCritique` — once as a four-line detect/format/log ritual, once as a term
|
|
11
|
+
* in the seven-way conjunction that lets a CLEAN triage short-circuit, and once
|
|
12
|
+
* as an element of the array merged into the rewrite. Adding a probe meant three
|
|
13
|
+
* coordinated edits, and forgetting the SECOND one is silent and severe: a CLEAN
|
|
14
|
+
* triage would then ship a spec carrying a defect the scanner had already found —
|
|
15
|
+
* which is precisely the failure each probe exists to prevent. No test would
|
|
16
|
+
* notice, because the wiring, unlike the scanners, was almost entirely untested
|
|
17
|
+
* (1 of 6).
|
|
18
|
+
*
|
|
19
|
+
* With a table the override and the merge are DERIVED from the rows, so "every
|
|
20
|
+
* probe blocks a CLEAN short-circuit" is structurally true rather than something
|
|
21
|
+
* a reviewer must check. This is the same move `PROBE_ADAPTERS` (verify-work.ts)
|
|
22
|
+
* and `CLOSURE_SCANS` (final-gate.ts) already made; critique is the one place
|
|
23
|
+
* that never got it.
|
|
24
|
+
*/
|
|
25
|
+
/** Everything the probes read. Assembled once by the critique phase. */
|
|
26
|
+
export interface CritiqueProbeContext {
|
|
27
|
+
/** The composed draft under critique. */
|
|
28
|
+
spec: string;
|
|
29
|
+
/** The refined feature description the draft was composed from. */
|
|
30
|
+
refined: string;
|
|
31
|
+
/** The task's RESEARCH section, when it has one. */
|
|
32
|
+
research?: string;
|
|
33
|
+
cwd: string;
|
|
34
|
+
/** The cross-slice contract registry, verbatim. Empty for a single /task. */
|
|
35
|
+
registryRaw: string;
|
|
36
|
+
planContext?: string;
|
|
37
|
+
}
|
|
38
|
+
interface CritiqueProbe<F> {
|
|
39
|
+
id: string;
|
|
40
|
+
/** Findings, or empty when the probe does not fire. Pure and total. */
|
|
41
|
+
detect: (ctx: CritiqueProbeContext) => F[];
|
|
42
|
+
/** The defect block fed to the rewrite as a forced FOCUS item. */
|
|
43
|
+
text: (findings: F[], ctx: CritiqueProbeContext) => string;
|
|
44
|
+
/** One debug line naming what was flagged. */
|
|
45
|
+
log: (findings: F[]) => string;
|
|
46
|
+
/**
|
|
47
|
+
* A rewrite that was HANDED this defect and shipped it anyway is a failed
|
|
48
|
+
* rewrite, not a finished one. Rows that can check their own closure name the
|
|
49
|
+
* retry problem here; the emphasis retry then gets a targeted hint.
|
|
50
|
+
*/
|
|
51
|
+
unresolvedProblem?: {
|
|
52
|
+
name: string;
|
|
53
|
+
stillPresent: (rewritten: string) => boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The probes, in the order their defects reach the rewrite. Order is presentation
|
|
58
|
+
* only — every row that fires blocks the CLEAN short-circuit equally.
|
|
59
|
+
*/
|
|
60
|
+
export declare const CRITIQUE_PROBES: ReadonlyArray<CritiqueProbe<unknown>>;
|
|
61
|
+
/** What the deterministic half of critique found. */
|
|
62
|
+
export interface CritiqueDefects {
|
|
63
|
+
/** One defect block per probe that fired, in table order. */
|
|
64
|
+
blocks: string[];
|
|
65
|
+
/**
|
|
66
|
+
* True when any probe fired. A CLEAN triage verdict may NOT short-circuit the
|
|
67
|
+
* rewrite while this holds — the whole reason the override exists.
|
|
68
|
+
*/
|
|
69
|
+
forced: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The retry problem for a rewrite that shipped a flagged defect anyway, or
|
|
72
|
+
* null. Only probes that were actually FIRED are re-checked: a defect the
|
|
73
|
+
* draft never had is not the rewrite's to resolve.
|
|
74
|
+
*/
|
|
75
|
+
unresolvedIn: (rewritten: string) => string | null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Run every probe over one draft. The loop owns the ritual — skip-when-empty,
|
|
79
|
+
* log, order — so a row cannot be added that silently skips any of it.
|
|
80
|
+
*/
|
|
81
|
+
export declare function collectCritiqueDefects(ctx: CritiqueProbeContext, logDebug?: (line: string) => void): CritiqueDefects;
|
|
82
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The critique phase's deterministic Probes, as a table.
|
|
3
|
+
*
|
|
4
|
+
* Each row is a defect a model does not self-discover reliably, found by a pure
|
|
5
|
+
* scanner over the composed spec, and forced into the rewrite. Together they are
|
|
6
|
+
* the deterministic half of critique: the triage child judges taste, these decide
|
|
7
|
+
* facts.
|
|
8
|
+
*
|
|
9
|
+
* Why a table. Every probe used to be written out three times inside
|
|
10
|
+
* `phaseCritique` — once as a four-line detect/format/log ritual, once as a term
|
|
11
|
+
* in the seven-way conjunction that lets a CLEAN triage short-circuit, and once
|
|
12
|
+
* as an element of the array merged into the rewrite. Adding a probe meant three
|
|
13
|
+
* coordinated edits, and forgetting the SECOND one is silent and severe: a CLEAN
|
|
14
|
+
* triage would then ship a spec carrying a defect the scanner had already found —
|
|
15
|
+
* which is precisely the failure each probe exists to prevent. No test would
|
|
16
|
+
* notice, because the wiring, unlike the scanners, was almost entirely untested
|
|
17
|
+
* (1 of 6).
|
|
18
|
+
*
|
|
19
|
+
* With a table the override and the merge are DERIVED from the rows, so "every
|
|
20
|
+
* probe blocks a CLEAN short-circuit" is structurally true rather than something
|
|
21
|
+
* a reviewer must check. This is the same move `PROBE_ADAPTERS` (verify-work.ts)
|
|
22
|
+
* and `CLOSURE_SCANS` (final-gate.ts) already made; critique is the one place
|
|
23
|
+
* that never got it.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { resolve } from 'node:path';
|
|
27
|
+
import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
|
|
28
|
+
import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
|
|
29
|
+
import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
|
|
30
|
+
import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
|
|
31
|
+
import { findGrepOnlyVerify, grepOnlyVerifyDefectText } from './verify-quality.js';
|
|
32
|
+
import { findScriptEscapesInText, scriptEscapeDefectText } from './script-escape.js';
|
|
33
|
+
/** Narrow helper so each row keeps its own finding type without a cast. */
|
|
34
|
+
function probe(row) {
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The probes, in the order their defects reach the rewrite. Order is presentation
|
|
39
|
+
* only — every row that fires blocks the CLEAN short-circuit equally.
|
|
40
|
+
*/
|
|
41
|
+
export const CRITIQUE_PROBES = [
|
|
42
|
+
probe({
|
|
43
|
+
// run-8 F2: a required VERIFY check wrapped in a skip-announcing `||`
|
|
44
|
+
// fallback (`… || echo "skipping (tool absent)"`) lets the check pass
|
|
45
|
+
// while never running. FP-measured 0/20 on the historical specs.
|
|
46
|
+
id: 'skip-escape',
|
|
47
|
+
detect: ctx => findSkipEscapes(ctx.spec),
|
|
48
|
+
text: f => skipEscapeDefectText(f),
|
|
49
|
+
log: f => `skip-escape flagged in VERIFY: ${f.length} line(s)`
|
|
50
|
+
}),
|
|
51
|
+
probe({
|
|
52
|
+
// run-8 F3, generation side. The registry alone is a WEAK catcher (live
|
|
53
|
+
// A/B: prompt+registry ~1/8) — the model's attention goes to the obvious
|
|
54
|
+
// VERIFY weakness and it rarely does the path-composition reasoning. The
|
|
55
|
+
// scanner NAMES the inferred mount mappings and juxtaposes the verbatim
|
|
56
|
+
// pinned facts, forcing focused reconciliation. FP-clean (1/18 files on
|
|
57
|
+
// the run-8 trees). Grounding = the registry ∪ any design doc the
|
|
58
|
+
// spec/refined @-reference. No registry ⇒ nothing to contradict.
|
|
59
|
+
id: 'synthesized-wiring',
|
|
60
|
+
detect: ctx => ctx.registryRaw.trim().length > 0 ?
|
|
61
|
+
findSynthesizedWiring(ctx.spec, ctx.registryRaw + '\n' + readReferencedDocs(ctx.cwd, ctx.refined, ctx.spec), ctx.registryRaw)
|
|
62
|
+
: [],
|
|
63
|
+
text: (f, ctx) => wiringProbeText(f, ctx.registryRaw),
|
|
64
|
+
log: f => `synthesized wiring flagged in spec: ${f.map(w => w.line).join(' | ')}`
|
|
65
|
+
}),
|
|
66
|
+
probe({
|
|
67
|
+
// mx5 run 11, goal D: a VERIFY line asserting the ABSENCE of an artifact
|
|
68
|
+
// the plan pins elsewhere — a path a prior task already shipped, a sibling
|
|
69
|
+
// title's deliverable, a contract-pinned boundary. Run 11: the scope fence
|
|
70
|
+
// leaked into TASK_0009's verify as "the admin page must NOT exist"
|
|
71
|
+
// (TASK_0008's deliverable); the guaranteed FAIL became an accepted debt
|
|
72
|
+
// that the final-gate autofix then "fixed" by deleting the sibling's work.
|
|
73
|
+
// It must die here, at spec time. Delete-tasks keep their check by
|
|
74
|
+
// declaring the delete.
|
|
75
|
+
id: 'plan-contradiction',
|
|
76
|
+
detect: ctx => findAbsenceConflicts(ctx.spec, {
|
|
77
|
+
fileExists: p => existsSync(resolve(ctx.cwd, p)),
|
|
78
|
+
siblingTitles: siblingTitlesFromPlanContext(ctx.planContext),
|
|
79
|
+
contracts: ctx.registryRaw
|
|
80
|
+
}),
|
|
81
|
+
text: f => absenceProbeText(f),
|
|
82
|
+
log: f => 'plan-contradiction flagged in VERIFY: '
|
|
83
|
+
+ f.map(c => `${c.assertion.target} (${c.against})`).join(' | ')
|
|
84
|
+
}),
|
|
85
|
+
probe({
|
|
86
|
+
// mx5 run 12 root cause: a blanket frozen path ("Do NOT modify
|
|
87
|
+
// `tsconfig.json` … handled in steps 1–2") whose registration edit the
|
|
88
|
+
// spec's OWN body — or the task's RESEARCH the spec was composed from
|
|
89
|
+
// (live drafts sometimes drop the nuance while shipping the freeze and the
|
|
90
|
+
// creation) — says the deliverable requires. Shipped as-is, the created
|
|
91
|
+
// files turn the repo-wide static check permanently red and no task is
|
|
92
|
+
// allowed to fix it: every later task burns its AUTOFIX rounds on it. The
|
|
93
|
+
// rewrite must grant scoped ownership or drop the creation.
|
|
94
|
+
id: 'frozen-conflict',
|
|
95
|
+
detect: ctx => findFrozenPathConflicts(ctx.spec, ctx.research),
|
|
96
|
+
text: f => frozenConflictProbeText(f),
|
|
97
|
+
log: f => 'unsatisfiable freeze/requires-edit pair flagged in spec: '
|
|
98
|
+
+ f.map(c => c.path).join(' | ')
|
|
99
|
+
}),
|
|
100
|
+
probe({
|
|
101
|
+
// mx5 run 13, Bug B: a VERIFY block that grep-asserts the SOURCE of a
|
|
102
|
+
// runnable deliverable while every command in the block is static
|
|
103
|
+
// inspection — the build script "verified" by three greps that was never
|
|
104
|
+
// run, shipping broken for 14 tasks. VERIFY must EXECUTE the artifact and
|
|
105
|
+
// assert an observable outcome of that run.
|
|
106
|
+
id: 'grep-theater',
|
|
107
|
+
detect: ctx => findGrepOnlyVerify(ctx.spec),
|
|
108
|
+
text: f => grepOnlyVerifyDefectText(f),
|
|
109
|
+
log: f => `grep-theater VERIFY flagged in spec: ${f.map(x => x.target).join(' | ')}`,
|
|
110
|
+
// Detector-backed closure: live A/B, 1/5 rewrites ignored the injected
|
|
111
|
+
// defect and re-shipped the grep-only block.
|
|
112
|
+
unresolvedProblem: {
|
|
113
|
+
name: 'verify_grep_theater',
|
|
114
|
+
stillPresent: rewritten => findGrepOnlyVerify(rewritten).length > 0
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
probe({
|
|
118
|
+
// mx5 run 13, PROMPT 4 item 4: a spec that DICTATES a check script which
|
|
119
|
+
// cannot fail — `"lint": "… || true"`, or a checker laundered through an
|
|
120
|
+
// inverted grep. Whatever task implements that spec writes the disarmed
|
|
121
|
+
// script into package.json, and from then on every gate that runs it
|
|
122
|
+
// reads a constant. Cheapest to kill here, before it is authored.
|
|
123
|
+
id: 'script-escape',
|
|
124
|
+
detect: ctx => findScriptEscapesInText(ctx.spec),
|
|
125
|
+
text: f => scriptEscapeDefectText(f),
|
|
126
|
+
log: f => `neutered check script dictated by spec: ${f.map(x => x.name).join(' | ')}`
|
|
127
|
+
})
|
|
128
|
+
];
|
|
129
|
+
/**
|
|
130
|
+
* Run every probe over one draft. The loop owns the ritual — skip-when-empty,
|
|
131
|
+
* log, order — so a row cannot be added that silently skips any of it.
|
|
132
|
+
*/
|
|
133
|
+
export function collectCritiqueDefects(ctx, logDebug) {
|
|
134
|
+
const blocks = [];
|
|
135
|
+
const fired = [];
|
|
136
|
+
for (const row of CRITIQUE_PROBES) {
|
|
137
|
+
const findings = row.detect(ctx);
|
|
138
|
+
if (findings.length === 0)
|
|
139
|
+
continue;
|
|
140
|
+
fired.push(row);
|
|
141
|
+
blocks.push(row.text(findings, ctx));
|
|
142
|
+
logDebug?.(row.log(findings));
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
blocks,
|
|
146
|
+
forced: blocks.length > 0,
|
|
147
|
+
unresolvedIn: rewritten => {
|
|
148
|
+
for (const row of fired) {
|
|
149
|
+
if (row.unresolvedProblem?.stillPresent(rewritten) === true) {
|
|
150
|
+
return row.unresolvedProblem.name;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SpawnFn } from '../shared/child-process.js';
|
|
2
|
+
import { type WorkerFailureInput } from '../workers/worker-failure.js';
|
|
2
3
|
/** Filenames discovered in the working directory (cwd only — no tree walk). */
|
|
3
4
|
export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
|
|
4
5
|
/**
|
|
@@ -82,29 +83,25 @@ export declare function parseEnforceVerdict(text: string): {
|
|
|
82
83
|
detail: string;
|
|
83
84
|
};
|
|
84
85
|
/** The subset of a runWorker result the enforcement-child mapping reads. */
|
|
85
|
-
export interface EnforceChildResult {
|
|
86
|
+
export interface EnforceChildResult extends WorkerFailureInput {
|
|
86
87
|
text: string;
|
|
87
|
-
exitCode: number;
|
|
88
|
-
aborted: boolean;
|
|
89
|
-
timedOut?: boolean;
|
|
90
|
-
loopHit?: unknown;
|
|
91
|
-
leakedToolCall?: unknown;
|
|
92
|
-
stalled?: boolean;
|
|
93
|
-
commandTimedOut?: {
|
|
94
|
-
toolName: string;
|
|
95
|
-
timeoutMs: number;
|
|
96
|
-
};
|
|
97
88
|
}
|
|
98
89
|
/**
|
|
99
90
|
* Map the enforcement child's runWorker result to a fatal error message, or null
|
|
100
91
|
* when it finished cleanly enough to parse a verdict from its text.
|
|
101
92
|
*
|
|
102
|
-
* The
|
|
103
|
-
* `aborted`
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
93
|
+
* The ORDER is load-bearing and no longer lives here: every kill path also sets
|
|
94
|
+
* `aborted` and a non-zero exit, so the specific causes must be matched before
|
|
95
|
+
* the generic ones, and that precedence is stated once in `classifyWorkerFailure`
|
|
96
|
+
* (workers/worker-failure.ts). This function is now only the enforce-specific
|
|
97
|
+
* half — what each cause MEANS to guideline enforcement.
|
|
98
|
+
*
|
|
99
|
+
* Writing the ladder out by hand here is exactly what cost us the stream-stall
|
|
100
|
+
* bug: `streamStalled` was added to the worker result and to `finalAttemptFailed`
|
|
101
|
+
* but never to this ladder, so a child killed for a dead model stream fell
|
|
102
|
+
* through to `aborted → USER_CANCELLED` and a hung backend was reported to the
|
|
103
|
+
* user as their own cancel. The switch below is exhaustive, so the next cause
|
|
104
|
+
* added to the union is a compile error here rather than a silent mislabel.
|
|
108
105
|
*
|
|
109
106
|
* A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
|
|
110
107
|
* loop that survived its restart-with-hint nudges returns null here (the caller
|
|
@@ -25,6 +25,7 @@ import * as fsp from 'node:fs/promises';
|
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import { makeGit } from '../shared/git-runner.js';
|
|
27
27
|
import { USER_CANCELLED } from './child-runner.js';
|
|
28
|
+
import { classifyWorkerFailure } from '../workers/worker-failure.js';
|
|
28
29
|
import { TASKS_DIR_NAME } from './task-types.js';
|
|
29
30
|
import { findProbeGamingInDiff } from './probe-gaming.js';
|
|
30
31
|
/** Filenames discovered in the working directory (cwd only — no tree walk). */
|
|
@@ -204,12 +205,18 @@ export function parseEnforceVerdict(text) {
|
|
|
204
205
|
* Map the enforcement child's runWorker result to a fatal error message, or null
|
|
205
206
|
* when it finished cleanly enough to parse a verdict from its text.
|
|
206
207
|
*
|
|
207
|
-
* The
|
|
208
|
-
* `aborted`
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
208
|
+
* The ORDER is load-bearing and no longer lives here: every kill path also sets
|
|
209
|
+
* `aborted` and a non-zero exit, so the specific causes must be matched before
|
|
210
|
+
* the generic ones, and that precedence is stated once in `classifyWorkerFailure`
|
|
211
|
+
* (workers/worker-failure.ts). This function is now only the enforce-specific
|
|
212
|
+
* half — what each cause MEANS to guideline enforcement.
|
|
213
|
+
*
|
|
214
|
+
* Writing the ladder out by hand here is exactly what cost us the stream-stall
|
|
215
|
+
* bug: `streamStalled` was added to the worker result and to `finalAttemptFailed`
|
|
216
|
+
* but never to this ladder, so a child killed for a dead model stream fell
|
|
217
|
+
* through to `aborted → USER_CANCELLED` and a hung backend was reported to the
|
|
218
|
+
* user as their own cancel. The switch below is exhaustive, so the next cause
|
|
219
|
+
* added to the union is a compile error here rather than a silent mislabel.
|
|
213
220
|
*
|
|
214
221
|
* A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
|
|
215
222
|
* loop that survived its restart-with-hint nudges returns null here (the caller
|
|
@@ -218,32 +225,38 @@ export function parseEnforceVerdict(text) {
|
|
|
218
225
|
* effects don't get re-classified as a user cancel or a crash.
|
|
219
226
|
*/
|
|
220
227
|
export function classifyEnforceChildFailure(r) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
228
|
+
const failure = classifyWorkerFailure(r);
|
|
229
|
+
if (!failure)
|
|
230
|
+
return null;
|
|
231
|
+
switch (failure.kind) {
|
|
232
|
+
case 'stalled':
|
|
233
|
+
// mx5 run 7: 64 minutes of silence reported as a user cancel.
|
|
234
|
+
return 'model server unreachable — the child produced no output and the model endpoint did not respond';
|
|
235
|
+
case 'command-timeout': {
|
|
236
|
+
// Its text is truncated mid-run — the verdict in it is partial and
|
|
237
|
+
// must never be parsed as a real one.
|
|
238
|
+
const mins = Math.max(1, Math.round(failure.timeoutMs / 60_000));
|
|
239
|
+
return (`child ran a \`${failure.toolName}\` command that had not returned after `
|
|
240
|
+
+ `${mins} minute${mins === 1 ? '' : 's'} and was killed — it never bounded the command`);
|
|
241
|
+
}
|
|
242
|
+
case 'stream-stall': {
|
|
243
|
+
// The arm this ladder was missing. Same class as the two above: a
|
|
244
|
+
// watchdog, not the model, ended the run, and the text is partial.
|
|
245
|
+
const secs = Math.max(1, Math.round(failure.idleMs / 1000));
|
|
246
|
+
return (`model stream went silent for ${secs}s with no tool running and the child was `
|
|
247
|
+
+ 'killed — the backend stopped producing, the child did not stop working');
|
|
248
|
+
}
|
|
249
|
+
case 'worker-timeout':
|
|
250
|
+
return 'enforcement child timed out';
|
|
251
|
+
case 'loop':
|
|
252
|
+
return null; // looped past the nudges → warning, handled by caller
|
|
253
|
+
case 'leaked-tool-call':
|
|
254
|
+
return 'enforcement child leaked a tool call';
|
|
255
|
+
case 'aborted':
|
|
256
|
+
return USER_CANCELLED;
|
|
257
|
+
case 'exit':
|
|
258
|
+
return `enforcement child exited ${failure.code}`;
|
|
235
259
|
}
|
|
236
|
-
if (r.timedOut)
|
|
237
|
-
return 'enforcement child timed out';
|
|
238
|
-
if (r.loopHit)
|
|
239
|
-
return null; // looped past the nudges → warning, handled by caller
|
|
240
|
-
if (r.leakedToolCall)
|
|
241
|
-
return 'enforcement child leaked a tool call';
|
|
242
|
-
if (r.aborted)
|
|
243
|
-
return USER_CANCELLED;
|
|
244
|
-
if (r.exitCode !== 0)
|
|
245
|
-
return `enforcement child exited ${r.exitCode}`;
|
|
246
|
-
return null;
|
|
247
260
|
}
|
|
248
261
|
/**
|
|
249
262
|
* The git "empty tree" object — the canonical base for diffing a ROOT commit
|
|
@@ -2,6 +2,7 @@ import { type HealthCommand } from './repo-health-check.js';
|
|
|
2
2
|
import { type AcceptDebt, type VerifyRerunResult } from './accept-debt.js';
|
|
3
3
|
import { type RenderOutcome } from './render-check.js';
|
|
4
4
|
import { type DeepRenderOutcome } from './deep-render-check.js';
|
|
5
|
+
import { type CommandRunner } from './command-run.js';
|
|
5
6
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
6
7
|
export interface FinalGateOutcome {
|
|
7
8
|
/** true → statics and every runnable integration command passed (or nothing to run). */
|
|
@@ -336,14 +337,6 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
|
336
337
|
* guard already owns.
|
|
337
338
|
*/
|
|
338
339
|
export declare function discoverGateCommandBodies(cwd: string): Record<string, string>;
|
|
339
|
-
/**
|
|
340
|
-
* A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
|
|
341
|
-
* talks to is absent HERE — a database/daemon that is not running or not
|
|
342
|
-
* installed — rather than a fault in the script itself. Applied ONLY to
|
|
343
|
-
* launch-contract scripts (a migrate/seed against no DB is an environment gap on
|
|
344
|
-
* this box; the same wording in a `test` run is a real failure the suite must own).
|
|
345
|
-
*/
|
|
346
|
-
export declare const INFRA_GAP_OUTPUT_RE: RegExp;
|
|
347
340
|
/**
|
|
348
341
|
* How a re-run of ONE recorded VERIFY command line ended.
|
|
349
342
|
* pass — it ran and exited 0. The ONLY outcome that may close a debt.
|
|
@@ -377,7 +370,9 @@ export type VerifyRerunOutcome = {
|
|
|
377
370
|
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
378
371
|
* debt exactly as open as it was.
|
|
379
372
|
*/
|
|
380
|
-
export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp
|
|
373
|
+
export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
|
|
374
|
+
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
375
|
+
run?: CommandRunner): VerifyRerunOutcome;
|
|
381
376
|
/**
|
|
382
377
|
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
383
378
|
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
@@ -537,7 +532,10 @@ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise
|
|
|
537
532
|
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
538
533
|
* assumption rather than an observation.
|
|
539
534
|
*/
|
|
540
|
-
export declare function rerunDebtVerifyCommand(cwd: string, command: string
|
|
535
|
+
export declare function rerunDebtVerifyCommand(cwd: string, command: string,
|
|
536
|
+
/** The spawner, for BOTH the command and the tracked-state reads. Injected so
|
|
537
|
+
* the guard's four outcomes are testable without a repo or a real command. */
|
|
538
|
+
run?: CommandRunner): VerifyRerunResult;
|
|
541
539
|
/**
|
|
542
540
|
* Where in the gate a closure scan runs. The two stages are NOT interchangeable
|
|
543
541
|
* and neither is a scheduling preference:
|
package/dist/task/final-gate.js
CHANGED
|
@@ -57,6 +57,7 @@ import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
|
|
|
57
57
|
import { runRenderCheck } from './render-check.js';
|
|
58
58
|
import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
|
|
59
59
|
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
60
|
+
import { classifyCommandRun, spawnCommand, outputTail, INFRA_GAP_OUTPUT_RE } from './command-run.js';
|
|
60
61
|
import { findLaunchConfigGap, probeEnv, configGapUnobservedNote } from './launch-config-gap.js';
|
|
61
62
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
62
63
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
@@ -978,30 +979,6 @@ function resolveCommandBody(bin, args, scripts, makefile) {
|
|
|
978
979
|
}
|
|
979
980
|
return null;
|
|
980
981
|
}
|
|
981
|
-
/** Last ~`limit` chars of the command's combined output, one line, for the reason. */
|
|
982
|
-
function outputTail(stdout, stderr, limit = 400) {
|
|
983
|
-
const combined = `${stdout}\n${stderr}`.trim();
|
|
984
|
-
if (combined.length === 0)
|
|
985
|
-
return '';
|
|
986
|
-
const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
|
|
987
|
-
return combined.length > limit ? `…${tail}` : tail;
|
|
988
|
-
}
|
|
989
|
-
/**
|
|
990
|
-
* A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
|
|
991
|
-
* a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
|
|
992
|
-
* libraries were never installed here (mx5 run 10 item 2: `test:ct` must run in the
|
|
993
|
-
* gate, but on a box with no Playwright browsers it is an environment gap, not a FAIL).
|
|
994
|
-
* These exit non-zero (not 127), so they need output-shape recognition to skip.
|
|
995
|
-
*/
|
|
996
|
-
const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|browserType\.\w+: Executable|(?:wasn't|weren't) installed|Host system is missing dependencies|No usable sandbox|Cypress verification|Cypress executable (?:not found|was not found)|browser(?:s)? (?:is|are)? ?not installed/i;
|
|
997
|
-
/**
|
|
998
|
-
* A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
|
|
999
|
-
* talks to is absent HERE — a database/daemon that is not running or not
|
|
1000
|
-
* installed — rather than a fault in the script itself. Applied ONLY to
|
|
1001
|
-
* launch-contract scripts (a migrate/seed against no DB is an environment gap on
|
|
1002
|
-
* this box; the same wording in a `test` run is a real failure the suite must own).
|
|
1003
|
-
*/
|
|
1004
|
-
export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EAI_AGAIN|is the server running|could not connect|cannot connect to the docker daemon|connect: connection|no such host/i;
|
|
1005
982
|
/**
|
|
1006
983
|
* Run one gate command with the env-gap contract: tool missing, timeout, or
|
|
1007
984
|
* command-not-found inside the script chain (127) → environment gap, not a code
|
|
@@ -1013,36 +990,20 @@ export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EA
|
|
|
1013
990
|
function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe,
|
|
1014
991
|
/** Replaces the child's environment wholesale (config-gap probe re-run only —
|
|
1015
992
|
* see launch-config-gap.ts). Absent ⇒ `runnerEnv(runner)`, i.e. unchanged. */
|
|
1016
|
-
envOverride
|
|
993
|
+
envOverride,
|
|
994
|
+
/** The spawner. Injected so the gate's own tests can script a verdict. */
|
|
995
|
+
run = spawnCommand) {
|
|
1017
996
|
// Runner resolution (mx5 run 16): a login-shell-stripped PATH left `bun`
|
|
1018
997
|
// unspawnable, so every dynamic check skipped and the gate went blind. The
|
|
1019
998
|
// resolved binary is spawned, and its directory rides on the child's PATH so
|
|
1020
999
|
// the SCRIPT CHAIN can re-invoke the runner (`bun run test` runs `bun test`
|
|
1021
1000
|
// inside — a bare 127 there is the same blindness one level down).
|
|
1022
|
-
// env passed explicitly: bun's spawnSync resolves the binary against a
|
|
1023
|
-
// startup snapshot of the environment, not the live process.env.
|
|
1024
1001
|
const runner = resolveRunner(bin);
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
timeout: timeoutMs,
|
|
1029
|
-
env: envOverride ?? runnerEnv(runner)
|
|
1030
|
-
});
|
|
1031
|
-
if (r.error)
|
|
1032
|
-
return { outcome: 'skip', spawnFailed: true };
|
|
1033
|
-
if (r.status === null)
|
|
1034
|
-
return { outcome: 'skip', spawnFailed: false };
|
|
1035
|
-
if (r.status !== 0) {
|
|
1036
|
-
const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
1037
|
-
if (isCommandNotFound(r.status, output))
|
|
1038
|
-
return { outcome: 'skip', spawnFailed: false };
|
|
1039
|
-
if (ENV_GAP_OUTPUT_RE.test(output))
|
|
1040
|
-
return { outcome: 'skip', spawnFailed: false };
|
|
1041
|
-
if (extraGapRe?.test(output))
|
|
1042
|
-
return { outcome: 'skip', spawnFailed: false };
|
|
1043
|
-
return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
|
|
1002
|
+
const verdict = classifyCommandRun(run({ cwd, bin: runner.bin, args, timeoutMs, env: envOverride ?? runnerEnv(runner) }), extraGapRe ? [extraGapRe] : []);
|
|
1003
|
+
if (verdict.outcome === 'gap') {
|
|
1004
|
+
return { outcome: 'skip', spawnFailed: verdict.gap === 'spawn-failed' };
|
|
1044
1005
|
}
|
|
1045
|
-
return
|
|
1006
|
+
return verdict;
|
|
1046
1007
|
}
|
|
1047
1008
|
/** The command word of a shell line, past any leading `VAR=value` assignments. */
|
|
1048
1009
|
function leadingBin(line) {
|
|
@@ -1068,31 +1029,28 @@ function leadingBin(line) {
|
|
|
1068
1029
|
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
1069
1030
|
* debt exactly as open as it was.
|
|
1070
1031
|
*/
|
|
1071
|
-
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe
|
|
1032
|
+
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
|
|
1033
|
+
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
1034
|
+
run = spawnCommand) {
|
|
1072
1035
|
const bin = leadingBin(line);
|
|
1073
1036
|
const runner = bin === null ? null : resolveRunner(bin);
|
|
1074
|
-
|
|
1037
|
+
// A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
|
|
1038
|
+
// redirects are all ordinary there — so the runner spawns `sh -c`.
|
|
1039
|
+
const verdict = classifyCommandRun(run({
|
|
1075
1040
|
cwd,
|
|
1076
|
-
|
|
1077
|
-
|
|
1041
|
+
bin: 'sh',
|
|
1042
|
+
args: ['-c', line],
|
|
1043
|
+
timeoutMs,
|
|
1078
1044
|
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
1079
|
-
})
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
if (
|
|
1086
|
-
return { outcome: '
|
|
1087
|
-
|
|
1088
|
-
return { outcome: 'gap', detail: 'command not found (127)' };
|
|
1089
|
-
}
|
|
1090
|
-
if (ENV_GAP_OUTPUT_RE.test(output))
|
|
1091
|
-
return { outcome: 'gap', detail: 'missing browser/runtime' };
|
|
1092
|
-
if (INFRA_GAP_OUTPUT_RE.test(output) || extraGapRe?.test(output) === true) {
|
|
1093
|
-
return { outcome: 'gap', detail: 'external infrastructure unreachable' };
|
|
1094
|
-
}
|
|
1095
|
-
return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
|
|
1045
|
+
}),
|
|
1046
|
+
// Infrastructure counts as a gap on EVERY debt re-run, not only on
|
|
1047
|
+
// request: an unreachable database cannot tell us whether the code is
|
|
1048
|
+
// fixed, and the asymmetry below means an inconclusive re-run simply
|
|
1049
|
+
// leaves the debt as open as it was.
|
|
1050
|
+
extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
|
|
1051
|
+
if (verdict.outcome === 'gap')
|
|
1052
|
+
return { outcome: 'gap', detail: verdict.detail };
|
|
1053
|
+
return verdict;
|
|
1096
1054
|
}
|
|
1097
1055
|
/**
|
|
1098
1056
|
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
@@ -1312,17 +1270,21 @@ const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|E
|
|
|
1312
1270
|
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
1313
1271
|
* assumption rather than an observation.
|
|
1314
1272
|
*/
|
|
1315
|
-
export function rerunDebtVerifyCommand(cwd, command
|
|
1273
|
+
export function rerunDebtVerifyCommand(cwd, command,
|
|
1274
|
+
/** The spawner, for BOTH the command and the tracked-state reads. Injected so
|
|
1275
|
+
* the guard's four outcomes are testable without a repo or a real command. */
|
|
1276
|
+
run = spawnCommand) {
|
|
1316
1277
|
const tracked = () => {
|
|
1317
|
-
const r =
|
|
1278
|
+
const r = run({
|
|
1318
1279
|
cwd,
|
|
1319
|
-
|
|
1320
|
-
|
|
1280
|
+
bin: 'git',
|
|
1281
|
+
args: ['status', '--porcelain', '--untracked-files=no'],
|
|
1282
|
+
timeoutMs: 60_000
|
|
1321
1283
|
});
|
|
1322
|
-
return r.
|
|
1284
|
+
return r.failedToStart || r.status !== 0 ? null : r.stdout;
|
|
1323
1285
|
};
|
|
1324
1286
|
const before = tracked();
|
|
1325
|
-
const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE);
|
|
1287
|
+
const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
|
|
1326
1288
|
if (r.outcome === 'fail')
|
|
1327
1289
|
return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
|
|
1328
1290
|
if (r.outcome === 'gap')
|