@mjasnikovs/pi-task 0.38.16 → 0.38.18

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.
Files changed (38) hide show
  1. package/dist/config/config.d.ts +37 -0
  2. package/dist/config/config.js +81 -18
  3. package/dist/task/accept-debt.js +2 -1
  4. package/dist/task/artifact-closure.js +18 -63
  5. package/dist/task/auto-orchestrator.js +205 -214
  6. package/dist/task/boot-probe.d.ts +46 -0
  7. package/dist/task/boot-probe.js +41 -21
  8. package/dist/task/coverage-loop.d.ts +11 -0
  9. package/dist/task/coverage-loop.js +16 -0
  10. package/dist/task/final-gate-fix.js +14 -24
  11. package/dist/task/final-gate.js +6 -1
  12. package/dist/task/fix-child.d.ts +64 -0
  13. package/dist/task/fix-child.js +66 -0
  14. package/dist/task/lint-fix.d.ts +7 -0
  15. package/dist/task/lint-fix.js +45 -9
  16. package/dist/task/orchestrator.js +9 -2
  17. package/dist/task/phases.d.ts +66 -4
  18. package/dist/task/phases.js +94 -34
  19. package/dist/task/plan-rounds.d.ts +86 -0
  20. package/dist/task/plan-rounds.js +105 -0
  21. package/dist/task/plan-session.d.ts +31 -21
  22. package/dist/task/plan-session.js +97 -120
  23. package/dist/task/qa-transcript.d.ts +100 -0
  24. package/dist/task/qa-transcript.js +99 -0
  25. package/dist/task/question-source.d.ts +117 -0
  26. package/dist/task/question-source.js +174 -0
  27. package/dist/task/serve-entry.js +6 -57
  28. package/dist/task/shipped-source.d.ts +67 -0
  29. package/dist/task/shipped-source.js +144 -0
  30. package/dist/task/task-gates.d.ts +1 -1
  31. package/dist/task/task-gates.js +4 -2
  32. package/dist/task/verify-work.d.ts +46 -0
  33. package/dist/task/verify-work.js +51 -3
  34. package/dist/task/widget.js +41 -9
  35. package/dist/workers/docs-core.d.ts +71 -1
  36. package/dist/workers/docs-core.js +131 -71
  37. package/dist/workers/pi-worker-core.js +23 -8
  38. package/package.json +1 -1
