@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.
@@ -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 ? { detached: true } : {}),
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
- try {
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 interface BootSpawnOptions {
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 whole process group is
259
- * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
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
@@ -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` (the detached boot child IS its own group leader, so pgid === child.pid). */
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
- try {
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 whole process group is
537
- * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
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
- detached: true,
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
- try {
626
- if (child?.pid)
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
- ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
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 mode, so a
119
- * loop that survived its restart-with-hint nudges returns null here (the caller
120
- * logs/notifies it as a warning) and the verdict gate alone decides the outcome.
121
- * It still has to be matched before `aborted`/`exitCode` so the kill's side
122
- * effects don't get re-classified as a user cancel or a crash.
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 mode, so a
233
- * loop that survived its restart-with-hint nudges returns null here (the caller
234
- * logs/notifies it as a warning) and the verdict gate alone decides the outcome.
235
- * It still has to be matched before `aborted`/`exitCode` so the kill's side
236
- * effects don't get re-classified as a user cancel or a crash.
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
- // Not a kill, so the ladder leaves it to the consumer: pi reports a failed
244
- // turn as exit 0, empty text, and the cause in `modelError`. Unread, the
245
- // empty text parses as "no verdict" and a dead provider is blamed on the work.
246
- // Checked after a surviving loop too — that arm is a warning, not an answer.
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
- if (end.kind === 'error')
203
- return { ok: false, reason: `fix child failed: ${end.msg}` };
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}` });
@@ -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
- if (r.loopHit) {
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} ===`);
@@ -219,14 +219,9 @@ export async function runBoundedLintFix(deps) {
219
219
  signal: deps.signal,
220
220
  marker: 'LINT-FIX'
221
221
  });
222
- if (end.kind === 'error') {
223
- deps.log?.(`lint-fix child failed ${end.msg}`);
224
- return { ok: false, reason: `fix child failed: ${end.msg}` };
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.
@@ -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
- * Only meaningful when `text` is empty: a turn that produced text after pi
276
- * recovered is a success, and the first-error capture must not relabel it.
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 => x.reason),
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
- // Not a kill, so the ladder leaves it to us: pi reports a failed turn
98
- // as exit 0, empty text, and the cause in `modelError`.
99
- if (result.modelError && result.text.trim().length === 0) {
100
- return workerUnavailable(`Worker failed: model error ${result.modelError.slice(0, 200)}`, details, 'model-error');
101
- }
102
- const text = result.text.trim();
103
- if (text.length === 0) {
104
- return workerUnavailable(describeEmptyAnswer(result), details, 'no-answer');
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 one field enforcement adds. Typing the input as
34
- * what is actually READ lets both pass without either importing the other's
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: an empty answer, and a reported `modelError` on a run
57
- * that still produced text. Neither is a kill — whether they count as a failure
58
- * is the consumer's policy — research-worker.ts, for one, accepts an explicit
59
- * empty section after a retry so folding them in would move a decision out of
60
- * the module that owns it.
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.41",
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",