@mjasnikovs/pi-task 0.38.11 → 0.38.12
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/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +5 -151
- package/dist/task/final-gate.js +81 -379
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +67 -294
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The final gate's tally: everything its sections RECORD, and the one pure
|
|
3
|
+
* function that turns the record into a `FinalGateOutcome`.
|
|
4
|
+
*
|
|
5
|
+
* `runFinalIntegrationGate` used to carry this as twelve mutable locals — the
|
|
6
|
+
* ranked failure list, four dynamic counters, three note lists, a warning list,
|
|
7
|
+
* the boot verdict — threaded through ~400 lines by closure, with three sections
|
|
8
|
+
* hand-incrementing the same counters and one branch doing `dynObserved -= 1`.
|
|
9
|
+
* The verdict assembly at the end was pure, but reachable only through a temp
|
|
10
|
+
* dir and real spawns. Here the sections call named methods (an attempt is
|
|
11
|
+
* `attempted(bin)`, the config-gap un-count is `unobserve()`, a probe that looked
|
|
12
|
+
* and saw something bad is `failObserved(...)`) and `verdict()` is the ONE place
|
|
13
|
+
* the PASS / FAIL / UNOBSERVED polarity, note ordering and debt attachment live,
|
|
14
|
+
* testable with no tree at all.
|
|
15
|
+
*
|
|
16
|
+
* The two verdict predicates the gate has always exported — `observabilityGapFailure`
|
|
17
|
+
* (run-16 full-blindness FAIL) and `unobservedVerdict` (the third, non-blocking
|
|
18
|
+
* verdict) — live here because they read exactly the counters the tally owns;
|
|
19
|
+
* `final-gate.ts` re-exports them so every existing importer is unchanged.
|
|
20
|
+
*/
|
|
21
|
+
import type { AcceptDebt } from './accept-debt.js';
|
|
22
|
+
import type { FinalGateOutcome } from './final-gate.js';
|
|
23
|
+
/**
|
|
24
|
+
* Full-skip blindness guard (mx5 run 16). Per-command env-gap skips stay
|
|
25
|
+
* legitimate (a missing browser must not fail a suite); what may never happen
|
|
26
|
+
* again is ALL of them skipping while the gate still reports PASS — a gate that
|
|
27
|
+
* observed nothing dynamic has no basis to vouch for the assembled app. Pure so
|
|
28
|
+
* the semantics are unit-tested; the tally feeds it its own counters plus runner
|
|
29
|
+
* resolvability.
|
|
30
|
+
*/
|
|
31
|
+
export declare function observabilityGapFailure(args: {
|
|
32
|
+
/** Dynamic commands the gate discovered and tried to run. */
|
|
33
|
+
attempted: number;
|
|
34
|
+
/** Of those, how many it actually OBSERVED (a real pass OR a real fail —
|
|
35
|
+
* either proves the command ran; only skips observe nothing). */
|
|
36
|
+
observed: number;
|
|
37
|
+
/** Of the skips, how many were SPAWN failures (runner never ran, ENOENT).
|
|
38
|
+
* Tool-level gaps (missing browser, 127 inside the chain, timeout) prove
|
|
39
|
+
* the runner itself works and keep the classic env-gap contract — the
|
|
40
|
+
* blindness class fires only when EVERY attempt failed to even spawn. */
|
|
41
|
+
spawnFailures: number;
|
|
42
|
+
/** Distinct runner bins across the attempted commands. */
|
|
43
|
+
runnerBins: string[];
|
|
44
|
+
/** Is this runner spawnable (bare or via a known install location)? */
|
|
45
|
+
runnerResolvable: (bin: string) => boolean;
|
|
46
|
+
}): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
|
|
49
|
+
* but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
|
|
50
|
+
* `attempted === 0`, and until now that silence fell straight through to
|
|
51
|
+
* `PASS — no integration command found (statics passed)`: the run-16 blindness class
|
|
52
|
+
* entering through a different door, where "we never checked" reads exactly like "we
|
|
53
|
+
* checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
|
|
54
|
+
* shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
|
|
55
|
+
* godot-engine (package.json whose only script is `verify`) reproduces it live today.
|
|
56
|
+
*
|
|
57
|
+
* So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
|
|
58
|
+
* existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
|
|
59
|
+
*
|
|
60
|
+
* WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
|
|
61
|
+
* the resolution):
|
|
62
|
+
* - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
|
|
63
|
+
* the runs with no dynamic evidence were exactly the runs already known to be
|
|
64
|
+
* carrying defects.
|
|
65
|
+
* - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
|
|
66
|
+
* gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
|
|
67
|
+
* integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
|
|
68
|
+
* missing signal was never the debt, it was the word PASS endorsing the run, and
|
|
69
|
+
* that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
|
|
70
|
+
* seed is `reason`; "no integration command is discoverable" is not fixable by
|
|
71
|
+
* editing code, so the highest-probability child response is to FABRICATE a
|
|
72
|
+
* runnable command to satisfy the gate — the same fabrication class that refuted
|
|
73
|
+
* the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
|
|
74
|
+
* run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
|
|
75
|
+
* means IAR1 and godot-engine can NEVER discover a command, so blocking would end
|
|
76
|
+
* every non-npm run in `failed` permanently, with no remedy — the task's own I3
|
|
77
|
+
* ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
|
|
78
|
+
* its stated consequence is to downgrade to a warning and say so. This is that.
|
|
79
|
+
* The teeth are elsewhere and are real: the verdict word changes, the gate trail says
|
|
80
|
+
* UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
|
|
81
|
+
* gate re-surfaces (it can never auto-close — it is not static-class).
|
|
82
|
+
*/
|
|
83
|
+
export declare function unobservedVerdict(args: {
|
|
84
|
+
/** Dynamic commands the gate discovered and tried to run (0 ⇒ nothing existed). */
|
|
85
|
+
discovered: number;
|
|
86
|
+
/** Of those, how many actually RAN (a real pass or a real fail). */
|
|
87
|
+
observed: number;
|
|
88
|
+
}): string | null;
|
|
89
|
+
/** What `verdict()` attaches to every outcome, PASS or FAIL: the run's open
|
|
90
|
+
* ACCEPT debts, derived once before any section runs (deriveOpenDebts). */
|
|
91
|
+
export interface GateDebts {
|
|
92
|
+
openDebts: AcceptDebt[];
|
|
93
|
+
/** Human-facing note listing the open debts; absent/undefined when none. */
|
|
94
|
+
debtNote?: string;
|
|
95
|
+
}
|
|
96
|
+
export declare class GateTally {
|
|
97
|
+
private readonly failures;
|
|
98
|
+
/** Labels of the dynamic commands that ran AND passed — the PASS reason names them. */
|
|
99
|
+
private readonly passed;
|
|
100
|
+
private attempts;
|
|
101
|
+
private observations;
|
|
102
|
+
private spawnFailures;
|
|
103
|
+
private readonly bins;
|
|
104
|
+
private readonly warnings;
|
|
105
|
+
/** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
|
|
106
|
+
* They ride in `unobserved`, not `warnings`, so the caller's existing
|
|
107
|
+
* `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
|
|
108
|
+
* never a PASS. */
|
|
109
|
+
private readonly configGapNotes;
|
|
110
|
+
/** The inert-launch-contract note (16A): "declared scripts, but no manifest to
|
|
111
|
+
* diff against" — a note, never a failure, never a silent pass. */
|
|
112
|
+
private readonly contractNotes;
|
|
113
|
+
/** The boot section's own UNOBSERVED verdict (bootSkipVerdict / rejected launch
|
|
114
|
+
* script). Lives outside the dynamic counters ON PURPOSE (mx5 run 18): the
|
|
115
|
+
* test/build commands that did run cannot cancel it. */
|
|
116
|
+
private bootNote;
|
|
117
|
+
/** A failure at `rank` (default 1). Rank 0 is boot/render, the most load-bearing. */
|
|
118
|
+
fail(text: string, rank?: number): void;
|
|
119
|
+
/**
|
|
120
|
+
* A failure a PROBE returned after observing (nexttask 19A — see
|
|
121
|
+
* FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
|
|
122
|
+
* section, whose `fail` outcome can only arise from a probe that looked. Every
|
|
123
|
+
* other `fail()` keeps its class, so nothing else changes.
|
|
124
|
+
*/
|
|
125
|
+
failObserved(text: string, rank?: number): void;
|
|
126
|
+
/** A dynamic command that ran and passed; the PASS reason lists these. */
|
|
127
|
+
ran(label: string): void;
|
|
128
|
+
/** A dynamic spawn was attempted through runner `bin` (counted whether or not
|
|
129
|
+
* it then skips). */
|
|
130
|
+
attempted(bin: string): void;
|
|
131
|
+
/** The attempt was OBSERVED — a real pass or a real fail; only skips observe nothing. */
|
|
132
|
+
observed(): void;
|
|
133
|
+
/**
|
|
134
|
+
* Un-count one observation. The config-gap branch (mx5 run 20): a launch
|
|
135
|
+
* script failed, the four static conditions plus the placeholder re-run said
|
|
136
|
+
* the failure was a missing env variable the shipped template declares, and
|
|
137
|
+
* so NOTHING about that script was observed — the real run could not reach
|
|
138
|
+
* it and the probe run is a diagnostic, never an observation. It un-counts,
|
|
139
|
+
* exactly like a skip would have.
|
|
140
|
+
*/
|
|
141
|
+
unobserve(): void;
|
|
142
|
+
/** The attempt through `bin` never even spawned (ENOENT-class), as opposed to a
|
|
143
|
+
* tool-level env gap inside the chain. */
|
|
144
|
+
spawnFailure(bin: string): void;
|
|
145
|
+
/** A WARNING appended to a PASS reason (excuse-note-covered skip, render note). */
|
|
146
|
+
warn(line: string): void;
|
|
147
|
+
configGap(note: string): void;
|
|
148
|
+
contractNote(note: string): void;
|
|
149
|
+
/** The boot section's UNOBSERVED verdict, or null when the boot was observed. */
|
|
150
|
+
bootUnobserved(note: string | null): void;
|
|
151
|
+
/** True while nothing dynamic has been attempted and nothing has failed —
|
|
152
|
+
* the state the zero-discovery return checks (see runFinalIntegrationGate). */
|
|
153
|
+
silent(): boolean;
|
|
154
|
+
/** The run-16 blindness guard over this tally's own counters (see
|
|
155
|
+
* observabilityGapFailure); the caller fails it at rank 0. */
|
|
156
|
+
blindness(runnerResolvable: (bin: string) => boolean): string | null;
|
|
157
|
+
/**
|
|
158
|
+
* The verdict. Pure over the tally's state; the same debts ride on every shape.
|
|
159
|
+
*
|
|
160
|
+
* FAIL when anything failed: stable-sorted so boot/render (rank 0) leads and
|
|
161
|
+
* everything else keeps execution order. One failure keeps the exact
|
|
162
|
+
* single-failure wording; several become a numbered list so the trail, the
|
|
163
|
+
* ACCEPT picker, and the autofix seed all carry the complete ranked picture.
|
|
164
|
+
* The observed subset rides along by exact text identity (19A) — the demote
|
|
165
|
+
* decision downstream reads THIS, instead of re-deriving observability from
|
|
166
|
+
* the failure string.
|
|
167
|
+
*
|
|
168
|
+
* Otherwise `ok: true`, with the UNOBSERVED note when the gate could not
|
|
169
|
+
* observe something it meant to. Two independent notes, either or both of which
|
|
170
|
+
* may apply: the boot never ran (run 18), and/or NOTHING dynamic ran at all
|
|
171
|
+
* (unobservedVerdict — commands WERE discovered, none spawn-failed so the run-16
|
|
172
|
+
* guard correctly stayed silent, and yet nothing ran: that used to be `statics
|
|
173
|
+
* passed (integration commands not runnable here)`, the identical "we never
|
|
174
|
+
* checked" silence wearing different words). The boot note leads because it
|
|
175
|
+
* names a concrete command and the trail line is sliced at 300 chars; then the
|
|
176
|
+
* config-gap notes, then the inert-contract note. Unchanged when anything at all
|
|
177
|
+
* was observed, so a project with runnable commands is byte-for-byte unaffected.
|
|
178
|
+
*
|
|
179
|
+
* ZERO ATTEMPTS IS UNOBSERVED, NEVER A PASS: when nothing dynamic was even
|
|
180
|
+
* attempted, the note IS the reason — there is no `statics passed (…)` suffix,
|
|
181
|
+
* because "we never checked" must not read like "we checked and it was fine".
|
|
182
|
+
* IAR1 shipped that verdict TWICE while carrying open verify-FAIL debt.
|
|
183
|
+
*
|
|
184
|
+
* The debt note rides in its OWN field: `reason` stays the mechanical failure
|
|
185
|
+
* because it seeds the autofix child's prompt (see FinalGateOutcome.reason —
|
|
186
|
+
* run 11's fix child executed a recorded claim as an instruction).
|
|
187
|
+
*/
|
|
188
|
+
verdict(debts: GateDebts): FinalGateOutcome;
|
|
189
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-skip blindness guard (mx5 run 16). Per-command env-gap skips stay
|
|
3
|
+
* legitimate (a missing browser must not fail a suite); what may never happen
|
|
4
|
+
* again is ALL of them skipping while the gate still reports PASS — a gate that
|
|
5
|
+
* observed nothing dynamic has no basis to vouch for the assembled app. Pure so
|
|
6
|
+
* the semantics are unit-tested; the tally feeds it its own counters plus runner
|
|
7
|
+
* resolvability.
|
|
8
|
+
*/
|
|
9
|
+
export function observabilityGapFailure(args) {
|
|
10
|
+
if (args.attempted === 0 || args.observed > 0)
|
|
11
|
+
return null;
|
|
12
|
+
if (args.spawnFailures < args.attempted)
|
|
13
|
+
return null;
|
|
14
|
+
const unresolvable = args.runnerBins.filter(b => !args.runnerResolvable(b));
|
|
15
|
+
const runnerNote = unresolvable.length > 0 ?
|
|
16
|
+
` — the project's own runner ${unresolvable
|
|
17
|
+
.map(b => `\`${b}\``)
|
|
18
|
+
.join(', ')} is not spawnable here (not on PATH nor any known install location)`
|
|
19
|
+
: '';
|
|
20
|
+
return (`observability gap: ${args.attempted} integration/boot command(s) exist but NONE `
|
|
21
|
+
+ `could even spawn in this environment${runnerNote}; `
|
|
22
|
+
+ `the gate observed nothing dynamic and cannot vouch for the assembled app`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
|
|
26
|
+
* but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
|
|
27
|
+
* `attempted === 0`, and until now that silence fell straight through to
|
|
28
|
+
* `PASS — no integration command found (statics passed)`: the run-16 blindness class
|
|
29
|
+
* entering through a different door, where "we never checked" reads exactly like "we
|
|
30
|
+
* checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
|
|
31
|
+
* shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
|
|
32
|
+
* godot-engine (package.json whose only script is `verify`) reproduces it live today.
|
|
33
|
+
*
|
|
34
|
+
* So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
|
|
35
|
+
* existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
|
|
36
|
+
*
|
|
37
|
+
* WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
|
|
38
|
+
* the resolution):
|
|
39
|
+
* - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
|
|
40
|
+
* the runs with no dynamic evidence were exactly the runs already known to be
|
|
41
|
+
* carrying defects.
|
|
42
|
+
* - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
|
|
43
|
+
* gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
|
|
44
|
+
* integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
|
|
45
|
+
* missing signal was never the debt, it was the word PASS endorsing the run, and
|
|
46
|
+
* that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
|
|
47
|
+
* seed is `reason`; "no integration command is discoverable" is not fixable by
|
|
48
|
+
* editing code, so the highest-probability child response is to FABRICATE a
|
|
49
|
+
* runnable command to satisfy the gate — the same fabrication class that refuted
|
|
50
|
+
* the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
|
|
51
|
+
* run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
|
|
52
|
+
* means IAR1 and godot-engine can NEVER discover a command, so blocking would end
|
|
53
|
+
* every non-npm run in `failed` permanently, with no remedy — the task's own I3
|
|
54
|
+
* ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
|
|
55
|
+
* its stated consequence is to downgrade to a warning and say so. This is that.
|
|
56
|
+
* The teeth are elsewhere and are real: the verdict word changes, the gate trail says
|
|
57
|
+
* UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
|
|
58
|
+
* gate re-surfaces (it can never auto-close — it is not static-class).
|
|
59
|
+
*/
|
|
60
|
+
export function unobservedVerdict(args) {
|
|
61
|
+
if (args.observed > 0)
|
|
62
|
+
return null;
|
|
63
|
+
// Kept short ON PURPOSE: the run-level trail line slices the reason at 300 chars,
|
|
64
|
+
// and the whole point of this verdict is that the durable record carries it.
|
|
65
|
+
const why = args.discovered === 0 ?
|
|
66
|
+
'no integration, lockfile or boot command was discoverable here, so the gate ran '
|
|
67
|
+
+ 'nothing at all'
|
|
68
|
+
: `all ${args.discovered} discovered command(s) skipped as environment gaps, so the `
|
|
69
|
+
+ 'gate ran nothing observable';
|
|
70
|
+
return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
|
|
71
|
+
+ 'that the assembled product builds, boots or works.');
|
|
72
|
+
}
|
|
73
|
+
export class GateTally {
|
|
74
|
+
// Aggregated failures across ALL sections (mx5 run 13 — see runFinalIntegrationGate's
|
|
75
|
+
// doc). rank 0 = boot/render ("does not serve/render" is the most load-bearing
|
|
76
|
+
// signal); rank 1 = everything else, kept in execution order by stable sort.
|
|
77
|
+
failures = [];
|
|
78
|
+
/** Labels of the dynamic commands that ran AND passed — the PASS reason names them. */
|
|
79
|
+
passed = [];
|
|
80
|
+
// Full-skip blindness counters (mx5 run 16): every dynamic spawn counts an
|
|
81
|
+
// attempt; a real pass OR a real fail counts an observation; skips observe
|
|
82
|
+
// nothing. If everything discovered ends up skipped, observabilityGapFailure
|
|
83
|
+
// turns the silence into a rank-0 failure instead of a static-only PASS.
|
|
84
|
+
attempts = 0;
|
|
85
|
+
observations = 0;
|
|
86
|
+
spawnFailures = 0;
|
|
87
|
+
bins = new Set();
|
|
88
|
+
warnings = [];
|
|
89
|
+
/** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
|
|
90
|
+
* They ride in `unobserved`, not `warnings`, so the caller's existing
|
|
91
|
+
* `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
|
|
92
|
+
* never a PASS. */
|
|
93
|
+
configGapNotes = [];
|
|
94
|
+
/** The inert-launch-contract note (16A): "declared scripts, but no manifest to
|
|
95
|
+
* diff against" — a note, never a failure, never a silent pass. */
|
|
96
|
+
contractNotes = [];
|
|
97
|
+
/** The boot section's own UNOBSERVED verdict (bootSkipVerdict / rejected launch
|
|
98
|
+
* script). Lives outside the dynamic counters ON PURPOSE (mx5 run 18): the
|
|
99
|
+
* test/build commands that did run cannot cancel it. */
|
|
100
|
+
bootNote = null;
|
|
101
|
+
/** A failure at `rank` (default 1). Rank 0 is boot/render, the most load-bearing. */
|
|
102
|
+
fail(text, rank = 1) {
|
|
103
|
+
this.failures.push({ rank, text });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A failure a PROBE returned after observing (nexttask 19A — see
|
|
107
|
+
* FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
|
|
108
|
+
* section, whose `fail` outcome can only arise from a probe that looked. Every
|
|
109
|
+
* other `fail()` keeps its class, so nothing else changes.
|
|
110
|
+
*/
|
|
111
|
+
failObserved(text, rank = 1) {
|
|
112
|
+
this.failures.push({ rank, text, observed: true });
|
|
113
|
+
}
|
|
114
|
+
/** A dynamic command that ran and passed; the PASS reason lists these. */
|
|
115
|
+
ran(label) {
|
|
116
|
+
this.passed.push(label);
|
|
117
|
+
}
|
|
118
|
+
/** A dynamic spawn was attempted through runner `bin` (counted whether or not
|
|
119
|
+
* it then skips). */
|
|
120
|
+
attempted(bin) {
|
|
121
|
+
this.attempts += 1;
|
|
122
|
+
this.bins.add(bin);
|
|
123
|
+
}
|
|
124
|
+
/** The attempt was OBSERVED — a real pass or a real fail; only skips observe nothing. */
|
|
125
|
+
observed() {
|
|
126
|
+
this.observations += 1;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Un-count one observation. The config-gap branch (mx5 run 20): a launch
|
|
130
|
+
* script failed, the four static conditions plus the placeholder re-run said
|
|
131
|
+
* the failure was a missing env variable the shipped template declares, and
|
|
132
|
+
* so NOTHING about that script was observed — the real run could not reach
|
|
133
|
+
* it and the probe run is a diagnostic, never an observation. It un-counts,
|
|
134
|
+
* exactly like a skip would have.
|
|
135
|
+
*/
|
|
136
|
+
unobserve() {
|
|
137
|
+
this.observations -= 1;
|
|
138
|
+
}
|
|
139
|
+
/** The attempt through `bin` never even spawned (ENOENT-class), as opposed to a
|
|
140
|
+
* tool-level env gap inside the chain. */
|
|
141
|
+
spawnFailure(bin) {
|
|
142
|
+
this.spawnFailures += 1;
|
|
143
|
+
this.bins.add(bin);
|
|
144
|
+
}
|
|
145
|
+
/** A WARNING appended to a PASS reason (excuse-note-covered skip, render note). */
|
|
146
|
+
warn(line) {
|
|
147
|
+
this.warnings.push(line);
|
|
148
|
+
}
|
|
149
|
+
configGap(note) {
|
|
150
|
+
this.configGapNotes.push(note);
|
|
151
|
+
}
|
|
152
|
+
contractNote(note) {
|
|
153
|
+
this.contractNotes.push(note);
|
|
154
|
+
}
|
|
155
|
+
/** The boot section's UNOBSERVED verdict, or null when the boot was observed. */
|
|
156
|
+
bootUnobserved(note) {
|
|
157
|
+
this.bootNote = note;
|
|
158
|
+
}
|
|
159
|
+
/** True while nothing dynamic has been attempted and nothing has failed —
|
|
160
|
+
* the state the zero-discovery return checks (see runFinalIntegrationGate). */
|
|
161
|
+
silent() {
|
|
162
|
+
return this.attempts === 0 && this.failures.length === 0;
|
|
163
|
+
}
|
|
164
|
+
/** The run-16 blindness guard over this tally's own counters (see
|
|
165
|
+
* observabilityGapFailure); the caller fails it at rank 0. */
|
|
166
|
+
blindness(runnerResolvable) {
|
|
167
|
+
return observabilityGapFailure({
|
|
168
|
+
attempted: this.attempts,
|
|
169
|
+
observed: this.observations,
|
|
170
|
+
spawnFailures: this.spawnFailures,
|
|
171
|
+
runnerBins: [...this.bins],
|
|
172
|
+
runnerResolvable
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The verdict. Pure over the tally's state; the same debts ride on every shape.
|
|
177
|
+
*
|
|
178
|
+
* FAIL when anything failed: stable-sorted so boot/render (rank 0) leads and
|
|
179
|
+
* everything else keeps execution order. One failure keeps the exact
|
|
180
|
+
* single-failure wording; several become a numbered list so the trail, the
|
|
181
|
+
* ACCEPT picker, and the autofix seed all carry the complete ranked picture.
|
|
182
|
+
* The observed subset rides along by exact text identity (19A) — the demote
|
|
183
|
+
* decision downstream reads THIS, instead of re-deriving observability from
|
|
184
|
+
* the failure string.
|
|
185
|
+
*
|
|
186
|
+
* Otherwise `ok: true`, with the UNOBSERVED note when the gate could not
|
|
187
|
+
* observe something it meant to. Two independent notes, either or both of which
|
|
188
|
+
* may apply: the boot never ran (run 18), and/or NOTHING dynamic ran at all
|
|
189
|
+
* (unobservedVerdict — commands WERE discovered, none spawn-failed so the run-16
|
|
190
|
+
* guard correctly stayed silent, and yet nothing ran: that used to be `statics
|
|
191
|
+
* passed (integration commands not runnable here)`, the identical "we never
|
|
192
|
+
* checked" silence wearing different words). The boot note leads because it
|
|
193
|
+
* names a concrete command and the trail line is sliced at 300 chars; then the
|
|
194
|
+
* config-gap notes, then the inert-contract note. Unchanged when anything at all
|
|
195
|
+
* was observed, so a project with runnable commands is byte-for-byte unaffected.
|
|
196
|
+
*
|
|
197
|
+
* ZERO ATTEMPTS IS UNOBSERVED, NEVER A PASS: when nothing dynamic was even
|
|
198
|
+
* attempted, the note IS the reason — there is no `statics passed (…)` suffix,
|
|
199
|
+
* because "we never checked" must not read like "we checked and it was fine".
|
|
200
|
+
* IAR1 shipped that verdict TWICE while carrying open verify-FAIL debt.
|
|
201
|
+
*
|
|
202
|
+
* The debt note rides in its OWN field: `reason` stays the mechanical failure
|
|
203
|
+
* because it seeds the autofix child's prompt (see FinalGateOutcome.reason —
|
|
204
|
+
* run 11's fix child executed a recorded claim as an instruction).
|
|
205
|
+
*/
|
|
206
|
+
verdict(debts) {
|
|
207
|
+
const withDebts = (o) => ({
|
|
208
|
+
...o,
|
|
209
|
+
...(debts.debtNote ? { debtNote: debts.debtNote } : {}),
|
|
210
|
+
openDebts: debts.openDebts
|
|
211
|
+
});
|
|
212
|
+
if (this.failures.length > 0) {
|
|
213
|
+
const ranked = [...this.failures].sort((a, b) => a.rank - b.rank);
|
|
214
|
+
const texts = ranked.map(f => f.text);
|
|
215
|
+
const observed = ranked.filter(f => f.observed === true).map(f => f.text);
|
|
216
|
+
return withDebts({
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: texts.length === 1 ?
|
|
219
|
+
texts[0]
|
|
220
|
+
: `${texts.length} failures (ranked, most load-bearing first):\n${texts
|
|
221
|
+
.map((t, i) => `${i + 1}. ${t}`)
|
|
222
|
+
.join('\n')}`,
|
|
223
|
+
failures: texts,
|
|
224
|
+
...(observed.length > 0 ? { observedFailures: observed } : {})
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const unobserved = [
|
|
228
|
+
this.bootNote,
|
|
229
|
+
unobservedVerdict({ discovered: this.attempts, observed: this.observations }),
|
|
230
|
+
...this.configGapNotes,
|
|
231
|
+
...this.contractNotes
|
|
232
|
+
]
|
|
233
|
+
.filter(n => n !== null)
|
|
234
|
+
.join(' ');
|
|
235
|
+
if (this.attempts === 0) {
|
|
236
|
+
return withDebts({ ok: true, unobserved, reason: unobserved });
|
|
237
|
+
}
|
|
238
|
+
const warningNote = this.warnings.length > 0 ? ` — WARNING: ${this.warnings.join('; WARNING: ')}` : '';
|
|
239
|
+
return withDebts({
|
|
240
|
+
ok: true,
|
|
241
|
+
...(unobserved ? { unobserved } : {}),
|
|
242
|
+
reason: (unobserved ? `${unobserved} — ` : '')
|
|
243
|
+
+ (this.passed.length > 0 ?
|
|
244
|
+
`statics + ${this.passed.map(c => `\`${c}\``).join(', ')} passed`
|
|
245
|
+
: 'statics passed (integration commands not runnable here)')
|
|
246
|
+
+ warningNote
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implementation-turn supervision — what happens between "the spec was delivered
|
|
3
|
+
* to the main session" and "we know how the implementation turn REALLY ended".
|
|
4
|
+
*
|
|
5
|
+
* A single `waitForIdle` resolves for four different reasons, and three of them
|
|
6
|
+
* are not "the model finished":
|
|
7
|
+
* • `aborted` — a user ESC (or the command watchdog) cut the turn short;
|
|
8
|
+
* • `compaction` — a threshold auto-compaction parked the turn at idle without
|
|
9
|
+
* auto-continuing (the runtime expects a manual continue);
|
|
10
|
+
* • `error` — the model/provider died mid-turn after pi's own retries;
|
|
11
|
+
* • `stop` — genuine completion.
|
|
12
|
+
* `classifyTurnEnd` reads the session entries and names ONE of those, in the
|
|
13
|
+
* precedence the supervision sequence needs; `superviseImplementation` then
|
|
14
|
+
* resumes across compactions, lets the user steer after an interrupt, and reports
|
|
15
|
+
* the terminal outcome. The orchestrator calls it once.
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
18
|
+
/** How the most recent implementation turn ended. See {@link classifyTurnEnd}. */
|
|
19
|
+
export type TurnEnd = 'stop' | 'aborted' | 'error' | 'compaction';
|
|
20
|
+
/**
|
|
21
|
+
* The slice of a session entry the classifier reads. Structural on purpose: the
|
|
22
|
+
* runtime's `SessionEntry` union is wider than we need, and the tests build
|
|
23
|
+
* entries from plain literals.
|
|
24
|
+
*/
|
|
25
|
+
export type SessionEntryLike = {
|
|
26
|
+
type?: string;
|
|
27
|
+
message?: {
|
|
28
|
+
role?: string;
|
|
29
|
+
stopReason?: string;
|
|
30
|
+
errorMessage?: string;
|
|
31
|
+
content?: unknown;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Classify how the most recent turn ended, from the session entries alone.
|
|
36
|
+
*
|
|
37
|
+
* Precedence, when several signals are present at once (this is the order the
|
|
38
|
+
* supervision sequence has always applied, now stated in one place):
|
|
39
|
+
* 1. `aborted` — the last assistant message has stopReason "aborted". A user
|
|
40
|
+
* ESC (or watchdog abort) wins over everything: it is not a
|
|
41
|
+
* compaction pause, and the steer loop owns it.
|
|
42
|
+
* 2. `compaction` — a `compaction` entry sits AFTER the last assistant message.
|
|
43
|
+
* Position-based, not timestamp-based: the runtime appends the
|
|
44
|
+
* boundary to the tail of the branch after the message that
|
|
45
|
+
* triggered it (`appendCompaction` → `_appendEntry` push), so a
|
|
46
|
+
* trailing compaction means we are parked with no continuation.
|
|
47
|
+
* A finished turn ends on an assistant message; an *overflow*
|
|
48
|
+
* compaction self-retries and never leaves us idle here.
|
|
49
|
+
* 3. `error` — the last assistant message has stopReason "error": the
|
|
50
|
+
* model/provider died (context-overflow 400, disconnect, 5xx)
|
|
51
|
+
* after pi exhausted its own retries.
|
|
52
|
+
* 4. `stop` — anything else, including a session with no assistant turn.
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyTurnEnd(entries: ReadonlyArray<SessionEntryLike>): TurnEnd;
|
|
55
|
+
/**
|
|
56
|
+
* The provider's error cause for an `error` turn end — the message the run's
|
|
57
|
+
* "stopped at …" line quotes so the real cause is not lost. Undefined unless the
|
|
58
|
+
* last assistant message ended with stopReason "error".
|
|
59
|
+
*/
|
|
60
|
+
export declare function turnErrorMessage(entries: ReadonlyArray<SessionEntryLike>): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* True when the watchdog's reminder follow-up has been DELIVERED into the session
|
|
63
|
+
* after the aborted assistant turn but its own turn has not finished yet — the
|
|
64
|
+
* artifact that confirms a pending watchdog recovery. Scoped after the LAST
|
|
65
|
+
* assistant entry so an earlier fire's reminder (already answered by its own
|
|
66
|
+
* turn) never matches.
|
|
67
|
+
*/
|
|
68
|
+
export declare function watchdogReminderDelivered(entries: ReadonlyArray<SessionEntryLike>): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The slice of the replacement-session context the supervision needs.
|
|
71
|
+
* `sendUserMessage` lives on ReplacedSessionContext (not the base command ctx,
|
|
72
|
+
* and not re-exported from the package), so we narrow to just what we call.
|
|
73
|
+
*/
|
|
74
|
+
export type SteerCtx = ExtensionCommandContext & {
|
|
75
|
+
sendUserMessage(content: string, options?: {
|
|
76
|
+
deliverAs?: 'steer' | 'followUp';
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Timing knobs for the watchdog-abort guard in {@link steerUntilDone}, injectable
|
|
81
|
+
* so tests exercise the grace expiry without a 10-second wait. `graceMs` bounds
|
|
82
|
+
* how long the loop waits for the watchdog's follow-up to be DELIVERED (not to
|
|
83
|
+
* finish — its turn may legitimately run for minutes afterwards); delivery is
|
|
84
|
+
* normally near-instant, so the grace only expires on a stale flag.
|
|
85
|
+
*/
|
|
86
|
+
export interface SteerWatchdogDeps {
|
|
87
|
+
consume: () => boolean;
|
|
88
|
+
graceMs: number;
|
|
89
|
+
pollMs: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* What the resume/steer loops genuinely need from the session — no more. Built
|
|
93
|
+
* from a live ctx by {@link turnDepsFor}; a test builds one from plain fakes.
|
|
94
|
+
*/
|
|
95
|
+
export interface ImplementationTurnDeps {
|
|
96
|
+
/** The live session entries — the only thing the classifier reads. */
|
|
97
|
+
entries: () => ReadonlyArray<SessionEntryLike>;
|
|
98
|
+
/** Queue a follow-up user turn on the (idle) session. */
|
|
99
|
+
send: (text: string) => Promise<void>;
|
|
100
|
+
/** Wait for the session to go idle again. */
|
|
101
|
+
waitForIdle: () => Promise<void>;
|
|
102
|
+
/** Solicit steering text after an interrupt; undefined/empty = pause the run. */
|
|
103
|
+
ask: () => Promise<string | undefined>;
|
|
104
|
+
/** The watchdog one-shot flag and the bounded wait for its follow-up. */
|
|
105
|
+
watchdog: SteerWatchdogDeps;
|
|
106
|
+
/** Optional trail for the decisions taken; absent → silent. */
|
|
107
|
+
log?: (msg: string) => void;
|
|
108
|
+
}
|
|
109
|
+
export interface SuperviseOptions {
|
|
110
|
+
/**
|
|
111
|
+
* Ask the user for a steering message after they interrupt (ESC) the
|
|
112
|
+
* implementation turn. Return text to continue the same task as another turn,
|
|
113
|
+
* or undefined/empty to pause the run. Defaults to a bridged SessionUI.ask
|
|
114
|
+
* (local TUI input raced against a remote browser card); injectable so the
|
|
115
|
+
* steer loop is testable without a real dialog.
|
|
116
|
+
*/
|
|
117
|
+
promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>;
|
|
118
|
+
/** Watchdog-guard timing overrides (tests); absent → production defaults. */
|
|
119
|
+
watchdog?: Partial<SteerWatchdogDeps>;
|
|
120
|
+
log?: (msg: string) => void;
|
|
121
|
+
}
|
|
122
|
+
/** Bind the supervision deps to a live session context. */
|
|
123
|
+
export declare function turnDepsFor(ctx: SteerCtx, opts?: SuperviseOptions): ImplementationTurnDeps;
|
|
124
|
+
/**
|
|
125
|
+
* Nudge that resumes an implementation turn the runtime parked at a compaction
|
|
126
|
+
* boundary. It must let a turn that was genuinely finished (then tipped over the
|
|
127
|
+
* threshold by its own final message) confirm completion without inventing busywork
|
|
128
|
+
* — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
|
|
129
|
+
* from the boundary alone, so the wording lets a done turn end in one line.
|
|
130
|
+
*/
|
|
131
|
+
export declare const CONTINUE_AFTER_COMPACTION: string;
|
|
132
|
+
/**
|
|
133
|
+
* Safety cap on compaction-driven resumes for a single implementation turn. Each
|
|
134
|
+
* resume follows a real compaction (which only fires after the model produced a
|
|
135
|
+
* turn large enough to cross the threshold), so a legitimately large task may
|
|
136
|
+
* resume a handful of times; the cap exists only to stop a pathological loop from
|
|
137
|
+
* auto-sending forever with no user in the loop. Hitting it stops resuming and lets
|
|
138
|
+
* the verify gate / `/task-auto-resume` catch any leftover incompleteness.
|
|
139
|
+
*/
|
|
140
|
+
export declare const MAX_COMPACTION_RESUMES = 20;
|
|
141
|
+
/**
|
|
142
|
+
* Resume an implementation turn that went idle at a threshold-compaction boundary.
|
|
143
|
+
* The runtime compacts and parks at idle without auto-continuing; we send a
|
|
144
|
+
* continue and wait again, repeating across successive compactions until the turn
|
|
145
|
+
* ends on a real assistant message (genuine completion). A user ESC takes priority
|
|
146
|
+
* (`classifyTurnEnd` ranks `aborted` above `compaction`, so the steer loop handles
|
|
147
|
+
* it), and the safety cap bounds a runaway. Returns the number of resumes
|
|
148
|
+
* performed (0 when the turn did not end on a compaction).
|
|
149
|
+
*/
|
|
150
|
+
export declare function resumeAcrossCompactions(deps: ImplementationTurnDeps): Promise<number>;
|
|
151
|
+
/**
|
|
152
|
+
* After the implementation turn settles, honour a user ESC by letting them steer.
|
|
153
|
+
*
|
|
154
|
+
* `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
|
|
155
|
+
* the turn → idle). When the last turn was aborted, the host's main input loop is
|
|
156
|
+
* blocked inside our command handler, so a message typed in the editor would only
|
|
157
|
+
* queue, never run (interactive-mode routes idle input through onInputCallback,
|
|
158
|
+
* which is unset while we hold the loop). We therefore solicit the steering text
|
|
159
|
+
* ourselves and feed it back as another turn via sendUserMessage — which runs to
|
|
160
|
+
* completion when the session is idle. Repeat until a turn finishes uninterrupted.
|
|
161
|
+
*
|
|
162
|
+
* A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
|
|
163
|
+
* from a human ESC by the session entries alone at that instant. The watchdog
|
|
164
|
+
* queues its own recovery follow-up, so prompting there would show a steering
|
|
165
|
+
* dialog to an empty room and wedge an unattended run on the race. The one-shot
|
|
166
|
+
* flag (set synchronously before the abort) routes that case to
|
|
167
|
+
* {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
|
|
168
|
+
* followed by the ordinary prompt, never to a suppressed one.
|
|
169
|
+
*
|
|
170
|
+
* Returns true when the user declined to steer (empty/cancelled) and the run
|
|
171
|
+
* should pause; false when the implementation completed (steered or not).
|
|
172
|
+
*/
|
|
173
|
+
export declare function steerUntilDone(deps: ImplementationTurnDeps): Promise<boolean>;
|
|
174
|
+
export interface ImplementationOutcome {
|
|
175
|
+
/**
|
|
176
|
+
* The user interrupted the implementation (ESC) and then declined to steer
|
|
177
|
+
* (empty steer prompt) — they want the run to pause rather than continue. A
|
|
178
|
+
* plain ESC followed by steering text does NOT set this: that case loops on the
|
|
179
|
+
* same task until a turn finishes uninterrupted.
|
|
180
|
+
*/
|
|
181
|
+
interrupted: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* The failure cause when the turn ended with stopReason "error" (the
|
|
184
|
+
* model/provider died mid-implementation after the task file was already
|
|
185
|
+
* marked `completed` at spec-handoff). Undefined when the turn ended cleanly,
|
|
186
|
+
* and never set alongside `interrupted`.
|
|
187
|
+
*/
|
|
188
|
+
error?: string;
|
|
189
|
+
/** Compaction resumes performed before the turn reached its real end. */
|
|
190
|
+
resumes: number;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Supervise an implementation turn from the first idle after spec delivery until
|
|
194
|
+
* its REAL end: resume across any compaction boundaries first (so steering and
|
|
195
|
+
* error inspection see the turn's actual end, not a compaction pause), then let
|
|
196
|
+
* the user steer across interrupts, then read how the final turn ended. The
|
|
197
|
+
* caller has already awaited the first idle.
|
|
198
|
+
*/
|
|
199
|
+
export declare function superviseImplementation(ctx: SteerCtx, opts?: SuperviseOptions): Promise<ImplementationOutcome>;
|
|
200
|
+
/** {@link superviseImplementation} over an already-bound deps object (tests). */
|
|
201
|
+
export declare function superviseWith(deps: ImplementationTurnDeps): Promise<ImplementationOutcome>;
|