@@ -115,21 +115,35 @@ export function buildWidgetData(s) {
115
115
  export function startWidget(ctx, getState) {
116
116
  if (!ctx.hasUI)
117
117
  return () => { };
118
+ // `ctx.ui` THROWS once the ctx goes stale (/reload, session replacement), so
119
+ // the theme read belongs INSIDE the guard, not one line above it. render()
120
+ // runs from a timer, where an unguarded throw is an uncaughtException that
121
+ // kills the whole pi process — and a swallowed one would throw again on
122
+ // every tick, so a stale ctx latches and stops the timer. Issue #15.
123
+ let stale = false;
118
124
  const render = () => {
125
+ if (stale)
126
+ return;
119
127
  const s = getState();
120
- const lines = s ? buildWidgetLines(s, ctx.ui.theme) : undefined;
121
- const plain = s ? buildWidgetLines(s, undefined) : undefined; // un-themed for the wire
128
+ const plain = s ? buildWidgetLines(s, undefined) : undefined; // un-themed for the wire; needs no ctx
122
129
  try {
130
+ const lines = s ? buildWidgetLines(s, ctx.ui.theme) : undefined;
123
131
  ctx.ui.setWidget(WIDGET_KEY, lines);
124
132
  }
125
133
  catch {
126
- /* stale ctx */
134
+ stale = true;
135
+ clearInterval(timer);
136
+ return;
127
137
  }
128
138
  setTaskWidget(plain, s ? buildWidgetData(s) : null);
129
139
  };
130
- render();
140
+ // The timer is created BEFORE the first render so `timer` is always bound
141
+ // when render's catch reaches for it: a ctx already stale on the very first
142
+ // paint now stops the loop there, instead of arming an interval that wakes
143
+ // up and returns early forever.
131
144
  const timer = setInterval(render, WIDGET_REFRESH_MS);
132
145
  timer.unref?.();
146
+ render();
133
147
  return () => {
134
148
  clearInterval(timer);
135
149
  try {
@@ -192,21 +206,32 @@ export function buildAutoLoaderData(s) {
192
206
  export function startAutoLoader(ctx, getState) {
193
207
  if (!ctx.hasUI)
194
208
  return () => { };
209
+ // Same staleness hazard as startWidget: the theme read must sit inside the
210
+ // guard, and a stale ctx stops the timer rather than throwing every tick.
211
+ let stale = false;
195
212
  const render = () => {
213
+ if (stale)
214
+ return;
196
215
  const s = getState();
197
- const lines = s ? buildAutoLoaderLines(s, ctx.ui.theme) : undefined;
198
- const plain = s ? buildAutoLoaderLines(s, undefined) : undefined;
216
+ const plain = s ? buildAutoLoaderLines(s, undefined) : undefined; // needs no ctx
199
217
  try {
218
+ const lines = s ? buildAutoLoaderLines(s, ctx.ui.theme) : undefined;
200
219
  ctx.ui.setWidget(AUTO_WIDGET_KEY, lines);
201
220
  }
202
221
  catch {
203
- /* stale ctx */
222
+ stale = true;
223
+ clearInterval(timer);
224
+ return;
204
225
  }
205
226
  setTaskWidget(plain, s ? buildAutoLoaderData(s) : null);
206
227
  };
207
- render();
228
+ // The timer is created BEFORE the first render so `timer` is always bound
229
+ // when render's catch reaches for it: a ctx already stale on the very first
230
+ // paint now stops the loop there, instead of arming an interval that wakes
231
+ // up and returns early forever.
208
232
  const timer = setInterval(render, WIDGET_REFRESH_MS);
209
233
  timer.unref?.();
234
+ render();
210
235
  return () => {
211
236
  clearInterval(timer);
212
237
  try {
@@ -249,7 +274,14 @@ export function buildImplData(s) {
249
274
  export function flashTerminalWidget(ctx, state, taskId, reason) {
250
275
  if (!ctx.hasUI)
251
276
  return;
252
- const theme = ctx.ui.theme;
277
+ // Same staleness hazard as the render timers: bail rather than throw.
278
+ let theme;
279
+ try {
280
+ theme = ctx.ui.theme;
281
+ }
282
+ catch {
283
+ return;
284
+ }
253
285
  let line;
254
286
  let clearMs;
255
287
  if (state === 'cancelled') {
@@ -167,11 +167,81 @@ export declare function findDeclaredRange(parentPkg: string, cwd: string): strin
167
167
  export declare function buildVersionBanner(pin: AutoInstallPin | undefined, resolved: string, version: string, cwd: string): string;
168
168
  export declare function getDocsModulesDir(): string;
169
169
  export declare function ensureDocsModulesDir(dir: string): void;
170
- export declare function runAutoInstall(spawn: SpawnFn, packageName: string, signal: AbortSignal | undefined, versionRange?: string): Promise<{
170
+ /**
171
+ * Options rather than a positional tail: `signal` and `versionRange` sat adjacent,
172
+ * and reaching the range meant writing `undefined` into the signal slot — which is
173
+ * exactly how the primary acquisition path lost its abort signal while the hop path
174
+ * kept it.
175
+ */
176
+ export interface AutoInstallOptions {
177
+ signal?: AbortSignal | undefined;
178
+ versionRange?: string | undefined;
179
+ }
180
+ export declare function runAutoInstall(spawn: SpawnFn, packageName: string, opts?: AutoInstallOptions): Promise<{
171
181
  success: boolean;
172
182
  installDir: string;
173
183
  stderr: string;
174
184
  }>;
185
+ /** What acquiring one package produced, or the stage at which it failed. */
186
+ export type AcquireOutcome = {
187
+ ok: true;
188
+ pkg: ResolvedPackage;
189
+ autoInstalled: boolean;
190
+ pin?: AutoInstallPin;
191
+ } | {
192
+ ok: false;
193
+ stage: 'resolve';
194
+ err: unknown;
195
+ } | {
196
+ ok: false;
197
+ stage: 'install';
198
+ stderr: string;
199
+ pin: AutoInstallPin;
200
+ asked: string;
201
+ } | {
202
+ ok: false;
203
+ stage: 'reresolve';
204
+ err: unknown;
205
+ pin: AutoInstallPin;
206
+ };
207
+ export interface AcquireInput {
208
+ /** The specifier to resolve. May be a subpath (`pkg/sub`) — the INSTALL always
209
+ * targets its parent package. */
210
+ name: string;
211
+ cwd: string;
212
+ spawn: SpawnFn;
213
+ resolvePackage: typeof defaultResolvePackage;
214
+ signal: AbortSignal | undefined;
215
+ }
216
+ /**
217
+ * Get a package onto disk and resolved: resolve from `cwd`, and on
218
+ * `not_installed` install it — at the range the PROJECT declares when it declares
219
+ * one — then resolve again from the install dir.
220
+ *
221
+ * One statement of the ladder, for both callers. It was written twice: inline in
222
+ * `docsRaw` for the requested package, and in `tryResolveOrInstall` for each hop
223
+ * of the type-redirect chain — and the copies had drifted on all three things
224
+ * that matter.
225
+ *
226
+ * - The abort signal. The hop copy passed it; the primary copy passed a bare
227
+ * `undefined` placeholder to reach the fourth positional, while
228
+ * `DocsRawInput.signal` was honoured on either side of that call. So a user
229
+ * cancel during the MAIN `npm install` of a model-chosen package was not
230
+ * delivered. `runAutoInstall` takes an options object now, so a hole like that
231
+ * cannot be typed.
232
+ * - The version pin. `findDeclaredRange` had exactly one call site — the primary
233
+ * copy. The hop copy installed `latest` unconditionally, on the hop most likely
234
+ * to be the declared one: `declarationChain` exists precisely because "a
235
+ * project that uses Bun declares `@types/bun`, not `bun`".
236
+ * - The provenance. `autoInstalled`/`autoInstallPin` were locals of `docsRaw`, so
237
+ * a package acquired only through a hop reported neither and got no version
238
+ * banner.
239
+ *
240
+ * CONTEXT.md records the redirect WALK as unified (`resolveTypeSource`) and its
241
+ * `resolveHop` seam as "the one thing its two call sites disagree about". They
242
+ * should disagree only about WHETHER to install, never about HOW.
243
+ */
244
+ export declare function acquirePackage(input: AcquireInput): Promise<AcquireOutcome>;
175
245
  export declare function docsRaw(input: DocsRawInput): Promise<DocsRawResult>;
176
246
  export declare function docsFocused(input: DocsFocusedInput): Promise<DocsFocusedResult>;
177
247
  export declare function buildPrompt(pkg: ResolvedPackage, query: string, content: string): string;
@@ -176,7 +176,8 @@ export function ensureDocsModulesDir(dir) {
176
176
  fs.writeFileSync(pkgPath, '{"name":"pi-worker-docs-modules","private":true}\n', 'utf8');
177
177
  }
178
178
  }
179
- export async function runAutoInstall(spawn, packageName, signal, versionRange) {
179
+ export async function runAutoInstall(spawn, packageName, opts = {}) {
180
+ const { signal, versionRange } = opts;
180
181
  const installDir = getDocsModulesDir();
181
182
  ensureDocsModulesDir(installDir);
182
183
  // `shell: false` in runChild, so a `^`/`~`/space in the range stays a single
@@ -203,31 +204,97 @@ export async function runAutoInstall(spawn, packageName, signal, versionRange) {
203
204
  }, installDir, signal, { mode: 'text', discardStdout: true });
204
205
  return { success: result.exitCode === 0 && !result.aborted, installDir, stderr: result.stderr };
205
206
  }
206
- /** Resolve `name` from cwd; on `not_installed`, auto-install it and resolve from
207
- * the install dir. Returns null on any failure (caller keeps its fallback). */
208
- async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal) {
207
+ /**
208
+ * Get a package onto disk and resolved: resolve from `cwd`, and on
209
+ * `not_installed` install it at the range the PROJECT declares when it declares
210
+ * one — then resolve again from the install dir.
211
+ *
212
+ * One statement of the ladder, for both callers. It was written twice: inline in
213
+ * `docsRaw` for the requested package, and in `tryResolveOrInstall` for each hop
214
+ * of the type-redirect chain — and the copies had drifted on all three things
215
+ * that matter.
216
+ *
217
+ * - The abort signal. The hop copy passed it; the primary copy passed a bare
218
+ * `undefined` placeholder to reach the fourth positional, while
219
+ * `DocsRawInput.signal` was honoured on either side of that call. So a user
220
+ * cancel during the MAIN `npm install` of a model-chosen package was not
221
+ * delivered. `runAutoInstall` takes an options object now, so a hole like that
222
+ * cannot be typed.
223
+ * - The version pin. `findDeclaredRange` had exactly one call site — the primary
224
+ * copy. The hop copy installed `latest` unconditionally, on the hop most likely
225
+ * to be the declared one: `declarationChain` exists precisely because "a
226
+ * project that uses Bun declares `@types/bun`, not `bun`".
227
+ * - The provenance. `autoInstalled`/`autoInstallPin` were locals of `docsRaw`, so
228
+ * a package acquired only through a hop reported neither and got no version
229
+ * banner.
230
+ *
231
+ * CONTEXT.md records the redirect WALK as unified (`resolveTypeSource`) and its
232
+ * `resolveHop` seam as "the one thing its two call sites disagree about". They
233
+ * should disagree only about WHETHER to install, never about HOW.
234
+ */
235
+ export async function acquirePackage(input) {
236
+ const { name, cwd, spawn, resolvePackage, signal } = input;
209
237
  try {
210
- return resolvePackage(name, cwd);
238
+ return { ok: true, pkg: resolvePackage(name, cwd), autoInstalled: false };
211
239
  }
212
- catch (err) {
213
- if (!(err instanceof ResolveError) || err.kind !== 'not_installed')
214
- return null;
215
- const install = await runAutoInstall(spawn, extractParentPackage(name), signal);
216
- if (!install.success)
217
- return null;
240
+ catch (firstErr) {
241
+ if (!(firstErr instanceof ResolveError) || firstErr.kind !== 'not_installed') {
242
+ return { ok: false, stage: 'resolve', err: firstErr };
243
+ }
244
+ // Auto-install — but FIRST honour the version the project intends. If the
245
+ // dep is declared in the project's package.json, install that range so a
246
+ // scaffolding answer is grounded in the project's major, not whatever npm
247
+ // currently tags `latest`.
248
+ const asked = extractParentPackage(name);
249
+ const declaredRange = findDeclaredRange(asked, cwd);
250
+ const pin = declaredRange ?
251
+ { source: 'declared-range', range: declaredRange, asked }
252
+ : { source: 'npm-latest', asked };
253
+ const install = await runAutoInstall(spawn, asked, {
254
+ signal,
255
+ versionRange: declaredRange ?? undefined
256
+ });
257
+ if (!install.success) {
258
+ return { ok: false, stage: 'install', stderr: install.stderr, pin, asked };
259
+ }
218
260
  try {
219
- return resolvePackage(name, install.installDir);
261
+ return {
262
+ ok: true,
263
+ pkg: resolvePackage(name, install.installDir),
264
+ autoInstalled: true,
265
+ pin
266
+ };
220
267
  }
221
- catch {
222
- return null;
268
+ catch (retryErr) {
269
+ return { ok: false, stage: 'reresolve', err: retryErr, pin };
223
270
  }
224
271
  }
225
272
  }
273
+ /** Resolve `name` from cwd; on `not_installed`, auto-install it and resolve from
274
+ * the install dir. Returns null on any failure (caller keeps its fallback). */
275
+ async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal, onAcquired) {
276
+ const got = await acquirePackage({ name, cwd, spawn, resolvePackage, signal });
277
+ if (!got.ok)
278
+ return null;
279
+ if (got.autoInstalled)
280
+ onAcquired?.(got.pin);
281
+ return got.pkg;
282
+ }
226
283
  /** The docs pipeline's adapter over the shared redirect walk (docs-resolve.ts):
227
284
  * hops resolve through the auto-installing lookup, so a declaration package that is
228
285
  * declared but not yet on disk is fetched rather than abandoned. */
229
- function resolveTypeSourceForDocs(pkg, requested, cwd, spawn, resolvePackage, signal) {
230
- return resolveTypeSource(pkg, extractParentPackage(requested), next => tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal));
286
+ async function resolveTypeSourceForDocs(pkg, requested, cwd, spawn, resolvePackage, signal) {
287
+ // A package acquired ONLY through a hop used to report neither `autoInstalled`
288
+ // nor a pin, so `pi-worker-docs` emitted no version banner for it — while the
289
+ // banner's own text ("only its types are, as `@types/bun` `^1.2`") claims a
290
+ // range as provenance. Report the last hop that actually installed.
291
+ let installed = false;
292
+ let pin;
293
+ const out = await resolveTypeSource(pkg, extractParentPackage(requested), next => tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal, hopPin => {
294
+ installed = true;
295
+ pin = hopPin;
296
+ }));
297
+ return pin ? { pkg: out, installed, pin } : { pkg: out, installed };
231
298
  }
232
299
  export async function docsRaw(input) {
233
300
  const resolvePackage = input.resolvePackage ?? defaultResolvePackage;
@@ -247,83 +314,76 @@ export async function docsRaw(input) {
247
314
  const npmVersionPromise = npmVersionLookup(extractParentPackage(requested), {
248
315
  signal: input.signal
249
316
  }).catch(() => null);
250
- // Step 1: resolve package
251
- let pkg;
252
- let autoInstalled = false;
253
- let autoInstallPin;
254
- try {
255
- pkg = resolvePackage(requested, input.cwd);
256
- }
257
- catch (firstErr) {
258
- if (firstErr instanceof ResolveError && firstErr.kind === 'not_installed') {
259
- // auto-install — but FIRST honour the version the project intends.
260
- // If the dep is declared in the project's package.json, install that
261
- // range so a scaffolding answer is grounded in the project's major,
262
- // not whatever npm currently tags `latest`.
263
- const parentPkg = extractParentPackage(requested);
264
- const declaredRange = findDeclaredRange(parentPkg, input.cwd);
265
- autoInstallPin =
266
- declaredRange ?
267
- { source: 'declared-range', range: declaredRange, asked: parentPkg }
268
- : { source: 'npm-latest', asked: parentPkg };
269
- const installResult = await runAutoInstall(spawn, parentPkg, undefined, declaredRange ?? undefined);
270
- if (!installResult.success) {
271
- return {
272
- kind: 'error',
273
- message: `Package "${parentPkg}" is not installed and auto-install failed.\n${installResult.stderr}`,
274
- resolveError: 'not_installed',
275
- installError: installResult.stderr,
276
- autoInstallPin,
277
- npmVersion: await npmVersionPromise
278
- };
279
- }
280
- autoInstalled = true;
281
- try {
282
- pkg = resolvePackage(requested, installResult.installDir);
283
- }
284
- catch (retryErr) {
285
- if (retryErr instanceof ResolveError) {
286
- return {
287
- kind: 'error',
288
- message: retryErr.message,
289
- resolveError: retryErr.kind,
290
- autoInstalled,
291
- autoInstallPin,
292
- npmVersion: await npmVersionPromise
293
- };
294
- }
317
+ // Step 1: acquire the package — resolve, or install-at-the-declared-range and
318
+ // resolve again. The ladder is `acquirePackage`; this maps its stages onto the
319
+ // rich error results the docs tool reports. `input.signal` now reaches the
320
+ // install, which it never did while the range was a fourth positional.
321
+ const got = await acquirePackage({
322
+ name: requested,
323
+ cwd: input.cwd,
324
+ spawn,
325
+ resolvePackage,
326
+ signal: input.signal
327
+ });
328
+ if (!got.ok) {
329
+ if (got.stage === 'install') {
330
+ return {
331
+ kind: 'error',
332
+ message: `Package "${got.asked}" is not installed and auto-install failed.\n${got.stderr}`,
333
+ resolveError: 'not_installed',
334
+ installError: got.stderr,
335
+ autoInstallPin: got.pin,
336
+ npmVersion: await npmVersionPromise
337
+ };
338
+ }
339
+ if (got.stage === 'reresolve') {
340
+ if (got.err instanceof ResolveError) {
295
341
  return {
296
342
  kind: 'error',
297
- message: `Could not resolve "${input.pkg}" after install: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
298
- autoInstalled,
299
- autoInstallPin,
343
+ message: got.err.message,
344
+ resolveError: got.err.kind,
345
+ autoInstalled: true,
346
+ autoInstallPin: got.pin,
300
347
  npmVersion: await npmVersionPromise
301
348
  };
302
349
  }
303
- }
304
- else if (firstErr instanceof ResolveError) {
305
350
  return {
306
351
  kind: 'error',
307
- message: firstErr.message,
308
- resolveError: firstErr.kind,
352
+ message: `Could not resolve "${input.pkg}" after install: ${got.err instanceof Error ? got.err.message : String(got.err)}`,
353
+ autoInstalled: true,
354
+ autoInstallPin: got.pin,
309
355
  npmVersion: await npmVersionPromise
310
356
  };
311
357
  }
312
- else {
358
+ if (got.err instanceof ResolveError) {
313
359
  return {
314
360
  kind: 'error',
315
- message: `Could not resolve "${input.pkg}": ${firstErr instanceof Error ? firstErr.message : String(firstErr)}`,
361
+ message: got.err.message,
362
+ resolveError: got.err.kind,
316
363
  npmVersion: await npmVersionPromise
317
364
  };
318
365
  }
366
+ return {
367
+ kind: 'error',
368
+ message: `Could not resolve "${input.pkg}": ${got.err instanceof Error ? got.err.message : String(got.err)}`,
369
+ npmVersion: await npmVersionPromise
370
+ };
319
371
  }
372
+ let pkg = got.pkg;
373
+ let autoInstalled = got.autoInstalled;
374
+ let autoInstallPin = got.pin;
320
375
  // Step 1b: if the resolved package ships no usable type declarations (e.g.
321
376
  // the `bun` runtime launcher, which is just a binary + install README), or is
322
377
  // a pure `@types/<name>` redirect stub, follow the conventional
323
378
  // @types/<name> + triple-slash `<reference types>` chain to the package that
324
379
  // actually holds the declarations (e.g. bun -> @types/bun -> bun-types).
325
380
  // Best-effort: any failure leaves the original resolution untouched.
326
- pkg = await resolveTypeSourceForDocs(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
381
+ const viaTypes = await resolveTypeSourceForDocs(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
382
+ pkg = viaTypes.pkg;
383
+ if (viaTypes.installed) {
384
+ autoInstalled = true;
385
+ autoInstallPin ??= viaTypes.pin;
386
+ }
327
387
  // Step 2: open cache
328
388
  let cache = null;
329
389
  let cacheError;
@@ -8,6 +8,7 @@ import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint, isConne
8
8
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
9
9
  import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
10
10
  import { streamStallHint } from '../shared/stream-watchdog.js';
11
+ import { classifyWorkerFailure } from './worker-failure.js';
11
12
  // `--mode json` makes pi emit structured events as they happen instead of
12
13
  // buffering the assistant text and flushing on exit. That matters for the
13
14
  // wait/work timing split: in text mode the first stdout chunk only arrives at
@@ -647,14 +648,28 @@ export async function runWorker(input) {
647
648
  // worker that finished cleanly has answered, and a short answer is a
648
649
  // legitimate answer — length would let a long half-finished fragment
649
650
  // override a concise correct one, which is the opposite of the fix.
650
- const finalAttemptFailed = timedOut === true
651
- || result.aborted
652
- || result.modelError !== undefined
653
- || result.stalled === true
654
- || streamStalled !== undefined
655
- || commandKill !== undefined
656
- || loopHit !== undefined
657
- || text.trim().length === 0;
651
+ // ASK THE LADDER do not restate it. This was an eight-term disjunction,
652
+ // a fifth hand-written statement of the taxonomy `worker-failure.ts` exists
653
+ // to own, and it had already drifted: `leakedToolCall` and a plain non-zero
654
+ // `exitCode` are rows in FAILURE_RULES and were missing here. Both are cases
655
+ // where an attempt that produced nothing usable counted as NOT failed, so
656
+ // salvage was skipped and a good earlier partial was overwritten — the exact
657
+ // outcome the comment above forbids.
658
+ //
659
+ // The two non-kill terms stay explicit because `worker-failure.ts`
660
+ // deliberately excludes them as CONSUMER policy: an empty answer and a
661
+ // reported `modelError` on a run that still produced text are not kills.
662
+ const killCause = classifyWorkerFailure({
663
+ exitCode: result.exitCode,
664
+ aborted: result.aborted,
665
+ timedOut,
666
+ ...(result.stalled === true ? { stalled: true } : {}),
667
+ ...(loopHit ? { loopHit } : {}),
668
+ ...(leaked ? { leakedToolCall: leaked } : {}),
669
+ ...(commandKill ? { commandTimedOut: commandKill } : {}),
670
+ ...(streamStalled ? { streamStalled } : {})
671
+ });
672
+ const finalAttemptFailed = killCause !== undefined || result.modelError !== undefined || text.trim().length === 0;
658
673
  const answer = (finalAttemptFailed
659
674
  && salvage.text !== null
660
675
  && salvage.text.length > text.trim().length) ?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.16",
3
+ "version": "0.38.18",
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",