@mjasnikovs/pi-task 0.40.41 → 0.40.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/shared/child-process.d.ts +32 -1
- package/dist/shared/child-process.js +38 -15
- package/dist/task/boot-probe.d.ts +9 -5
- package/dist/task/boot-probe.js +9 -23
- package/dist/task/deep-render-check.js +4 -8
- package/dist/task/enforce-guidelines.d.ts +12 -9
- package/dist/task/enforce-guidelines.js +15 -13
- package/dist/task/final-gate-fix.js +5 -2
- package/dist/task/gate-child.js +4 -3
- package/dist/task/lint-fix.js +7 -8
- package/dist/task/verify-work.js +4 -0
- package/dist/workers/pi-worker-core.d.ts +3 -2
- package/dist/workers/pi-worker.js +14 -11
- package/dist/workers/worker-failure.d.ts +37 -8
- package/dist/workers/worker-failure.js +29 -0
- package/package.json +1 -1
|
@@ -37,9 +37,37 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
|
37
37
|
* possible at all: with `detached`, a `kill(-pid)` issued after the child
|
|
38
38
|
* has exited still takes the backgrounded grandchild with it; without it
|
|
39
39
|
* the same call throws ESRCH and the grandchild survives. Set only for
|
|
40
|
-
* model children (json-events); plumbing stays in-group.
|
|
40
|
+
* model children (json-events); plumbing stays in-group. Never set on
|
|
41
|
+
* win32 — see ownGroupSpawnOptions. */
|
|
41
42
|
detached?: boolean;
|
|
43
|
+
/** win32 only: keep the child's console off-screen. */
|
|
44
|
+
windowsHide?: boolean;
|
|
42
45
|
}) => ProcLike;
|
|
46
|
+
/**
|
|
47
|
+
* Spawn options that make a model child reapable with everything it backgrounds.
|
|
48
|
+
*
|
|
49
|
+
* POSIX (linux, darwin): `detached` = own process group, so `kill(-pid)` sweeps
|
|
50
|
+
* the grandchildren. On win32 the same flag is a defect: libuv maps it to
|
|
51
|
+
* DETACHED_PROCESS, which gives the child NO console, so every console process
|
|
52
|
+
* the model then runs allocates a fresh visible one (issue #20). win32 gets
|
|
53
|
+
* `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
|
|
54
|
+
* that its descendants inherit. Either/or is load-bearing — Windows ignores
|
|
55
|
+
* CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
|
|
56
|
+
* which walks the live tree and needs no flag; unlike a POSIX group kill it
|
|
57
|
+
* cannot catch what the child left behind after it exited.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ownGroupSpawnOptions(platform: NodeJS.Platform): OwnGroupSpawnOptions;
|
|
60
|
+
export type OwnGroupSpawnOptions = {
|
|
61
|
+
detached: true;
|
|
62
|
+
} | {
|
|
63
|
+
windowsHide: true;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
|
|
67
|
+
* backgrounded. Best-effort: a group already gone is not an error. Kept beside
|
|
68
|
+
* the spawn shape so a platform change edits one file.
|
|
69
|
+
*/
|
|
70
|
+
export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, platform?: NodeJS.Platform): void;
|
|
43
71
|
/**
|
|
44
72
|
* Why runChild killed the child. Five sources converge on one kill path, and
|
|
45
73
|
* each names itself here rather than in its own flag — so a consumer reads ONE
|
|
@@ -124,6 +152,9 @@ export interface RunChildTextOptions {
|
|
|
124
152
|
}
|
|
125
153
|
export interface RunChildJsonEventsOptions {
|
|
126
154
|
mode: 'json-events';
|
|
155
|
+
/** Which platform's group options and reap to use. Tests drive the win32 arm
|
|
156
|
+
* from a POSIX host with it; production leaves it to `process.platform`. */
|
|
157
|
+
platform?: NodeJS.Platform;
|
|
127
158
|
onLine?: (line: string) => void;
|
|
128
159
|
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
129
160
|
/**
|
|
@@ -13,6 +13,40 @@ export const CHILD_BASE_ARGS = [
|
|
|
13
13
|
'--no-context-files',
|
|
14
14
|
'--no-session'
|
|
15
15
|
];
|
|
16
|
+
/**
|
|
17
|
+
* Spawn options that make a model child reapable with everything it backgrounds.
|
|
18
|
+
*
|
|
19
|
+
* POSIX (linux, darwin): `detached` = own process group, so `kill(-pid)` sweeps
|
|
20
|
+
* the grandchildren. On win32 the same flag is a defect: libuv maps it to
|
|
21
|
+
* DETACHED_PROCESS, which gives the child NO console, so every console process
|
|
22
|
+
* the model then runs allocates a fresh visible one (issue #20). win32 gets
|
|
23
|
+
* `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
|
|
24
|
+
* that its descendants inherit. Either/or is load-bearing — Windows ignores
|
|
25
|
+
* CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
|
|
26
|
+
* which walks the live tree and needs no flag; unlike a POSIX group kill it
|
|
27
|
+
* cannot catch what the child left behind after it exited.
|
|
28
|
+
*/
|
|
29
|
+
export function ownGroupSpawnOptions(platform) {
|
|
30
|
+
return platform === 'win32' ? { windowsHide: true } : { detached: true };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
|
|
34
|
+
* backgrounded. Best-effort: a group already gone is not an error. Kept beside
|
|
35
|
+
* the spawn shape so a platform change edits one file.
|
|
36
|
+
*/
|
|
37
|
+
export function reapProcessGroup(pid, sig, platform = process.platform) {
|
|
38
|
+
try {
|
|
39
|
+
if (platform === 'win32') {
|
|
40
|
+
spawnSyncDefault('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
process.kill(-pid, sig);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// group already gone
|
|
48
|
+
}
|
|
49
|
+
}
|
|
16
50
|
/** The cause a signal was aborted with, when its owner attached one. */
|
|
17
51
|
function abortCause(reason) {
|
|
18
52
|
const tagged = reason;
|
|
@@ -229,11 +263,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
229
263
|
// OWN process group so every such grandchild can be reaped as a unit on exit.
|
|
230
264
|
// Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
|
|
231
265
|
const ownGroup = opts?.mode === 'json-events';
|
|
266
|
+
const platform = (ownGroup && opts.platform) || process.platform;
|
|
232
267
|
const proc = spawn(invocation.command, invocation.args, {
|
|
233
268
|
cwd,
|
|
234
269
|
shell: false,
|
|
235
270
|
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
236
|
-
...(ownGroup ?
|
|
271
|
+
...(ownGroup ? ownGroupSpawnOptions(platform) : {}),
|
|
237
272
|
...(invocation.env ? { env: invocation.env } : {})
|
|
238
273
|
});
|
|
239
274
|
if (usesStdin) {
|
|
@@ -250,23 +285,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
250
285
|
proc.stdin?.end();
|
|
251
286
|
}
|
|
252
287
|
// Reap the child's whole process group — the child itself AND anything it
|
|
253
|
-
// backgrounded. No-op unless the child owns a group (ownGroup) and we have a
|
|
254
|
-
// pid; ESRCH (group already gone) is swallowed. POSIX: negative-pid signals
|
|
255
|
-
// the group; Windows has no groups, so taskkill /T tears down the tree.
|
|
288
|
+
// backgrounded. No-op unless the child owns a group (ownGroup) and we have a pid.
|
|
256
289
|
const reapGroup = (sig) => {
|
|
257
290
|
if (!ownGroup || !proc.pid)
|
|
258
291
|
return;
|
|
259
|
-
|
|
260
|
-
if (process.platform === 'win32') {
|
|
261
|
-
spawnSyncDefault('taskkill', ['/pid', String(proc.pid), '/T', '/F']);
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
process.kill(-proc.pid, sig);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
// group already gone
|
|
269
|
-
}
|
|
292
|
+
reapProcessGroup(proc.pid, sig, platform);
|
|
270
293
|
};
|
|
271
294
|
// One kill path for every source: SIGTERM, then SIGKILL after a grace
|
|
272
295
|
// period if the child ignored the term. For a group-owning (model) child,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RenderOutcome } from './render-check.js';
|
|
2
|
+
import type { OwnGroupSpawnOptions } from '../shared/child-process.js';
|
|
2
3
|
import { type DeepRenderOutcome } from './deep-render-check.js';
|
|
3
4
|
import type { HealthCommand } from './repo-health-check.js';
|
|
4
5
|
/**
|
|
@@ -163,14 +164,16 @@ export interface BootDeps {
|
|
|
163
164
|
* fake pid would signal something else entirely.
|
|
164
165
|
*/
|
|
165
166
|
killGroup?: (pid: number, signal: NodeJS.Signals) => void;
|
|
167
|
+
/** Which platform's group options to spawn with. Tests drive the win32 arm
|
|
168
|
+
* from a POSIX host; production leaves it to `process.platform`. */
|
|
169
|
+
platform?: NodeJS.Platform;
|
|
166
170
|
}
|
|
167
171
|
/** What `runBootCheck` passes to its spawn. */
|
|
168
|
-
export
|
|
172
|
+
export type BootSpawnOptions = {
|
|
169
173
|
cwd: string;
|
|
170
|
-
detached: true;
|
|
171
174
|
stdio: ['ignore', 'pipe', 'pipe'];
|
|
172
175
|
env: Record<string, string | undefined>;
|
|
173
|
-
}
|
|
176
|
+
} & OwnGroupSpawnOptions;
|
|
174
177
|
/** A stream the boot check reads output from. */
|
|
175
178
|
export interface BootStream {
|
|
176
179
|
on: (event: 'data', cb: (chunk: Buffer | string) => void) => void;
|
|
@@ -255,8 +258,9 @@ export declare function defaultFindPortHolder(port: number): {
|
|
|
255
258
|
*
|
|
256
259
|
* - non-zero exit (or signal death) before the window closes → FAIL, output tail;
|
|
257
260
|
* - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
|
|
258
|
-
* - still alive when the window closes → PASS, then the
|
|
259
|
-
* killed (
|
|
261
|
+
* - still alive when the window closes → PASS, then the child and everything it
|
|
262
|
+
* backgrounded are killed (ownGroupSpawnOptions + reapProcessGroup; SIGTERM,
|
|
263
|
+
* escalating to SIGKILL).
|
|
260
264
|
*
|
|
261
265
|
* For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
|
|
262
266
|
* survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
|
package/dist/task/boot-probe.js
CHANGED
|
@@ -25,6 +25,7 @@ import * as path from 'node:path';
|
|
|
25
25
|
import { runRenderCheck } from './render-check.js';
|
|
26
26
|
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
27
27
|
import { outputTail } from './command-run.js';
|
|
28
|
+
import { ownGroupSpawnOptions, reapProcessGroup } from '../shared/child-process.js';
|
|
28
29
|
import { packageScripts, makeHasTarget } from './launch-manifest.js';
|
|
29
30
|
import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
|
|
30
31
|
/** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
|
|
@@ -435,7 +436,8 @@ function pgidOf(pid) {
|
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
438
|
/** Default listener probe: any LISTENing socket owned by a pid in process group
|
|
438
|
-
* `pgid
|
|
439
|
+
* `pgid`. POSIX only (expectServer is fenced off win32): there the detached boot
|
|
440
|
+
* child leads its own group, so pgid === child.pid. */
|
|
439
441
|
function defaultGroupHasListener(pgid) {
|
|
440
442
|
for (const { pid } of listeningSockets()) {
|
|
441
443
|
if (pgidOf(pid) === pgid)
|
|
@@ -503,26 +505,9 @@ function holderIsOurs(command, boot) {
|
|
|
503
505
|
function defaultSpawnBoot(bin, args, o) {
|
|
504
506
|
return spawn(bin, args, o);
|
|
505
507
|
}
|
|
506
|
-
/**
|
|
507
|
-
* The real group teardown, best-effort. A group already gone is not an error.
|
|
508
|
-
*
|
|
509
|
-
* On POSIX the negative pid signals the whole group, which is what makes a
|
|
510
|
-
* `detached` spawn reapable together with anything it backgrounded. Windows has
|
|
511
|
-
* neither process groups nor a negative-pid kill, so that branch shells out to
|
|
512
|
-
* `taskkill /T /F` as a single forced tree teardown instead of escalating.
|
|
513
|
-
*/
|
|
508
|
+
/** The real group teardown; the spawn shape's twin lives beside it. */
|
|
514
509
|
function defaultKillGroup(pid, sig) {
|
|
515
|
-
|
|
516
|
-
if (process.platform === 'win32') {
|
|
517
|
-
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
518
|
-
}
|
|
519
|
-
else {
|
|
520
|
-
process.kill(-pid, sig);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
catch {
|
|
524
|
-
// group already gone
|
|
525
|
-
}
|
|
510
|
+
reapProcessGroup(pid, sig);
|
|
526
511
|
}
|
|
527
512
|
/**
|
|
528
513
|
* Exercise the start command ONCE. All four outcomes below were run against real
|
|
@@ -533,8 +518,9 @@ function defaultKillGroup(pid, sig) {
|
|
|
533
518
|
*
|
|
534
519
|
* - non-zero exit (or signal death) before the window closes → FAIL, output tail;
|
|
535
520
|
* - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
|
|
536
|
-
* - still alive when the window closes → PASS, then the
|
|
537
|
-
* killed (
|
|
521
|
+
* - still alive when the window closes → PASS, then the child and everything it
|
|
522
|
+
* backgrounded are killed (ownGroupSpawnOptions + reapProcessGroup; SIGTERM,
|
|
523
|
+
* escalating to SIGKILL).
|
|
538
524
|
*
|
|
539
525
|
* For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
|
|
540
526
|
* survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
|
|
@@ -593,7 +579,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
593
579
|
return new Promise(resolve => {
|
|
594
580
|
const child = spawnBoot(runner.bin, args, {
|
|
595
581
|
cwd,
|
|
596
|
-
|
|
582
|
+
...ownGroupSpawnOptions(opts.deps?.platform ?? process.platform),
|
|
597
583
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
598
584
|
env: {
|
|
599
585
|
...runnerEnv(runner),
|
|
@@ -44,6 +44,7 @@ import * as os from 'node:os';
|
|
|
44
44
|
import * as path from 'node:path';
|
|
45
45
|
import WebSocket from 'ws';
|
|
46
46
|
import { findHeadlessBrowser, judgeRenderedDom } from './render-check.js';
|
|
47
|
+
import { ownGroupSpawnOptions, reapProcessGroup } from '../shared/child-process.js';
|
|
47
48
|
/** Identifier halves of a credential pair, in preference order. */
|
|
48
49
|
const IDENTIFIER_SUFFIXES = ['PHONE', 'EMAIL', 'USERNAME', 'USER', 'LOGIN', 'IDENTIFIER'];
|
|
49
50
|
const PASSWORD_SUFFIXES = ['PASSWORD', 'PASSWD', 'PASS'];
|
|
@@ -622,13 +623,8 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
|
|
|
622
623
|
catch {
|
|
623
624
|
// socket already gone
|
|
624
625
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
process.kill(-child.pid, 'SIGKILL');
|
|
628
|
-
}
|
|
629
|
-
catch {
|
|
630
|
-
// group already gone
|
|
631
|
-
}
|
|
626
|
+
if (child?.pid)
|
|
627
|
+
reapProcessGroup(child.pid, 'SIGKILL');
|
|
632
628
|
return Promise.resolve();
|
|
633
629
|
};
|
|
634
630
|
signal?.addEventListener('abort', () => void close(), { once: true });
|
|
@@ -647,7 +643,7 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
|
|
|
647
643
|
`--user-data-dir=${userDataDir}`,
|
|
648
644
|
'--remote-debugging-port=0',
|
|
649
645
|
'about:blank'
|
|
650
|
-
], {
|
|
646
|
+
], { ...ownGroupSpawnOptions(process.platform), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
651
647
|
const proc = child;
|
|
652
648
|
proc.unref();
|
|
653
649
|
const wsUrl = await new Promise((resolve, reject) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SpawnFn } from '../shared/child-process.js';
|
|
2
|
-
import { type WorkerFailureInput } from '../workers/worker-failure.js';
|
|
2
|
+
import { type WorkerAnswerInput, type WorkerFailureInput } from '../workers/worker-failure.js';
|
|
3
3
|
/** Filenames discovered in the working directory (cwd only — no tree walk). */
|
|
4
4
|
export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
|
|
5
5
|
/**
|
|
@@ -94,9 +94,7 @@ export declare function parseEnforceVerdict(text: string): {
|
|
|
94
94
|
detail: string;
|
|
95
95
|
};
|
|
96
96
|
/** The subset of a runWorker result the enforcement-child mapping reads. */
|
|
97
|
-
export interface EnforceChildResult extends WorkerFailureInput {
|
|
98
|
-
text: string;
|
|
99
|
-
modelError?: string;
|
|
97
|
+
export interface EnforceChildResult extends WorkerFailureInput, WorkerAnswerInput {
|
|
100
98
|
}
|
|
101
99
|
/**
|
|
102
100
|
* Map the enforcement child's runWorker result to a fatal error message, or null
|
|
@@ -115,11 +113,16 @@ export interface EnforceChildResult extends WorkerFailureInput {
|
|
|
115
113
|
* user as their own cancel. The switch below is exhaustive, so the next cause
|
|
116
114
|
* added to the union is a compile error here rather than a silent mislabel.
|
|
117
115
|
*
|
|
118
|
-
* A loop is NOT fatal: enforce attaches the detector in nudge-then-warn
|
|
119
|
-
* loop that survived its restart-with-hint nudges
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
116
|
+
* A loop is NOT fatal on its own: enforce attaches the detector in nudge-then-warn
|
|
117
|
+
* mode, so a loop that survived its restart-with-hint nudges is a warning (the
|
|
118
|
+
* caller notifies it) and the answer still decides. It still has to be matched
|
|
119
|
+
* before `aborted`/`exitCode` so the kill's side effects don't get re-classified
|
|
120
|
+
* as a user cancel or a crash.
|
|
121
|
+
*
|
|
122
|
+
* After the kill ladder, the answer itself: a model error or a child that never
|
|
123
|
+
* spoke is fatal — left unread, either parses as "no verdict" and a dead provider
|
|
124
|
+
* is blamed on the work (issue #19). Only a genuinely empty answer reaches the
|
|
125
|
+
* verdict parser.
|
|
123
126
|
*/
|
|
124
127
|
export declare function classifyEnforceChildFailure(r: EnforceChildResult): string | null;
|
|
125
128
|
/**
|
|
@@ -25,7 +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
|
+
import { classifyWorkerAnswer, classifyWorkerFailure, describeNoAnswer } from '../workers/worker-failure.js';
|
|
29
29
|
import { TASKS_DIR_NAME } from './task-types.js';
|
|
30
30
|
import { findProbeGamingInDiff } from './probe-gaming.js';
|
|
31
31
|
/** Filenames discovered in the working directory (cwd only — no tree walk). */
|
|
@@ -229,24 +229,26 @@ export function parseEnforceVerdict(text) {
|
|
|
229
229
|
* user as their own cancel. The switch below is exhaustive, so the next cause
|
|
230
230
|
* added to the union is a compile error here rather than a silent mislabel.
|
|
231
231
|
*
|
|
232
|
-
* A loop is NOT fatal: enforce attaches the detector in nudge-then-warn
|
|
233
|
-
* loop that survived its restart-with-hint nudges
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
232
|
+
* A loop is NOT fatal on its own: enforce attaches the detector in nudge-then-warn
|
|
233
|
+
* mode, so a loop that survived its restart-with-hint nudges is a warning (the
|
|
234
|
+
* caller notifies it) and the answer still decides. It still has to be matched
|
|
235
|
+
* before `aborted`/`exitCode` so the kill's side effects don't get re-classified
|
|
236
|
+
* as a user cancel or a crash.
|
|
237
|
+
*
|
|
238
|
+
* After the kill ladder, the answer itself: a model error or a child that never
|
|
239
|
+
* spoke is fatal — left unread, either parses as "no verdict" and a dead provider
|
|
240
|
+
* is blamed on the work (issue #19). Only a genuinely empty answer reaches the
|
|
241
|
+
* verdict parser.
|
|
237
242
|
*/
|
|
238
243
|
export function classifyEnforceChildFailure(r) {
|
|
239
244
|
const kill = classifyWorkerFailure(r);
|
|
240
245
|
const named = kill ? describeKill(kill) : null;
|
|
241
246
|
if (named !== null)
|
|
242
247
|
return named;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return r.modelError && r.text.trim().length === 0 ?
|
|
248
|
-
`model error — ${r.modelError.slice(0, 200)}`
|
|
249
|
-
: null;
|
|
248
|
+
const noAnswer = classifyWorkerAnswer(r);
|
|
249
|
+
if (!noAnswer || noAnswer.kind === 'empty-answer')
|
|
250
|
+
return null;
|
|
251
|
+
return describeNoAnswer(noAnswer);
|
|
250
252
|
}
|
|
251
253
|
function describeKill(failure) {
|
|
252
254
|
switch (failure.kind) {
|
|
@@ -199,8 +199,8 @@ export async function runFinalGateAutofix(deps) {
|
|
|
199
199
|
signal: deps.signal,
|
|
200
200
|
marker: 'FINAL-GATE-FIX'
|
|
201
201
|
});
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
// A child that threw is NOT an early return from here: the guards below catch
|
|
203
|
+
// what it wrote before it died, and a thrown child has still written it.
|
|
204
204
|
// What the child wrote to gitignored paths. Recorded on the trail IMMEDIATELY —
|
|
205
205
|
// before any guard can reject the attempt — because `discard` reverts tracked
|
|
206
206
|
// edits only: an ignored file the pass wrote survives a rejection, and the trail
|
|
@@ -300,6 +300,9 @@ export async function runFinalGateAutofix(deps) {
|
|
|
300
300
|
return r;
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
|
+
if (end.kind === 'error') {
|
|
304
|
+
return withIgnored({ ok: false, reason: `fix child failed: ${end.msg}` });
|
|
305
|
+
}
|
|
303
306
|
if (end.kind === 'blocked') {
|
|
304
307
|
// Self-declared blocked: skip the (expensive) gate re-run; nothing converged.
|
|
305
308
|
return withIgnored({ ok: false, reason: `fix child blocked: ${end.note}` });
|
package/dist/task/gate-child.js
CHANGED
|
@@ -152,15 +152,16 @@ export function makeGateChild(deps) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
const failure = classifyEnforceChildFailure(r);
|
|
155
156
|
// A loop that survived the restart-with-hint nudges is a WARNING, not a
|
|
156
157
|
// failure: log it and tell the user, but let the verdict gate be the
|
|
157
|
-
// only thing that can block.
|
|
158
|
-
|
|
158
|
+
// only thing that can block. Unless the same child also failed — then
|
|
159
|
+
// "continuing" would contradict the throw below.
|
|
160
|
+
if (r.loopHit && failure === null) {
|
|
159
161
|
log(`=== ${deps.kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
160
162
|
notifyRun(deps.ctx, `${deps.taskTitle}: ${deps.kind} worker looped past the nudges — `
|
|
161
163
|
+ 'continuing (not blocked).', 'warning');
|
|
162
164
|
}
|
|
163
|
-
const failure = classifyEnforceChildFailure(r);
|
|
164
165
|
log(failure ?
|
|
165
166
|
`=== ${deps.kind} end: FAIL — ${failure} ===`
|
|
166
167
|
: `=== ${deps.kind} end: ${row.okMarker} ===`);
|
package/dist/task/lint-fix.js
CHANGED
|
@@ -219,14 +219,9 @@ export async function runBoundedLintFix(deps) {
|
|
|
219
219
|
signal: deps.signal,
|
|
220
220
|
marker: 'LINT-FIX'
|
|
221
221
|
});
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
// A BLOCKED child is NOT an early return from here: the guards below exist to
|
|
227
|
-
// catch a child that discarded work, and a child can discard work and then
|
|
228
|
-
// block. The marker is consulted after them, in place of the re-run — which is
|
|
229
|
-
// where the twin consults its own.
|
|
222
|
+
// Neither a BLOCKED nor a thrown child is an early return from here: the
|
|
223
|
+
// guards below exist to catch a child that discarded work, and a child can
|
|
224
|
+
// discard work and then block, or die. Both are consulted after them.
|
|
230
225
|
// REVERT-GUARD: every pre-existing work file must still differ from HEAD, and
|
|
231
226
|
// every pre-existing untracked file must still exist. Trip → restore snapshot.
|
|
232
227
|
// Every comparison requires git to have actually SUCCEEDED. A git error after
|
|
@@ -325,6 +320,10 @@ export async function runBoundedLintFix(deps) {
|
|
|
325
320
|
}
|
|
326
321
|
}
|
|
327
322
|
}
|
|
323
|
+
if (end.kind === 'error') {
|
|
324
|
+
deps.log?.(`lint-fix child failed — ${end.msg}`);
|
|
325
|
+
return { ok: false, reason: `fix child failed: ${end.msg}` };
|
|
326
|
+
}
|
|
328
327
|
if (end.kind === 'blocked')
|
|
329
328
|
deps.log?.(`lint-fix BLOCKED — ${end.note}`);
|
|
330
329
|
// The CHECK is the arbiter, including after a BLOCKED marker.
|
package/dist/task/verify-work.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* can FAIL before the spec is looked at.
|
|
38
38
|
*/
|
|
39
39
|
import { USER_CANCELLED } from './child-runner.js';
|
|
40
|
+
import { isModelErrorMessage } from '../workers/worker-failure.js';
|
|
40
41
|
import { buildEnvNotesBlock, ENV_NOTE_EMIT_INSTRUCTION, extractEnvNotes } from './env-notes.js';
|
|
41
42
|
import { buildContractsVerifyBlock } from './contracts.js';
|
|
42
43
|
import { findSkipEscapes, skipEscapeVerifyFindings } from './skip-escape.js';
|
|
@@ -823,6 +824,9 @@ export async function runWorkVerification(deps) {
|
|
|
823
824
|
if (err instanceof Error && err.message === USER_CANCELLED)
|
|
824
825
|
throw err;
|
|
825
826
|
const msg = err instanceof Error ? err.message : String(err);
|
|
827
|
+
// A provider that died under the child never judged the work either.
|
|
828
|
+
if (isModelErrorMessage(msg) && attempt === 1)
|
|
829
|
+
continue;
|
|
826
830
|
return {
|
|
827
831
|
ok: false,
|
|
828
832
|
failClass: 'harness-fault',
|
|
@@ -272,8 +272,9 @@ export interface RunWorkerResult {
|
|
|
272
272
|
* surface this through child-runner.ts; without it a swallowed provider error
|
|
273
273
|
* reaches the caller as an indistinguishable empty answer and gets reported as
|
|
274
274
|
* the useless "produced no output".
|
|
275
|
-
*
|
|
276
|
-
* recovered is a success
|
|
275
|
+
* Survives next to text only when the error came AFTER that text: a turn
|
|
276
|
+
* that produced text after pi recovered is a success and the sink drops the
|
|
277
|
+
* earlier error. See `classifyWorkerAnswer` (worker-failure.ts).
|
|
277
278
|
*/
|
|
278
279
|
modelError?: string;
|
|
279
280
|
/**
|
|
@@ -17,6 +17,7 @@ import { getConfig } from '../config/config.js';
|
|
|
17
17
|
import { groupChildArgs } from '../config/group-args.js';
|
|
18
18
|
import { runWorker } from './pi-worker-core.js';
|
|
19
19
|
import { contextWindowForGroup } from '../task/context-usage.js';
|
|
20
|
+
import { classifyWorkerAnswer, describeNoAnswer } from './worker-failure.js';
|
|
20
21
|
import { childFailureReason, formatChildFailure, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
|
|
21
22
|
const RENDER_PROMPT_MAX = 120;
|
|
22
23
|
const STDERR_TAIL = 500;
|
|
@@ -24,7 +25,10 @@ function workerDetails(r) {
|
|
|
24
25
|
return {
|
|
25
26
|
exitCode: r.exitCode,
|
|
26
27
|
attempts: r.attempts,
|
|
27
|
-
restarts: r.restarts.map(x =>
|
|
28
|
+
restarts: r.restarts.map(x => ({
|
|
29
|
+
reason: x.reason,
|
|
30
|
+
...(x.detail !== undefined ? { detail: x.detail } : {})
|
|
31
|
+
})),
|
|
28
32
|
...(r.modelError !== undefined ? { modelError: r.modelError } : {}),
|
|
29
33
|
...(r.stderr ? { stderr: r.stderr.slice(-STDERR_TAIL) } : {})
|
|
30
34
|
};
|
|
@@ -94,16 +98,15 @@ export function registerPiWorker(pi, internals = {}) {
|
|
|
94
98
|
if (failure !== null) {
|
|
95
99
|
return workerUnavailable(failure, details, childFailureReason(result));
|
|
96
100
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const text =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return workerAnswer(result.text, details);
|
|
101
|
+
const noAnswer = classifyWorkerAnswer(result);
|
|
102
|
+
if (noAnswer === undefined)
|
|
103
|
+
return workerAnswer(result.text, details);
|
|
104
|
+
// Only the model sees the text, never `details`, so the diagnosis
|
|
105
|
+
// has to be in the text.
|
|
106
|
+
const text = noAnswer.kind === 'empty-answer' ?
|
|
107
|
+
describeEmptyAnswer(result)
|
|
108
|
+
: `Worker failed: ${describeNoAnswer(noAnswer)}`;
|
|
109
|
+
return workerUnavailable(text, details, noAnswer.kind);
|
|
107
110
|
},
|
|
108
111
|
renderCall(args, theme) {
|
|
109
112
|
const prompt = args.prompt.replace(/\s+/g, ' ').trim();
|
|
@@ -30,9 +30,9 @@ import type { WorkerKillId } from './worker-kill.js';
|
|
|
30
30
|
* The subset of a finished child result this classification reads.
|
|
31
31
|
*
|
|
32
32
|
* Structural on purpose: `runWorker` returns a superset, and `EnforceChildResult`
|
|
33
|
-
* extends this interface with the
|
|
34
|
-
* what is actually READ lets both pass without either importing the
|
|
35
|
-
* interface.
|
|
33
|
+
* extends this interface with the answer fields enforcement adds. Typing the
|
|
34
|
+
* input as what is actually READ lets both pass without either importing the
|
|
35
|
+
* other's interface.
|
|
36
36
|
*/
|
|
37
37
|
export interface WorkerFailureInput {
|
|
38
38
|
exitCode: number;
|
|
@@ -53,11 +53,11 @@ export interface WorkerFailureInput {
|
|
|
53
53
|
/**
|
|
54
54
|
* Why the child died, or `undefined` when it finished under its own power.
|
|
55
55
|
*
|
|
56
|
-
* Note what is NOT here:
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* Note what is NOT here: a reported `modelError`, a child that never spoke, and
|
|
57
|
+
* an empty answer. None is a kill — whether they count as a failure is the
|
|
58
|
+
* consumer's policy — research-worker.ts, for one, accepts an explicit empty
|
|
59
|
+
* section after a retry. `classifyWorkerAnswer` below names them; each consumer
|
|
60
|
+
* decides what they mean.
|
|
61
61
|
*/
|
|
62
62
|
export type WorkerFailure = {
|
|
63
63
|
kind: 'stalled';
|
|
@@ -143,3 +143,32 @@ stderr?: string): string;
|
|
|
143
143
|
* and that judgement belongs to the caller.
|
|
144
144
|
*/
|
|
145
145
|
export declare function classifyWorkerFailure(r: WorkerFailureInput): WorkerFailure | undefined;
|
|
146
|
+
/** The answer-side fields `classifyWorkerAnswer` reads off a finished child. */
|
|
147
|
+
export interface WorkerAnswerInput {
|
|
148
|
+
text: string;
|
|
149
|
+
modelError?: string;
|
|
150
|
+
sawOutput?: boolean;
|
|
151
|
+
stderr?: string;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Why a child that was NOT killed still has no answer, or `undefined` for an answer.
|
|
155
|
+
*
|
|
156
|
+
* `model-error` wins even next to text. The event sink already drops an error
|
|
157
|
+
* that a LATER turn answered past, so a `modelError` that survives is one that
|
|
158
|
+
* came AFTER the last text: the provider died mid-run and the text is partial.
|
|
159
|
+
* Two consumers once required empty text here and each shipped a truncated
|
|
160
|
+
* verdict as a whole one.
|
|
161
|
+
*/
|
|
162
|
+
export type WorkerNoAnswer = {
|
|
163
|
+
kind: 'model-error';
|
|
164
|
+
cause: string;
|
|
165
|
+
} | {
|
|
166
|
+
kind: 'dead-child';
|
|
167
|
+
stderr: string;
|
|
168
|
+
} | {
|
|
169
|
+
kind: 'empty-answer';
|
|
170
|
+
};
|
|
171
|
+
export declare function classifyWorkerAnswer(r: WorkerAnswerInput): WorkerNoAnswer | undefined;
|
|
172
|
+
/** The one wording of a model error, so a caller can recognise it back (`isModelErrorMessage`). */
|
|
173
|
+
export declare function describeNoAnswer(f: WorkerNoAnswer): string;
|
|
174
|
+
export declare function isModelErrorMessage(msg: string): boolean;
|
|
@@ -138,3 +138,32 @@ export function classifyWorkerFailure(r) {
|
|
|
138
138
|
}
|
|
139
139
|
return undefined;
|
|
140
140
|
}
|
|
141
|
+
export function classifyWorkerAnswer(r) {
|
|
142
|
+
if (r.modelError !== undefined && r.modelError.length > 0) {
|
|
143
|
+
return { kind: 'model-error', cause: r.modelError };
|
|
144
|
+
}
|
|
145
|
+
if (r.text.trim().length > 0)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (r.sawOutput === false)
|
|
148
|
+
return { kind: 'dead-child', stderr: r.stderr ?? '' };
|
|
149
|
+
return { kind: 'empty-answer' };
|
|
150
|
+
}
|
|
151
|
+
const MODEL_ERROR_PREFIX = 'model error — ';
|
|
152
|
+
const CAUSE_MAX = 200;
|
|
153
|
+
const STDERR_TAIL = 300;
|
|
154
|
+
/** The one wording of a model error, so a caller can recognise it back (`isModelErrorMessage`). */
|
|
155
|
+
export function describeNoAnswer(f) {
|
|
156
|
+
switch (f.kind) {
|
|
157
|
+
case 'model-error':
|
|
158
|
+
return `${MODEL_ERROR_PREFIX}${f.cause.slice(0, CAUSE_MAX)}`;
|
|
159
|
+
case 'dead-child':
|
|
160
|
+
return ('produced no output — the child never wrote a single byte, so it died '
|
|
161
|
+
+ 'before it could answer'
|
|
162
|
+
+ (f.stderr ? `: ${f.stderr.slice(-STDERR_TAIL)}` : ''));
|
|
163
|
+
case 'empty-answer':
|
|
164
|
+
return 'produced no output';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function isModelErrorMessage(msg) {
|
|
168
|
+
return msg.startsWith(MODEL_ERROR_PREFIX);
|
|
169
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.43",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|