@mjasnikovs/pi-task 0.13.25 → 0.13.26

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.
Binary file
@@ -56,6 +56,14 @@ export interface PhaseResearchDeps extends ExternalContextDeps {
56
56
  * mentions; the rest are unchanged or only lightly trimmed.
57
57
  */
58
58
  export declare function scopedToolingGoal(refined: string): string;
59
+ /**
60
+ * Build a degraded section body for a runaway worker: a one-line marker naming
61
+ * the failure (so downstream phases and a human reading the task file know this
62
+ * section is incomplete) followed by whatever partial answer the worker streamed
63
+ * before it was killed. The marker is always present even when there is no
64
+ * partial text, so an empty degrade is never mistaken for a real finding.
65
+ */
66
+ export declare function degradedSectionBody(name: string, reason: string, partial: string): string;
59
67
  export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
60
68
  export interface PhaseAutoAnswerDeps {
61
69
  docsFocused?: typeof docsFocused;
@@ -122,34 +122,67 @@ export function scopedToolingGoal(refined) {
122
122
  return firstBullet === -1 ? goal : goal.slice(0, firstBullet).trim();
123
123
  }
124
124
  /**
125
- * Throw a descriptive error if a research worker's result is not trustworthy
126
- * output, so only good output is ever cached and assembled. Precedence mirrors
127
- * the original inline checks: loop/timeout exhaustion first (a loop-kill is a
128
- * SIGTERM/exit 143 OR a clean exit 0 with partial text, so exitCode alone is
129
- * ambiguous the worker already burned its MAX_LOOP_RESTARTS restarts before
130
- * setting these), then exit code, empty output, and finally a leaked
131
- * (never-executed) tool call.
125
+ * Classify a research worker's result so the phase can react per-worker instead
126
+ * of treating every failure the same. Two distinct failure shapes:
127
+ *
128
+ * - 'runaway' (loop-kill OR per-worker wall-clock timeout): the worker explored
129
+ * too long and was killed *after* burning its MAX_LOOP_RESTARTS restarts. It
130
+ * did real work and left partial text; the other three workers are unaffected.
131
+ * Failing the whole task here would throw away every already-good worker AND
132
+ * abort the entire auto-run over the weakest section — and because the loop is
133
+ * deterministic, a resume just re-loops and re-fails. So this DEGRADES: keep
134
+ * the partial answer (marked), cache it, move on. A loop-kill is a SIGTERM
135
+ * (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
136
+ * exitCode — are the reliable signal and are checked first.
137
+ *
138
+ * - 'fatal' (non-zero exit that isn't a loop-kill, empty output, or a leaked
139
+ * never-executed tool call): the output is untrustworthy in a way partial text
140
+ * can't paper over (broken env, model disconnect, wrong tool-call dialect).
141
+ * These still throw — degrading them would launder a real breakage into a
142
+ * plausible-looking section. Returns null when the result is trustworthy.
132
143
  */
133
- function assertResearchWorkerOk(name, result) {
144
+ function classifyResearchWorker(name, result) {
134
145
  if (result.loopHit) {
135
146
  const argsStr = JSON.stringify(result.loopHit.call.args);
136
- throw new Error(`Research ${name} worker stuck in a loop — called `
137
- + `${result.loopHit.call.name}(${argsStr}) ×${result.loopHit.count} in the last `
138
- + `${result.loopHit.windowSize} calls and still looped after restarts`);
147
+ return {
148
+ kind: 'runaway',
149
+ reason: `stuck in a loop — called ${result.loopHit.call.name}(${argsStr}) `
150
+ + `×${result.loopHit.count} in the last ${result.loopHit.windowSize} calls `
151
+ + `and still looped after restarts`
152
+ };
139
153
  }
140
154
  if (result.timedOut) {
141
- throw new Error(`Research ${name} worker timed out after restarts`);
155
+ return { kind: 'runaway', reason: 'timed out after restarts' };
142
156
  }
143
157
  if (result.exitCode !== 0) {
144
- throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
158
+ return {
159
+ kind: 'fatal',
160
+ error: new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`)
161
+ };
145
162
  }
146
163
  if (result.text.trim().length === 0) {
147
- throw new Error(`Research ${name} worker produced no output`);
164
+ return { kind: 'fatal', error: new Error(`Research ${name} worker produced no output`) };
148
165
  }
149
166
  if (result.leakedToolCall) {
150
- throw new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
151
- + `(${result.leakedToolCall.trim()}) — it never ran`);
167
+ return {
168
+ kind: 'fatal',
169
+ error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
170
+ + `(${result.leakedToolCall.trim()}) — it never ran`)
171
+ };
152
172
  }
173
+ return null;
174
+ }
175
+ /**
176
+ * Build a degraded section body for a runaway worker: a one-line marker naming
177
+ * the failure (so downstream phases and a human reading the task file know this
178
+ * section is incomplete) followed by whatever partial answer the worker streamed
179
+ * before it was killed. The marker is always present even when there is no
180
+ * partial text, so an empty degrade is never mistaken for a real finding.
181
+ */
182
+ export function degradedSectionBody(name, reason, partial) {
183
+ const marker = `(degraded: research ${name} worker ${reason}; this section may be incomplete)`;
184
+ const body = partial.trim();
185
+ return body.length > 0 ? `${marker}\n\n${body}` : marker;
153
186
  }
154
187
  export async function phaseResearch(deps, refined, researchDeps = {}) {
155
188
  const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
@@ -265,11 +298,21 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
265
298
  + (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
266
299
  + (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
267
300
  updateProgress();
268
- // Throws (failing the phase) on a loop/timeout/exit/empty/leak — so the
269
- // workers that already succeeded above stay cached for the resume.
270
- assertResearchWorkerOk(spec.section, r);
271
- await setTaskSection(deps.cwd, deps.taskId, cacheHeading, r.text);
272
- sections.push({ name: spec.section, text: r.text.trim() });
301
+ // A fatal failure (crash/empty/leak) still throws the already-cached
302
+ // workers survive for the resume. A runaway (loop/timeout) degrades to its
303
+ // partial output instead, so one weak worker can't abort a whole auto-run;
304
+ // the degraded section is cached too, so a resume doesn't re-loop it.
305
+ const failure = classifyResearchWorker(spec.section, r);
306
+ if (failure?.kind === 'fatal')
307
+ throw failure.error;
308
+ const sectionText = failure?.kind === 'runaway' ?
309
+ degradedSectionBody(spec.section, failure.reason, r.text)
310
+ : r.text.trim();
311
+ if (failure?.kind === 'runaway') {
312
+ deps.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
313
+ }
314
+ await setTaskSection(deps.cwd, deps.taskId, cacheHeading, sectionText);
315
+ sections.push({ name: spec.section, text: sectionText });
273
316
  }
274
317
  // All workers succeeded — the assembled output below becomes the canonical
275
318
  // 'research' section (written by the orchestrator). The per-worker caches
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.25",
3
+ "version": "0.13.26",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -66,6 +66,7 @@
66
66
  "pi": {
67
67
  "extensions": [
68
68
  "dist/index.js"
69
- ]
69
+ ],
70
+ "image": "https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/pipeline.png"
70
71
  }
71
72
  }