@mjasnikovs/pi-task 0.17.27 → 0.18.0
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 +2 -1
- package/dist/config/register.js +5 -0
- package/dist/shared/child-process.d.ts +21 -0
- package/dist/shared/child-process.js +52 -4
- package/dist/shared/model-endpoint.d.ts +8 -0
- package/dist/shared/model-endpoint.js +56 -0
- package/dist/task/auto-orchestrator.d.ts +9 -0
- package/dist/task/auto-orchestrator.js +67 -16
- package/dist/task/child-runner.js +1 -1
- package/dist/task/enforce-guidelines.d.ts +1 -0
- package/dist/task/enforce-guidelines.js +6 -0
- package/dist/task/env-notes.d.ts +23 -0
- package/dist/task/env-notes.js +121 -0
- package/dist/task/final-gate-fix.d.ts +91 -0
- package/dist/task/final-gate-fix.js +182 -0
- package/dist/task/final-gate.d.ts +40 -4
- package/dist/task/final-gate.js +205 -23
- package/dist/task/gate-deps.d.ts +8 -1
- package/dist/task/gate-deps.js +31 -7
- package/dist/task/phases.js +62 -23
- package/dist/task/prompts.d.ts +1 -1
- package/dist/task/prompts.js +8 -1
- package/dist/task/verify-work.d.ts +12 -1
- package/dist/task/verify-work.js +28 -2
- package/dist/task/widget.d.ts +3 -2
- package/dist/task/widget.js +4 -2
- package/dist/workers/pi-worker-core.d.ts +16 -0
- package/dist/workers/pi-worker-core.js +21 -1
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export interface PiTaskConfig {
|
|
|
5
5
|
orientation: boolean;
|
|
6
6
|
enforceGuidelines: boolean;
|
|
7
7
|
verifyWork: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Run the four research workers concurrently instead of one at a time.
|
|
10
|
+
* DEFAULT OFF: serial was A/B-proven faster on a single-GPU local backend
|
|
11
|
+
* (concurrent streams split the GPU and slow each other ~4×, see
|
|
12
|
+
* phases.ts). Turn on only for a parallel-capable backend.
|
|
13
|
+
*/
|
|
14
|
+
parallelResearchWorkers: boolean;
|
|
8
15
|
}
|
|
9
16
|
export declare function getConfig(): PiTaskConfig;
|
|
10
17
|
export declare function saveConfig(config: PiTaskConfig): Promise<void>;
|
package/dist/config/config.js
CHANGED
|
@@ -8,7 +8,8 @@ const DEFAULTS = {
|
|
|
8
8
|
autoCommit: true,
|
|
9
9
|
orientation: true,
|
|
10
10
|
enforceGuidelines: true,
|
|
11
|
-
verifyWork: true
|
|
11
|
+
verifyWork: true,
|
|
12
|
+
parallelResearchWorkers: false
|
|
12
13
|
};
|
|
13
14
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
|
|
14
15
|
const _g = globalThis;
|
package/dist/config/register.js
CHANGED
|
@@ -72,6 +72,11 @@ const ITEMS = [
|
|
|
72
72
|
id: 'enforceGuidelines',
|
|
73
73
|
label: 'enforce guidelines',
|
|
74
74
|
description: "Check each /task and /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations. Enabling it makes /task wait for the implementation"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'parallelResearchWorkers',
|
|
78
|
+
label: 'parallel research',
|
|
79
|
+
description: 'Run the 4 research workers concurrently. Leave OFF on a single-GPU local server (serial is measurably faster there); turn on only for a parallel-capable model backend'
|
|
75
80
|
}
|
|
76
81
|
];
|
|
77
82
|
function makeTheme(theme) {
|
|
@@ -39,6 +39,13 @@ export interface ChildResult {
|
|
|
39
39
|
* Only populated in json-events mode.
|
|
40
40
|
*/
|
|
41
41
|
modelError?: string;
|
|
42
|
+
/**
|
|
43
|
+
* true when the stall guard killed the child: no output for the stall
|
|
44
|
+
* window AND the model endpoint probe found the backend unreachable.
|
|
45
|
+
* Callers must check this BEFORE `aborted` — the kill sets aborted too,
|
|
46
|
+
* and without the flag it would mislabel as a user cancel.
|
|
47
|
+
*/
|
|
48
|
+
stalled?: boolean;
|
|
42
49
|
}
|
|
43
50
|
export interface ToolCall {
|
|
44
51
|
name: string;
|
|
@@ -76,6 +83,20 @@ export interface RunChildJsonEventsOptions {
|
|
|
76
83
|
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
77
84
|
onToolCall?: (call: ToolCall) => LoopHit | null;
|
|
78
85
|
onFirstByte?: () => void;
|
|
86
|
+
/**
|
|
87
|
+
* Dead-backend stall guard (mx5 run 7: model server died mid-child, the
|
|
88
|
+
* child hung MUTE for 64 minutes). Liveness is OUTPUT PROGRESS, not wall
|
|
89
|
+
* time: any stdout/stderr chunk resets the window, so honest long work is
|
|
90
|
+
* never killed. Only when nothing arrived for `afterMs` is `probe` asked
|
|
91
|
+
* whether the model backend is reachable — reachable → keep waiting
|
|
92
|
+
* (prompt processing legitimately emits nothing for minutes); unreachable
|
|
93
|
+
* → the child is killed and the result carries `stalled: true` so callers
|
|
94
|
+
* report the real cause instead of hanging or mislabeling a user cancel.
|
|
95
|
+
*/
|
|
96
|
+
stall?: {
|
|
97
|
+
afterMs: number;
|
|
98
|
+
probe: () => Promise<boolean>;
|
|
99
|
+
};
|
|
79
100
|
}
|
|
80
101
|
export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
|
|
81
102
|
/**
|
|
@@ -197,8 +197,40 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
197
197
|
}, KILL_GRACE_MS);
|
|
198
198
|
};
|
|
199
199
|
const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
|
|
200
|
+
// Dead-backend stall guard (json-events children only; see the option
|
|
201
|
+
// docs). Any output resets the window; a reachable probe also resets it
|
|
202
|
+
// so the next probe is a full window away, not every tick.
|
|
203
|
+
const stall = opts?.mode === 'json-events' ? opts.stall : undefined;
|
|
204
|
+
let lastActivity = Date.now();
|
|
205
|
+
let stalled = false;
|
|
206
|
+
let probing = false;
|
|
207
|
+
const stallTimer = stall ?
|
|
208
|
+
setInterval(() => {
|
|
209
|
+
if (probing || Date.now() - lastActivity < stall.afterMs)
|
|
210
|
+
return;
|
|
211
|
+
probing = true;
|
|
212
|
+
stall
|
|
213
|
+
.probe()
|
|
214
|
+
.then(reachable => {
|
|
215
|
+
probing = false;
|
|
216
|
+
if (reachable || stalled) {
|
|
217
|
+
lastActivity = Date.now();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
stalled = true;
|
|
221
|
+
killProc();
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {
|
|
224
|
+
// A probe that itself crashed proves nothing —
|
|
225
|
+
// benefit of the doubt, keep waiting.
|
|
226
|
+
probing = false;
|
|
227
|
+
lastActivity = Date.now();
|
|
228
|
+
});
|
|
229
|
+
}, Math.max(50, Math.min(stall.afterMs / 2, 15_000)))
|
|
230
|
+
: undefined;
|
|
200
231
|
let firstByteFired = false;
|
|
201
232
|
proc.stdout?.on('data', (d) => {
|
|
233
|
+
lastActivity = Date.now();
|
|
202
234
|
if (!firstByteFired) {
|
|
203
235
|
firstByteFired = true;
|
|
204
236
|
opts?.onFirstByte?.();
|
|
@@ -212,9 +244,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
212
244
|
stdout += chunk;
|
|
213
245
|
});
|
|
214
246
|
proc.stderr?.on('data', (d) => {
|
|
247
|
+
lastActivity = Date.now();
|
|
215
248
|
stderr += d.toString();
|
|
216
249
|
});
|
|
217
250
|
proc.on('close', (code) => {
|
|
251
|
+
if (stallTimer)
|
|
252
|
+
clearInterval(stallTimer);
|
|
218
253
|
if (sink)
|
|
219
254
|
sink.flush();
|
|
220
255
|
const text = sink ? sink.text : undefined;
|
|
@@ -224,10 +259,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
224
259
|
exitCode: code ?? 0,
|
|
225
260
|
aborted,
|
|
226
261
|
text,
|
|
227
|
-
modelError: sink?.modelError
|
|
262
|
+
modelError: sink?.modelError,
|
|
263
|
+
...(stalled ? { stalled: true } : {})
|
|
228
264
|
});
|
|
229
265
|
});
|
|
230
266
|
proc.on('error', () => {
|
|
267
|
+
if (stallTimer)
|
|
268
|
+
clearInterval(stallTimer);
|
|
231
269
|
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
232
270
|
});
|
|
233
271
|
if (signal) {
|
|
@@ -247,15 +285,25 @@ export function summarizeToolArgs(toolName, args) {
|
|
|
247
285
|
if (!args || typeof args !== 'object')
|
|
248
286
|
return '';
|
|
249
287
|
const a = args;
|
|
288
|
+
const clip = (s) => {
|
|
289
|
+
const one = s.replace(/\s+/g, ' ').trim();
|
|
290
|
+
return one.length > 60 ? one.slice(0, 59) + '…' : one;
|
|
291
|
+
};
|
|
250
292
|
if (toolName === 'bash' && typeof a.command === 'string') {
|
|
251
293
|
return a.command.replace(/\s+/g, ' ').trim();
|
|
252
294
|
}
|
|
253
295
|
if (toolName === 'pi-worker-docs'
|
|
254
296
|
&& typeof a.module === 'string'
|
|
255
297
|
&& typeof a.query === 'string') {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
298
|
+
return `${a.module} "${clip(a.query)}"`;
|
|
299
|
+
}
|
|
300
|
+
// Search/fetch workers: without these the debug log shows a bare tool name
|
|
301
|
+
// and a run audit cannot tell WHAT was searched or fetched.
|
|
302
|
+
if (toolName === 'pi-worker-search' && typeof a.query === 'string') {
|
|
303
|
+
return `"${clip(a.query)}"`;
|
|
304
|
+
}
|
|
305
|
+
if (toolName === 'pi-worker-fetch' && typeof a.url === 'string') {
|
|
306
|
+
return clip(a.url);
|
|
259
307
|
}
|
|
260
308
|
if (typeof a.file_path === 'string')
|
|
261
309
|
return a.file_path;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Base URLs of every custom provider pi is configured with (possibly none). */
|
|
2
|
+
export declare function discoverModelEndpoints(agentDir?: string): string[];
|
|
3
|
+
/**
|
|
4
|
+
* true → at least one endpoint ANSWERED (any HTTP status counts — a 404 still
|
|
5
|
+
* proves the server is alive); false → every probe was refused or hung past the
|
|
6
|
+
* timeout. An empty list is true: nothing to probe means never kill.
|
|
7
|
+
*/
|
|
8
|
+
export declare function probeModelEndpoints(urls: string[], timeoutMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-endpoint — discovery + reachability probe for the model backend(s) a
|
|
3
|
+
* child pi process talks to.
|
|
4
|
+
*
|
|
5
|
+
* The failure this serves (mx5 run 7, validated): the model server went down
|
|
6
|
+
* mid-gate-child and the child hung MUTE for 64 minutes — pi's own
|
|
7
|
+
* connection-error handling only fires when a request FAILS, not when the
|
|
8
|
+
* backend freezes and the open request simply never answers. The stall guard in
|
|
9
|
+
* runChild uses this module to tell the two apart: no output could be honest
|
|
10
|
+
* long work (prompt processing emits nothing for minutes), so only "no output
|
|
11
|
+
* AND the endpoint does not answer" is treated as a dead backend.
|
|
12
|
+
*
|
|
13
|
+
* Discovery is generic: the custom providers pi itself is configured with
|
|
14
|
+
* (models.json `providers.*.baseUrl`), no provider or server names hardcoded.
|
|
15
|
+
* No discoverable endpoint → nothing to probe → the guard NEVER kills (a child
|
|
16
|
+
* on a backend we cannot see must get the benefit of the doubt).
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as os from 'node:os';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
/** Base URLs of every custom provider pi is configured with (possibly none). */
|
|
22
|
+
export function discoverModelEndpoints(agentDir = path.join(os.homedir(), '.pi', 'agent')) {
|
|
23
|
+
try {
|
|
24
|
+
const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models.json'), 'utf8'));
|
|
25
|
+
const urls = [];
|
|
26
|
+
for (const p of Object.values(j.providers ?? {})) {
|
|
27
|
+
if (typeof p?.baseUrl === 'string' && p.baseUrl.length > 0)
|
|
28
|
+
urls.push(p.baseUrl);
|
|
29
|
+
}
|
|
30
|
+
return [...new Set(urls)];
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* true → at least one endpoint ANSWERED (any HTTP status counts — a 404 still
|
|
38
|
+
* proves the server is alive); false → every probe was refused or hung past the
|
|
39
|
+
* timeout. An empty list is true: nothing to probe means never kill.
|
|
40
|
+
*/
|
|
41
|
+
export async function probeModelEndpoints(urls, timeoutMs = 5_000) {
|
|
42
|
+
if (urls.length === 0)
|
|
43
|
+
return true;
|
|
44
|
+
const results = await Promise.all(urls.map(async (u) => {
|
|
45
|
+
try {
|
|
46
|
+
await fetch(new URL('models', u.endsWith('/') ? u : `${u}/`), {
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
48
|
+
});
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
return results.some(Boolean);
|
|
56
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { type FinalGateFixFn } from './gate-deps.js';
|
|
2
3
|
import { type GateDeps } from './task-gates.js';
|
|
3
4
|
/**
|
|
4
5
|
* Injectable seams so the planner and loop are testable without spawning pi.
|
|
@@ -32,6 +33,14 @@ export interface AutoDeps extends GateDeps {
|
|
|
32
33
|
ok: boolean;
|
|
33
34
|
reason: string;
|
|
34
35
|
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Bounded model-driven fix pass for a final-gate FAIL (see final-gate-fix.ts),
|
|
38
|
+
* offered as the picker's third option. Runs the fix child, applies the
|
|
39
|
+
* command-shrink guard, and re-runs the gate; the result's `ok` means the gate
|
|
40
|
+
* now passes. Absent (tests / no fix wiring) → the picker keeps only
|
|
41
|
+
* Leave-failed / Accept, exactly the pre-autofix behavior.
|
|
42
|
+
*/
|
|
43
|
+
finalGateFix?: FinalGateFixFn;
|
|
35
44
|
}
|
|
36
45
|
/**
|
|
37
46
|
* Expand any @file references in the feature text by appending each referenced
|
|
@@ -27,6 +27,7 @@ import { buildGateDeps } from './gate-deps.js';
|
|
|
27
27
|
import { runGatesForTask } from './task-gates.js';
|
|
28
28
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
29
29
|
import { runFinalIntegrationGate } from './final-gate.js';
|
|
30
|
+
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
|
|
30
31
|
import { getConfig } from '../config/config.js';
|
|
31
32
|
// Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
|
|
32
33
|
// when the model emits NONE), but a model that never says NONE would otherwise
|
|
@@ -547,7 +548,7 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
547
548
|
// The final integration gate follows the `verify work` switch: it is the
|
|
548
549
|
// run-level half of the same verification story.
|
|
549
550
|
finalGate: cwd2 => getConfig().verifyWork ?
|
|
550
|
-
|
|
551
|
+
runFinalIntegrationGate(cwd2)
|
|
551
552
|
: Promise.resolve({ ok: true, reason: 'disabled' })
|
|
552
553
|
};
|
|
553
554
|
}
|
|
@@ -598,32 +599,82 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
598
599
|
// the gate, so fixing and resuming converges.
|
|
599
600
|
if (deps.finalGate) {
|
|
600
601
|
active.ui.notify(`${id}: running final integration gate…`, 'info');
|
|
601
|
-
|
|
602
|
-
|
|
602
|
+
// Run-level gate trail on the parent task file — same durable
|
|
603
|
+
// auditability contract as the per-task `## gates` records.
|
|
604
|
+
const recGate = async (line) => {
|
|
605
|
+
try {
|
|
606
|
+
await deps.record?.(cwd, id, line);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// recording must never break the gate
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
let fin = await deps.finalGate(cwd);
|
|
613
|
+
if (!fin.ok)
|
|
614
|
+
await recGate(`final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
|
|
615
|
+
// Resolution loop: Leave-failed (recommended) / Autofix (bounded,
|
|
616
|
+
// model-driven fix pass + gate re-run — run 7's gap: the picker
|
|
617
|
+
// had NO automated fix path) / Accept. The user always decides;
|
|
618
|
+
// after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
|
|
619
|
+
// autofix card is withdrawn so the loop cannot run unbounded.
|
|
620
|
+
let fixAttempts = 0;
|
|
621
|
+
while (!fin.ok) {
|
|
622
|
+
const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
|
|
603
623
|
const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}\n\n`
|
|
604
624
|
+ 'All tasks are checked off — this is the whole-repo check '
|
|
605
|
-
+ '(the project’s own test/build/static commands, run unaided).'
|
|
625
|
+
+ '(the project’s own test/build/static commands, run unaided).'
|
|
626
|
+
+ (fixAttempts > 0 ?
|
|
627
|
+
`\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
|
|
628
|
+
: '');
|
|
606
629
|
const answer = await new SessionUI(active).ask({
|
|
607
630
|
localTitle: 'Final integration gate failed — how should pi proceed?',
|
|
608
631
|
displayQuestion: question,
|
|
609
632
|
question,
|
|
610
|
-
recommended:
|
|
611
|
-
recommended2:
|
|
633
|
+
recommended: FINAL_LEAVE_LABEL,
|
|
634
|
+
recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
|
|
612
635
|
allowSkip: false,
|
|
613
636
|
options: [
|
|
614
|
-
{
|
|
615
|
-
|
|
616
|
-
value:
|
|
617
|
-
|
|
618
|
-
{ label:
|
|
637
|
+
{ label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
|
|
638
|
+
...(canAutofix ?
|
|
639
|
+
[{ label: FINAL_AUTOFIX_LABEL, value: FINAL_AUTOFIX_VALUE }]
|
|
640
|
+
: []),
|
|
641
|
+
{ label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
|
|
619
642
|
]
|
|
620
643
|
});
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
644
|
+
const choice = classifyFinalGateAnswer(answer);
|
|
645
|
+
if (choice.action === 'accept') {
|
|
646
|
+
await recGate('final-gate: FAIL accepted by user');
|
|
647
|
+
active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`, 'warning');
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
if (choice.action === 'autofix' && canAutofix) {
|
|
651
|
+
fixAttempts += 1;
|
|
652
|
+
await recGate(`final-gate: user chose AUTOFIX (attempt ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX})`);
|
|
653
|
+
active.ui.notify(`${id}: final-gate autofix (${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}) — bounded fix pass, then the gate re-runs…`, 'info');
|
|
654
|
+
const seed = choice.guidance ?
|
|
655
|
+
`${fin.reason}\n\nUser guidance: ${choice.guidance}`
|
|
656
|
+
: fin.reason;
|
|
657
|
+
const fix = await deps.finalGateFix(active, cwd, seed);
|
|
658
|
+
if (fix.ok) {
|
|
659
|
+
await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
|
|
660
|
+
await recGate(`final-gate: autofix converged — ${fix.reason.slice(0, 200)}`);
|
|
661
|
+
active.ui.notify(`${id}: final integration gate PASSES after autofix — ${fix.reason.slice(0, 140)}`, 'info');
|
|
662
|
+
fin = { ok: true, reason: fix.reason };
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
|
|
666
|
+
active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
|
|
667
|
+
// Work from the FRESH gate failure when the fix pass got
|
|
668
|
+
// as far as re-running the gate; otherwise keep the last.
|
|
669
|
+
fin = { ok: false, reason: fix.gateReason ?? fin.reason };
|
|
670
|
+
continue;
|
|
625
671
|
}
|
|
626
|
-
|
|
672
|
+
// Leave failed — the dismissal default, unchanged from the
|
|
673
|
+
// two-option picker (an unavailable autofix demotes here too).
|
|
674
|
+
await recGate('final-gate: left failed (user)');
|
|
675
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
676
|
+
announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
|
|
677
|
+
return;
|
|
627
678
|
}
|
|
628
679
|
}
|
|
629
680
|
await updateTaskFrontMatter(cwd, id, { state: 'completed' });
|
|
@@ -33,7 +33,7 @@ export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 rest
|
|
|
33
33
|
* provider 5xx that names a real fault) still fails fast: re-spawning against
|
|
34
34
|
* the same request won't fix it, so burning the budget only delays the report.
|
|
35
35
|
*/
|
|
36
|
-
const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated)\b/i;
|
|
36
|
+
const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated|unreachable)\b/i;
|
|
37
37
|
export function isConnectionError(cause) {
|
|
38
38
|
return CONNECTION_ERROR_RE.test(cause);
|
|
39
39
|
}
|
|
@@ -191,6 +191,12 @@ export function parseEnforceVerdict(text) {
|
|
|
191
191
|
* effects don't get re-classified as a user cancel or a crash.
|
|
192
192
|
*/
|
|
193
193
|
export function classifyEnforceChildFailure(r) {
|
|
194
|
+
// Stall-kill must be matched BEFORE `aborted`: the kill sets aborted too,
|
|
195
|
+
// and mislabeling a dead model backend as a user cancel hides the cause
|
|
196
|
+
// (mx5 run 7: 64 minutes of silence).
|
|
197
|
+
if (r.stalled) {
|
|
198
|
+
return 'model server unreachable — the child produced no output and the model endpoint did not respond';
|
|
199
|
+
}
|
|
194
200
|
if (r.timedOut)
|
|
195
201
|
return 'enforcement child timed out';
|
|
196
202
|
if (r.loopHit)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function envNotesFile(cwd: string): string;
|
|
2
|
+
/** The cached notes, one fact per line ('' when none were recorded yet). */
|
|
3
|
+
export declare function readEnvNotes(cwd: string): Promise<string>;
|
|
4
|
+
/**
|
|
5
|
+
* Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
|
|
6
|
+
* length-capped; verdict markers can never match (different prefix).
|
|
7
|
+
*/
|
|
8
|
+
export declare function extractEnvNotes(text: string): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Append newly discovered facts to the cache, deduplicated against what is
|
|
11
|
+
* already there (case-insensitive full-line match), keeping the newest
|
|
12
|
+
* MAX_NOTES. Failures are swallowed — the cache is a sharpener, never a
|
|
13
|
+
* blocker.
|
|
14
|
+
*/
|
|
15
|
+
export declare function appendEnvNotes(cwd: string, notes: string[]): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* The prompt block a gate child receives when notes exist. The caveat is
|
|
18
|
+
* load-bearing: facts save re-discovery time but grant no waiver from the
|
|
19
|
+
* verify-as-shipped rule.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildEnvNotesBlock(notes: string): string;
|
|
22
|
+
/** The emit instruction appended to bash-capable gate-child prompts. */
|
|
23
|
+
export declare const ENV_NOTE_EMIT_INSTRUCTION: string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-notes — a per-run cache of ENVIRONMENT FACTS shared across gate children.
|
|
3
|
+
*
|
|
4
|
+
* The failure this serves (mx5 run 7, F8): every gate child re-discovers the
|
|
5
|
+
* same environment facts from scratch — where the DB credentials live, which
|
|
6
|
+
* services are reachable, which tools are installed — burning minutes of
|
|
7
|
+
* archaeology per child through the serial model bottleneck.
|
|
8
|
+
*
|
|
9
|
+
* Mechanism: children EMIT facts as `ENV-NOTE: <fact>` lines in their answer
|
|
10
|
+
* text; the HOST parses and appends them to `.pi-tasks/env-notes.md` (children
|
|
11
|
+
* never write the file — no artifact corruption, host-side dedupe). The file
|
|
12
|
+
* lives under `.pi-tasks/`, so it survives discardEdits and the git-state
|
|
13
|
+
* guard, both of which exclude that directory by design.
|
|
14
|
+
*
|
|
15
|
+
* SCOPE — facts only, never verdicts, never spec content: an endpoint, a
|
|
16
|
+
* credential LOCATION, a tool's presence/version, a service's reachability.
|
|
17
|
+
* And the cache must not become a pre-prepared runway that masks missing
|
|
18
|
+
* project setup: the verify-as-shipped rule ("any prep you needed IS the
|
|
19
|
+
* defect") still governs every verdict — the block injected into prompts says
|
|
20
|
+
* so explicitly. The cache only kills re-discovery time.
|
|
21
|
+
*/
|
|
22
|
+
import * as fsp from 'node:fs/promises';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { tasksDir } from './task-io.js';
|
|
25
|
+
const ENV_NOTES_FILE = 'env-notes.md';
|
|
26
|
+
/** Cap kept notes so a chatty run cannot grow the prompt block unboundedly. */
|
|
27
|
+
const MAX_NOTES = 40;
|
|
28
|
+
/** A single fact is one line; anything longer is prose, not a fact. */
|
|
29
|
+
const MAX_NOTE_LENGTH = 240;
|
|
30
|
+
export function envNotesFile(cwd) {
|
|
31
|
+
return path.join(tasksDir(cwd), ENV_NOTES_FILE);
|
|
32
|
+
}
|
|
33
|
+
/** The cached notes, one fact per line ('' when none were recorded yet). */
|
|
34
|
+
export async function readEnvNotes(cwd) {
|
|
35
|
+
try {
|
|
36
|
+
return (await fsp.readFile(envNotesFile(cwd), 'utf8')).trim();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
|
|
44
|
+
* length-capped; verdict markers can never match (different prefix).
|
|
45
|
+
*/
|
|
46
|
+
export function extractEnvNotes(text) {
|
|
47
|
+
const notes = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const m of text.matchAll(/^[ \t]*ENV-NOTE:[ \t]*(.+)$/gm)) {
|
|
50
|
+
const note = m[1].trim();
|
|
51
|
+
if (note.length === 0 || note.length > MAX_NOTE_LENGTH)
|
|
52
|
+
continue;
|
|
53
|
+
const key = note.toLowerCase();
|
|
54
|
+
if (seen.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
notes.push(note);
|
|
58
|
+
}
|
|
59
|
+
return notes;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Append newly discovered facts to the cache, deduplicated against what is
|
|
63
|
+
* already there (case-insensitive full-line match), keeping the newest
|
|
64
|
+
* MAX_NOTES. Failures are swallowed — the cache is a sharpener, never a
|
|
65
|
+
* blocker.
|
|
66
|
+
*/
|
|
67
|
+
export async function appendEnvNotes(cwd, notes) {
|
|
68
|
+
if (notes.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
const existing = (await readEnvNotes(cwd)).split('\n').filter(l => l.trim().length > 0);
|
|
72
|
+
const seen = new Set(existing.map(l => l.trim().toLowerCase()));
|
|
73
|
+
const merged = [...existing];
|
|
74
|
+
for (const note of notes) {
|
|
75
|
+
const key = note.trim().toLowerCase();
|
|
76
|
+
if (seen.has(key))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
merged.push(note.trim());
|
|
80
|
+
}
|
|
81
|
+
const kept = merged.slice(-MAX_NOTES);
|
|
82
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
83
|
+
await fsp.writeFile(envNotesFile(cwd), kept.join('\n') + '\n', 'utf8');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// best-effort cache
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The prompt block a gate child receives when notes exist. The caveat is
|
|
91
|
+
* load-bearing: facts save re-discovery time but grant no waiver from the
|
|
92
|
+
* verify-as-shipped rule.
|
|
93
|
+
*/
|
|
94
|
+
export function buildEnvNotesBlock(notes) {
|
|
95
|
+
if (notes.trim().length === 0)
|
|
96
|
+
return '';
|
|
97
|
+
return [
|
|
98
|
+
'KNOWN ENVIRONMENT FACTS — discovered by earlier verification passes in this run',
|
|
99
|
+
'(informational, may be stale):',
|
|
100
|
+
...notes
|
|
101
|
+
.trim()
|
|
102
|
+
.split('\n')
|
|
103
|
+
.map(n => `- ${n}`),
|
|
104
|
+
'These facts only save you re-discovery time (where credentials/config live, which',
|
|
105
|
+
'tools are installed, which services are reachable). They are NOT a license to',
|
|
106
|
+
'prepare or repair the run: the verify-as-shipped rules below still govern the',
|
|
107
|
+
'verdict — if the project needs something its own committed files do not provide,',
|
|
108
|
+
'that remains the defect no matter what is listed here.',
|
|
109
|
+
''
|
|
110
|
+
].join('\n');
|
|
111
|
+
}
|
|
112
|
+
/** The emit instruction appended to bash-capable gate-child prompts. */
|
|
113
|
+
export const ENV_NOTE_EMIT_INSTRUCTION = [
|
|
114
|
+
'ENVIRONMENT FACTS — share what you discover: when you establish a durable fact about',
|
|
115
|
+
'THIS MACHINE or the project environment (a service reachable/absent at an address, where',
|
|
116
|
+
'credentials/config live, a tool or runtime present/missing and its version), emit a line',
|
|
117
|
+
' ENV-NOTE: <one-line fact>',
|
|
118
|
+
'anywhere in your answer, one per fact. Facts about the ENVIRONMENT only — never task',
|
|
119
|
+
'verdicts, never spec content, never code judgments. These are cached for later',
|
|
120
|
+
'verification passes in this run so they do not re-discover the same things.'
|
|
121
|
+
].join('\n');
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
|
|
2
|
+
* failing command (and the project's own tooling), not to mutate git state. */
|
|
3
|
+
export declare const FINAL_FIX_TOOLS = "read,edit,bash";
|
|
4
|
+
/**
|
|
5
|
+
* How many fix passes the user may launch from the picker before the option is
|
|
6
|
+
* withdrawn (mirrors the per-task MAX_AUTO_AUTOFIX budget). Each attempt is a
|
|
7
|
+
* full model child plus a full gate re-run — after this many that still FAIL,
|
|
8
|
+
* only Leave-failed / Accept remain so a person breaks the loop.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_FINAL_GATE_AUTOFIX = 3;
|
|
11
|
+
/** Picker labels/values. Classification accepts the value token, the label, or
|
|
12
|
+
* free text (→ autofix guidance), same contract as the per-task resolution. */
|
|
13
|
+
export declare const FINAL_LEAVE_VALUE = "fail";
|
|
14
|
+
export declare const FINAL_ACCEPT_VALUE = "accept";
|
|
15
|
+
export declare const FINAL_AUTOFIX_VALUE = "autofix";
|
|
16
|
+
export declare const FINAL_LEAVE_LABEL = "Leave failed \u2014 I will fix and /task-auto-resume";
|
|
17
|
+
export declare const FINAL_ACCEPT_LABEL = "Accept \u2014 complete the run anyway";
|
|
18
|
+
export declare const FINAL_AUTOFIX_LABEL = "Autofix \u2014 run a bounded fix pass and re-run the gate";
|
|
19
|
+
export interface FinalGateChoice {
|
|
20
|
+
/** What the user decided. 'leave' = leave the run failed (also the dismissal
|
|
21
|
+
* default — identical to the pre-autofix behavior). */
|
|
22
|
+
action: 'leave' | 'accept' | 'autofix';
|
|
23
|
+
/** Free-text guidance typed instead of picking a card; folded into the fix
|
|
24
|
+
* child's failure seed. Only set with 'autofix'. */
|
|
25
|
+
guidance?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Map a final-gate picker answer to an action. Dismissal (undefined/empty) and
|
|
29
|
+
* an explicit leave stay "leave" — exactly what the two-option picker did.
|
|
30
|
+
* Free text becomes autofix guidance, mirroring classifyResolutionAnswer; the
|
|
31
|
+
* caller demotes autofix back to "leave" when the option was not offered.
|
|
32
|
+
*/
|
|
33
|
+
export declare function classifyFinalGateAnswer(answer: string | undefined): FinalGateChoice;
|
|
34
|
+
/**
|
|
35
|
+
* The gate's fail reason always carries the failing command in backticks
|
|
36
|
+
* (`` `bun run test` exited 1 — … `` / ``static checks: `make lint` exited 2``).
|
|
37
|
+
* Extract it for reporting; the shrink guard itself compares the FULL
|
|
38
|
+
* discovered-command sets, so a reason this cannot parse still guards.
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractFailingCommand(reason: string): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Build the fix child's prompt. Generic by construction: the only project facts
|
|
43
|
+
* in it are the gate's own failure text — the command comes from the project's
|
|
44
|
+
* discovered manifest, never from a hardcoded ecosystem.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildFinalFixPrompt(failReason: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Parse the child's final marker. Last match wins (the model reasons before
|
|
49
|
+
* concluding and bash output can echo the words). No marker → treated as DONE:
|
|
50
|
+
* the gate re-run is the arbiter either way, so a missing marker only skips the
|
|
51
|
+
* early-out on a self-declared BLOCKED.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseFinalFixMarker(text: string): {
|
|
54
|
+
blocked: boolean;
|
|
55
|
+
note?: string;
|
|
56
|
+
};
|
|
57
|
+
export interface FinalFixResult {
|
|
58
|
+
/** true → the fix child ran AND the re-run gate passed. */
|
|
59
|
+
ok: boolean;
|
|
60
|
+
/** Human-readable outcome (converged gate reason, or why the attempt failed). */
|
|
61
|
+
reason: string;
|
|
62
|
+
/** On a did-not-converge outcome: the FRESH gate failure, so the caller's next
|
|
63
|
+
* picker (and next fix attempt) works from the current state, not the stale one. */
|
|
64
|
+
gateReason?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface FinalFixDeps {
|
|
67
|
+
cwd: string;
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
/** The gate's FAIL reason (command + exit code + output tail), plus any
|
|
70
|
+
* user-typed guidance the caller folded in. */
|
|
71
|
+
failReason: string;
|
|
72
|
+
/** Run the fix child; same closure shape the other gate children use. */
|
|
73
|
+
runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
|
|
74
|
+
/** Re-run the final integration gate — the only arbiter of convergence. */
|
|
75
|
+
gate: (cwd: string) => Promise<{
|
|
76
|
+
ok: boolean;
|
|
77
|
+
reason: string;
|
|
78
|
+
}>;
|
|
79
|
+
/** Labels of every currently-discoverable gate command (static + integration),
|
|
80
|
+
* for the shrink guard. Pure discovery — nothing is executed. */
|
|
81
|
+
discoverLabels: (cwd: string) => string[];
|
|
82
|
+
/** Discard the fix child's working-tree edits (shrink-guard trip only). Absent
|
|
83
|
+
* → the violation is still rejected, edits are left for inspection. */
|
|
84
|
+
discard?: (cwd: string) => Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Run one bounded final-gate fix attempt: snapshot discovery → child → shrink
|
|
88
|
+
* guard → gate re-run. Never throws for an outcome; only a user cancel inside
|
|
89
|
+
* runChild propagates (the caller's USER_CANCELLED path handles it).
|
|
90
|
+
*/
|
|
91
|
+
export declare function runFinalGateAutofix(deps: FinalFixDeps): Promise<FinalFixResult>;
|