@mjasnikovs/pi-task 0.40.40 → 0.40.42

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.
@@ -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,8 +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;
97
+ export interface EnforceChildResult extends WorkerFailureInput, WorkerAnswerInput {
99
98
  }
100
99
  /**
101
100
  * Map the enforcement child's runWorker result to a fatal error message, or null
@@ -114,11 +113,16 @@ export interface EnforceChildResult extends WorkerFailureInput {
114
113
  * user as their own cancel. The switch below is exhaustive, so the next cause
115
114
  * added to the union is a compile error here rather than a silent mislabel.
116
115
  *
117
- * A loop is NOT fatal: enforce attaches the detector in nudge-then-warn mode, so a
118
- * loop that survived its restart-with-hint nudges returns null here (the caller
119
- * logs/notifies it as a warning) and the verdict gate alone decides the outcome.
120
- * It still has to be matched before `aborted`/`exitCode` so the kill's side
121
- * 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.
122
126
  */
123
127
  export declare function classifyEnforceChildFailure(r: EnforceChildResult): string | null;
124
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,16 +229,28 @@ 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
- const failure = classifyWorkerFailure(r);
240
- if (!failure)
244
+ const kill = classifyWorkerFailure(r);
245
+ const named = kill ? describeKill(kill) : null;
246
+ if (named !== null)
247
+ return named;
248
+ const noAnswer = classifyWorkerAnswer(r);
249
+ if (!noAnswer || noAnswer.kind === 'empty-answer')
241
250
  return null;
251
+ return describeNoAnswer(noAnswer);
252
+ }
253
+ function describeKill(failure) {
242
254
  switch (failure.kind) {
243
255
  case 'stalled':
244
256
  // Otherwise a long silence is reported as a user cancel.
@@ -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
  /**
@@ -12,4 +12,10 @@
12
12
  * — the tool whitelist, not the extension flag, is the bound that holds.
13
13
  */
14
14
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
15
- export declare function registerPiWorker(pi: ExtensionAPI): void;
15
+ import type { SpawnFn } from '../shared/child-process.js';
16
+ /** Test seams: a fake child, and no real backoff sleep between its attempts. */
17
+ export interface PiWorkerInternals {
18
+ spawn?: SpawnFn;
19
+ sleepFor?: (ms: number) => Promise<void>;
20
+ }
21
+ export declare function registerPiWorker(pi: ExtensionAPI, internals?: PiWorkerInternals): void;
@@ -17,12 +17,34 @@ 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;
23
+ const STDERR_TAIL = 500;
24
+ function workerDetails(r) {
25
+ return {
26
+ exitCode: r.exitCode,
27
+ attempts: r.attempts,
28
+ restarts: r.restarts.map(x => ({
29
+ reason: x.reason,
30
+ ...(x.detail !== undefined ? { detail: x.detail } : {})
31
+ })),
32
+ ...(r.modelError !== undefined ? { modelError: r.modelError } : {}),
33
+ ...(r.stderr ? { stderr: r.stderr.slice(-STDERR_TAIL) } : {})
34
+ };
35
+ }
36
+ /** An empty final answer, with the spawns it cost, so the model can tell a
37
+ * retried failure from a child that simply had nothing to say. */
38
+ function describeEmptyAnswer(r) {
39
+ if (r.restarts.length === 0)
40
+ return '(no output)';
41
+ const reasons = r.restarts.map(x => x.reason).join(', ');
42
+ return `(no output after ${r.attempts} attempts; discarded: ${reasons})`;
43
+ }
22
44
  const WorkerParams = Type.Object({
23
45
  prompt: Type.String({ description: 'Task for the worker to perform.' })
24
46
  });
25
- export function registerPiWorker(pi) {
47
+ export function registerPiWorker(pi, internals = {}) {
26
48
  makeWorkerTool(pi, {
27
49
  name: 'pi-worker',
28
50
  label: 'Pi Worker',
@@ -67,14 +89,24 @@ export function registerPiWorker(pi) {
67
89
  // model and its window is the honest one. Without this the churn
68
90
  // rule cannot fire — see RunWorkerInput.contextWindow.
69
91
  contextWindow: contextWindowForGroup(ctx, 'research') || 'unknown',
70
- groupArgs: groupChildArgs('research')
92
+ groupArgs: groupChildArgs('research'),
93
+ ...(internals.spawn ? { spawn: internals.spawn } : {}),
94
+ ...(internals.sleepFor ? { sleepFor: internals.sleepFor } : {})
71
95
  });
72
- const details = { exitCode: result.exitCode };
96
+ const details = workerDetails(result);
73
97
  const failure = formatChildFailure(result, 'Worker aborted.');
74
98
  if (failure !== null) {
75
99
  return workerUnavailable(failure, details, childFailureReason(result));
76
100
  }
77
- return workerAnswer(result.text || '(no output)', 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);
78
110
  },
79
111
  renderCall(args, theme) {
80
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.40",
3
+ "version": "0.40.42",
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",