@mjasnikovs/pi-task 0.13.15 → 0.13.17
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/task/auto-orchestrator.d.ts +18 -0
- package/dist/task/auto-orchestrator.js +51 -2
- package/dist/task/auto-prompts.js +1 -0
- package/dist/task/child-runner.d.ts +1 -0
- package/dist/task/child-runner.js +1 -1
- package/dist/task/orchestrator.d.ts +8 -0
- package/dist/task/orchestrator.js +38 -1
- package/dist/task/phases.js +14 -0
- package/dist/task/prompts.js +3 -0
- package/dist/workers/pi-worker-core.d.ts +16 -0
- package/dist/workers/pi-worker-core.js +98 -10
- package/package.json +1 -1
|
@@ -26,6 +26,24 @@ export interface AutoDeps {
|
|
|
26
26
|
* is returned verbatim when nothing readable is referenced.
|
|
27
27
|
*/
|
|
28
28
|
export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* The @file references in the feature that point at a readable file on disk —
|
|
31
|
+
* the bare path tokens, deduped, in first-seen order. Unreadable mentions
|
|
32
|
+
* (typos, non-file @tokens) are dropped so we never advertise a missing file as
|
|
33
|
+
* an authoritative spec.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Thread the feature's spec references into every decomposed task title.
|
|
38
|
+
* Decompose emits titles only, and a title is ALL a per-task pipeline ever sees
|
|
39
|
+
* — so without this the design doc the feature pointed at is invisible
|
|
40
|
+
* downstream and each task drifts to a generic reading of its one-line title
|
|
41
|
+
* (this is how an "Implement @design.md" run built a generic `posts` table the
|
|
42
|
+
* spec never mentioned). Appending the @refs makes the per-task refine/research
|
|
43
|
+
* read the real spec and treat it as authoritative over the title. No readable
|
|
44
|
+
* refs → titles unchanged, so a doc-less /task-auto behaves exactly as before.
|
|
45
|
+
*/
|
|
46
|
+
export declare function attachSpecRefs(titles: string[], refs: string[]): string[];
|
|
29
47
|
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
30
48
|
export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
|
|
31
49
|
export declare function requestAutoCancel(): void;
|
|
@@ -51,6 +51,47 @@ export async function expandFeatureMentions(cwd, feature) {
|
|
|
51
51
|
}
|
|
52
52
|
return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The @file references in the feature that point at a readable file on disk —
|
|
56
|
+
* the bare path tokens, deduped, in first-seen order. Unreadable mentions
|
|
57
|
+
* (typos, non-file @tokens) are dropped so we never advertise a missing file as
|
|
58
|
+
* an authoritative spec.
|
|
59
|
+
*/
|
|
60
|
+
export async function readableMentions(cwd, feature) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const m of feature.matchAll(MENTION_RE)) {
|
|
64
|
+
const rel = m[1];
|
|
65
|
+
if (seen.has(rel))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(rel);
|
|
68
|
+
try {
|
|
69
|
+
await fsp.access(path.resolve(cwd, rel));
|
|
70
|
+
out.push(rel);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// not a readable file — don't thread it into task titles
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Thread the feature's spec references into every decomposed task title.
|
|
80
|
+
* Decompose emits titles only, and a title is ALL a per-task pipeline ever sees
|
|
81
|
+
* — so without this the design doc the feature pointed at is invisible
|
|
82
|
+
* downstream and each task drifts to a generic reading of its one-line title
|
|
83
|
+
* (this is how an "Implement @design.md" run built a generic `posts` table the
|
|
84
|
+
* spec never mentioned). Appending the @refs makes the per-task refine/research
|
|
85
|
+
* read the real spec and treat it as authoritative over the title. No readable
|
|
86
|
+
* refs → titles unchanged, so a doc-less /task-auto behaves exactly as before.
|
|
87
|
+
*/
|
|
88
|
+
export function attachSpecRefs(titles, refs) {
|
|
89
|
+
if (refs.length === 0)
|
|
90
|
+
return titles;
|
|
91
|
+
const list = refs.map(r => '@' + r).join(' ');
|
|
92
|
+
const suffix = ` | spec: ${list} — authoritative; read it and follow it over this title wherever they differ`;
|
|
93
|
+
return titles.map(t => (t.includes('| spec:') ? t : t + suffix));
|
|
94
|
+
}
|
|
54
95
|
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
55
96
|
export async function planAuto(ctx, cwd, feature, deps) {
|
|
56
97
|
// clarify — sequential & adaptive: ask one question at a time, feeding every
|
|
@@ -138,7 +179,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
138
179
|
const clarifications = answers.join('\n');
|
|
139
180
|
// decompose
|
|
140
181
|
const listRaw = await deps.runChild('auto-decompose', 'read', AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications));
|
|
141
|
-
|
|
182
|
+
// Thread the feature's spec doc(s) into every title so each per-task
|
|
183
|
+
// pipeline — which only ever sees its title — reads the real spec instead of
|
|
184
|
+
// a lossy one-line paraphrase of it.
|
|
185
|
+
const refs = await readableMentions(cwd, feature);
|
|
186
|
+
const titles = attachSpecRefs(parseDecomposeList(listRaw), refs);
|
|
142
187
|
if (titles.length === 0) {
|
|
143
188
|
announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
|
|
144
189
|
return null;
|
|
@@ -304,7 +349,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
304
349
|
}
|
|
305
350
|
if (!res.ok) {
|
|
306
351
|
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
307
|
-
|
|
352
|
+
// res.reason is set when the implementation turn itself died
|
|
353
|
+
// (e.g. a context-overflow 400) — surface it so the real cause
|
|
354
|
+
// isn't lost behind the generic "stopped" message.
|
|
355
|
+
const why = res.reason ? ` — ${res.reason.slice(0, 160)}` : '';
|
|
356
|
+
announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
|
|
308
357
|
return;
|
|
309
358
|
}
|
|
310
359
|
// res.ok === true means runner.run() completed, so res.taskId is the
|
|
@@ -33,6 +33,7 @@ fork the breakdown). Account for the answers so far:
|
|
|
33
33
|
time (file/blob storage, client/rendering strategy, auth and session model,
|
|
34
34
|
real-time vs polling transport, search, deployment).
|
|
35
35
|
- Skip anything /task will naturally resolve per-task during its own research.
|
|
36
|
+
- Stay grounded in the referenced spec. If a design/spec doc is included above, do NOT propose a new subsystem, dependency, or requirement it does not call for, and do NOT re-ask a choice the spec already settles. Ask only about genuine forks the spec leaves open.
|
|
36
37
|
|
|
37
38
|
YOU MUST propose a default answer for the question — every question you emit
|
|
38
39
|
carries exactly one SUGGESTED line. Never omit it, never leave it blank, never
|
|
@@ -52,6 +52,7 @@ export type { PhaseDeps };
|
|
|
52
52
|
* leaking, throw LeakedToolCallError rather than returning the unexecuted call.
|
|
53
53
|
*/
|
|
54
54
|
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
|
|
55
|
+
export declare function formatLoopHint(hit: LoopHit): string;
|
|
55
56
|
export declare function prependHint(hint: string | null, prompt: string): string;
|
|
56
57
|
/**
|
|
57
58
|
* Run a phase child with loop detection. On a detected loop, kill and re-spawn
|
|
@@ -119,7 +119,7 @@ export async function runPhaseChild(deps, name, tools, prompt) {
|
|
|
119
119
|
// Unreachable: the loop returns clean text or throws on the final leak.
|
|
120
120
|
throw new LeakedToolCallError(name, '(unknown)');
|
|
121
121
|
}
|
|
122
|
-
function formatLoopHint(hit) {
|
|
122
|
+
export function formatLoopHint(hit) {
|
|
123
123
|
const argsStr = JSON.stringify(hit.call.args);
|
|
124
124
|
return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
|
|
125
125
|
+ `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
|
|
@@ -108,6 +108,14 @@ export interface RunSingleTaskResult {
|
|
|
108
108
|
* uninterrupted.
|
|
109
109
|
*/
|
|
110
110
|
interrupted?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Why the run is not ok, when known. Set when a waitForImplementation turn
|
|
113
|
+
* ended with stopReason "error" (the model/provider died mid-implementation —
|
|
114
|
+
* e.g. a context-overflow 400 — after the task file was already marked
|
|
115
|
+
* `completed` at spec-handoff). The /task-auto loop surfaces this in its
|
|
116
|
+
* "stopped at …" message so the real cause isn't lost. Undefined otherwise.
|
|
117
|
+
*/
|
|
118
|
+
reason?: string;
|
|
111
119
|
}
|
|
112
120
|
/**
|
|
113
121
|
* Run one prompt through the full single-task pipeline in a fresh session and
|
|
@@ -288,6 +288,29 @@ function wasInterrupted(ctx) {
|
|
|
288
288
|
}
|
|
289
289
|
return false;
|
|
290
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* The error cause when the most recent assistant turn ended with stopReason
|
|
293
|
+
* "error" — the model/provider failed mid-implementation (context overflow,
|
|
294
|
+
* disconnect, provider 5xx) after pi exhausted its own retries. Returns the
|
|
295
|
+
* provider's errorMessage, or undefined if the turn ended cleanly ("stop") or
|
|
296
|
+
* was user-aborted ("aborted", handled by wasInterrupted).
|
|
297
|
+
*
|
|
298
|
+
* The /task-auto loop reads this AFTER the implementation wait: a task's file is
|
|
299
|
+
* marked `completed` at spec-handoff (before implementation), so without this
|
|
300
|
+
* check an implementation turn that died — e.g. "400 ... exceeds the available
|
|
301
|
+
* context size" — would still read as ok and get checked off and committed.
|
|
302
|
+
*/
|
|
303
|
+
function implementationError(ctx) {
|
|
304
|
+
const entries = ctx.sessionManager.getEntries();
|
|
305
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
306
|
+
const e = entries[i];
|
|
307
|
+
if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
|
|
308
|
+
const m = e.message;
|
|
309
|
+
return m.stopReason === 'error' ? (m.errorMessage ?? 'model error') : undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
291
314
|
/**
|
|
292
315
|
* After the implementation turn settles, honour a user ESC by letting them steer.
|
|
293
316
|
*
|
|
@@ -326,6 +349,11 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
326
349
|
// cancellation path (where no replacement occurs).
|
|
327
350
|
let freshCtx = ctx;
|
|
328
351
|
let interrupted = false;
|
|
352
|
+
// The implementation turn's failure cause, when it ended with stopReason
|
|
353
|
+
// "error" (only meaningful in the waitForImplementation path). The task file
|
|
354
|
+
// was already marked `completed` at spec-handoff, so this is the only signal
|
|
355
|
+
// that the implementation itself died and the task must not be checked off.
|
|
356
|
+
let implError;
|
|
329
357
|
const result = await ctx.newSession({
|
|
330
358
|
withSession: async (newCtx) => {
|
|
331
359
|
freshCtx = newCtx;
|
|
@@ -335,6 +363,10 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
335
363
|
if (opts.waitForImplementation) {
|
|
336
364
|
await newCtx.waitForIdle();
|
|
337
365
|
interrupted = await steerUntilDone(newCtx, opts.promptSteer);
|
|
366
|
+
// A user-declined steer (interrupted) is its own paused
|
|
367
|
+
// path; otherwise inspect how the turn actually ended.
|
|
368
|
+
if (!interrupted)
|
|
369
|
+
implError = implementationError(newCtx);
|
|
338
370
|
}
|
|
339
371
|
}, opts.spawnFn, opts.onStart);
|
|
340
372
|
await runner.run();
|
|
@@ -360,13 +392,18 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
360
392
|
ok = false;
|
|
361
393
|
}
|
|
362
394
|
}
|
|
395
|
+
// The file reads `completed` from spec-handoff even when the implementation
|
|
396
|
+
// turn then died. Demote to a failure so /task-auto stops here (leaving the
|
|
397
|
+
// entry unchecked and resumable) instead of committing and advancing.
|
|
398
|
+
if (ok && implError)
|
|
399
|
+
ok = false;
|
|
363
400
|
if (opts.notifyFinish) {
|
|
364
401
|
// One push per top-level /task or /task-resume, on any terminal end. The
|
|
365
402
|
// file state is the source of truth: 'completed' on success, 'failed' /
|
|
366
403
|
// 'cancelled' otherwise; an unreadable/absent file falls back to 'ended'.
|
|
367
404
|
void pushNotify('Task finished', `${taskId || 'Task'} ${state ?? 'ended'}.`, 'pi-end').catch(() => { });
|
|
368
405
|
}
|
|
369
|
-
return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted };
|
|
406
|
+
return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted, reason: implError };
|
|
370
407
|
}
|
|
371
408
|
// ─── Command handlers ────────────────────────────────────────────────────────
|
|
372
409
|
async function handleTask(args, ctx) {
|
package/dist/task/phases.js
CHANGED
|
@@ -179,6 +179,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
179
179
|
// mirrors workerSpecs order — the loop above pushes in sequence).
|
|
180
180
|
const sections = workerSpecs.map((spec, i) => ({ name: spec.section, result: workerResults[i] }));
|
|
181
181
|
for (const { name, result } of sections) {
|
|
182
|
+
// Loop/timeout exhaustion is checked before the generic exit-code branch:
|
|
183
|
+
// a loop-kill arrives as a SIGTERM (exit 143) AND sometimes as a clean
|
|
184
|
+
// exit 0 with partial text, so keying off exitCode alone would either give
|
|
185
|
+
// a useless "exit 143" message or silently accept truncated output. The
|
|
186
|
+
// worker already burned its MAX_LOOP_RESTARTS restarts before setting these.
|
|
187
|
+
if (result.loopHit) {
|
|
188
|
+
const argsStr = JSON.stringify(result.loopHit.call.args);
|
|
189
|
+
throw new Error(`Research ${name} worker stuck in a loop — called `
|
|
190
|
+
+ `${result.loopHit.call.name}(${argsStr}) ×${result.loopHit.count} in the last `
|
|
191
|
+
+ `${result.loopHit.windowSize} calls and still looped after restarts`);
|
|
192
|
+
}
|
|
193
|
+
if (result.timedOut) {
|
|
194
|
+
throw new Error(`Research ${name} worker timed out after restarts`);
|
|
195
|
+
}
|
|
182
196
|
if (result.exitCode !== 0) {
|
|
183
197
|
throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
|
|
184
198
|
}
|
package/dist/task/prompts.js
CHANGED
|
@@ -54,6 +54,7 @@ Rules:
|
|
|
54
54
|
- Fix spelling and grammar; output in English regardless of input language.
|
|
55
55
|
- Preserve every concrete identifier verbatim (paths, function names, ports, env vars, file:line refs).
|
|
56
56
|
- Do not invent requirements not implied by the input.
|
|
57
|
+
- If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
|
|
57
58
|
- Do not output any preamble, commentary, or markdown headings beyond the four sections above.
|
|
58
59
|
|
|
59
60
|
Task: ${raw}`;
|
|
@@ -73,6 +74,8 @@ const RESEARCH_FILES_PROMPT = (refined) => `You are doing targeted research for
|
|
|
73
74
|
|
|
74
75
|
FILES owns paths. APIS owns symbols. Do not omit a path because it "feels like config" — if the agent will touch or read it, list it here.
|
|
75
76
|
|
|
77
|
+
If the task references a design/spec document (an @-path or a named spec file), OPEN it first and list it — it is the contract for this task, and the schema, names, and endpoints it defines are authoritative over any paraphrase in the task title.
|
|
78
|
+
|
|
76
79
|
When a task operates across a whole directory tree (e.g. lint, typecheck, build, format, test-all), list the root directory entry (\`src/ one-line purpose\`) instead of enumerating every file under it. Enumerate individual files only when they need to be singled out — modified specifically, called out by name in the task, or distinct from their siblings in some material way.
|
|
77
80
|
|
|
78
81
|
RELEVANCE — read carefully: list ONLY paths this specific task touches — the files the agent will read, edit, or must be aware of to complete THIS task. Do NOT inventory the whole repo or list files just because they exist. A file the agent will never open does not belong here. Aim for the smallest sufficient set: include every path the task genuinely reaches and nothing more. There is no fixed limit — a broad task may need many entries, a narrow one only a few. Right-size to the task, not to a number, and collapse directories to their root entry where the task spans a whole tree.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SpawnFn } from '../shared/child-process.js';
|
|
2
|
+
import { type LoopHit } from '../task/loop-detector.js';
|
|
2
3
|
export interface RunWorkerInput {
|
|
3
4
|
prompt: string;
|
|
4
5
|
cwd: string;
|
|
@@ -10,6 +11,8 @@ export interface RunWorkerInput {
|
|
|
10
11
|
extensions?: string[];
|
|
11
12
|
/** Called for each tool execution start and text-writing event inside the worker. */
|
|
12
13
|
onLine?: (line: string) => void;
|
|
14
|
+
/** Per-worker wall-clock timeout in ms. Defaults to RESEARCH_WORKER_TIMEOUT_MS. */
|
|
15
|
+
timeoutMs?: number;
|
|
13
16
|
}
|
|
14
17
|
export interface RunWorkerResult {
|
|
15
18
|
text: string;
|
|
@@ -34,5 +37,18 @@ export interface RunWorkerResult {
|
|
|
34
37
|
* failure rather than trusting the returned text.
|
|
35
38
|
*/
|
|
36
39
|
leakedToolCall?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Set when the worker was killed for looping (the same tool call repeated
|
|
42
|
+
* past threshold) and still looped after exhausting MAX_LOOP_RESTARTS
|
|
43
|
+
* restarts. The returned text is truncated mid-stream — the caller must treat
|
|
44
|
+
* this as a failure, not trust it.
|
|
45
|
+
*/
|
|
46
|
+
loopHit?: LoopHit;
|
|
47
|
+
/**
|
|
48
|
+
* Set when the worker's final attempt hit the per-worker wall-clock timeout
|
|
49
|
+
* after exhausting its restart budget. Like loopHit, the text is partial and
|
|
50
|
+
* the caller must treat it as a failure.
|
|
51
|
+
*/
|
|
52
|
+
timedOut?: boolean;
|
|
37
53
|
}
|
|
38
54
|
export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
2
2
|
import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
|
|
3
3
|
import { LoopDetector } from '../task/loop-detector.js';
|
|
4
|
+
import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
|
|
4
5
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
5
6
|
// `--mode json` makes pi emit structured events as they happen instead of
|
|
6
7
|
// buffering the assistant text and flushing on exit. That matters for the
|
|
@@ -10,33 +11,118 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
|
|
|
10
11
|
// model starts producing — making waitMs the real queue/cold-start cost and
|
|
11
12
|
// workMs the real generation+tool-call cost.
|
|
12
13
|
const DEFAULT_TOOLS = 'read,grep,find,ls';
|
|
14
|
+
/**
|
|
15
|
+
* Hard wall-clock bound on a single research worker run (one spawn). The
|
|
16
|
+
* exact-match LoopDetector only catches *identical* repeated tool calls; a model
|
|
17
|
+
* that thrashes with slightly-varied calls (different grep patterns each time)
|
|
18
|
+
* slips past it and would otherwise run unbounded. This is the backstop for that
|
|
19
|
+
* case: after this long with no clean exit, abort and restart with a hint. Sized
|
|
20
|
+
* well above a healthy worker's observed runtime (~25-130s on the local backend)
|
|
21
|
+
* so it never trips a legitimately slow run.
|
|
22
|
+
*/
|
|
23
|
+
const RESEARCH_WORKER_TIMEOUT_MS = 240_000;
|
|
24
|
+
/** Restart hint after a wall-clock timeout — distinct from the loop hint. */
|
|
25
|
+
const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
|
|
26
|
+
+ 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
|
|
27
|
+
+ 'then write your answer now. Do not re-explore ground you have already covered.]';
|
|
28
|
+
/**
|
|
29
|
+
* Combine an external abort signal with an internal wall-clock timeout into one
|
|
30
|
+
* signal, while keeping the two causes distinguishable: `timedOut()` is true
|
|
31
|
+
* only when the timer fired (not when the external signal aborted), so the caller
|
|
32
|
+
* can restart on a timeout but not on a user cancel.
|
|
33
|
+
*/
|
|
34
|
+
function workerTimeout(external, ms) {
|
|
35
|
+
const ctrl = new AbortController();
|
|
36
|
+
let timedOut = false;
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
timedOut = true;
|
|
39
|
+
ctrl.abort();
|
|
40
|
+
}, ms);
|
|
41
|
+
const onExternal = () => ctrl.abort();
|
|
42
|
+
if (external) {
|
|
43
|
+
if (external.aborted)
|
|
44
|
+
ctrl.abort();
|
|
45
|
+
else
|
|
46
|
+
external.addEventListener('abort', onExternal, { once: true });
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
signal: ctrl.signal,
|
|
50
|
+
timedOut: () => timedOut,
|
|
51
|
+
cleanup: () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
external?.removeEventListener('abort', onExternal);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
13
57
|
export async function runWorker(input) {
|
|
14
58
|
const tools = input.tools ?? DEFAULT_TOOLS;
|
|
15
59
|
const extensionArgs = (input.extensions ?? []).flatMap(e => ['-e', e]);
|
|
16
60
|
const baseArgs = [...extensionArgs, ...CHILD_BASE_ARGS, '--mode', 'json', '--tools', tools];
|
|
61
|
+
const timeoutMs = input.timeoutMs ?? RESEARCH_WORKER_TIMEOUT_MS;
|
|
17
62
|
let hint = null;
|
|
18
|
-
|
|
63
|
+
// Loop-kill and timeout share one restart budget, mirroring
|
|
64
|
+
// runPhaseWithLoopGuard: a runaway worker gets re-spawned with a corrective
|
|
65
|
+
// hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
|
|
66
|
+
// keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
|
|
67
|
+
let restarts = 0;
|
|
68
|
+
let leakRetries = 0;
|
|
69
|
+
for (;;) {
|
|
19
70
|
const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
|
|
20
71
|
const invocation = getPiInvocation([...baseArgs, prompt]);
|
|
21
72
|
const tStart = Date.now();
|
|
22
73
|
let tFirstByte = null;
|
|
23
|
-
const loopDetector = new LoopDetector(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
74
|
+
const loopDetector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
75
|
+
// Capture the hit the detector reports (it also returns it to the unified
|
|
76
|
+
// runner, which kills the child on a hit). Without capturing it here the
|
|
77
|
+
// SIGTERM that kill produces would surface as a bare non-zero exit the
|
|
78
|
+
// caller couldn't distinguish from a crash.
|
|
79
|
+
let loopHit;
|
|
80
|
+
const timeout = workerTimeout(input.signal, timeoutMs);
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = await runChildDefault(invocation, input.cwd, timeout.signal, {
|
|
84
|
+
mode: 'json-events',
|
|
85
|
+
onFirstByte: () => (tFirstByte = Date.now()),
|
|
86
|
+
onToolCall: call => {
|
|
87
|
+
const hit = loopDetector.record(call);
|
|
88
|
+
if (hit && !loopHit)
|
|
89
|
+
loopHit = hit;
|
|
90
|
+
return hit;
|
|
91
|
+
},
|
|
92
|
+
onLine: input.onLine
|
|
93
|
+
}, input.spawn);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
timeout.cleanup();
|
|
97
|
+
}
|
|
30
98
|
const tEnd = Date.now();
|
|
31
99
|
const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
|
|
32
100
|
const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
|
|
33
101
|
const text = result.text ?? '';
|
|
102
|
+
const timedOut = timeout.timedOut();
|
|
103
|
+
// A loop-kill gets the same restart-with-hint treatment every other phase
|
|
104
|
+
// already gets (runPhaseWithLoopGuard) — name the offending call so the
|
|
105
|
+
// re-spawn avoids it. Bounded by the shared restart budget.
|
|
106
|
+
if (loopHit && restarts < MAX_LOOP_RESTARTS) {
|
|
107
|
+
hint = formatLoopHint(loopHit);
|
|
108
|
+
restarts++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// A wall-clock timeout (the backstop for varied thrash the exact-match
|
|
112
|
+
// detector misses) is also restartable, sharing the same budget. Skip when
|
|
113
|
+
// a loop also tripped — the loop hint above is more specific.
|
|
114
|
+
if (timedOut && !loopHit && restarts < MAX_LOOP_RESTARTS) {
|
|
115
|
+
hint = WORKER_TIMEOUT_HINT;
|
|
116
|
+
restarts++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
34
119
|
// Only treat output as a leak on a clean, complete run — a non-zero exit
|
|
35
120
|
// or abort yields partial text the caller already handles, and detecting
|
|
36
121
|
// there would just mislabel the real failure.
|
|
37
122
|
const leaked = result.exitCode === 0 && !result.aborted ? detectLeakedToolCall(text) : null;
|
|
38
|
-
if (leaked &&
|
|
123
|
+
if (leaked && leakRetries < MAX_LEAK_RETRIES) {
|
|
39
124
|
hint = leakedToolCallHint(leaked);
|
|
125
|
+
leakRetries++;
|
|
40
126
|
continue;
|
|
41
127
|
}
|
|
42
128
|
return {
|
|
@@ -46,7 +132,9 @@ export async function runWorker(input) {
|
|
|
46
132
|
aborted: result.aborted,
|
|
47
133
|
waitMs,
|
|
48
134
|
workMs,
|
|
49
|
-
...(leaked ? { leakedToolCall: leaked } : {})
|
|
135
|
+
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
136
|
+
...(loopHit ? { loopHit } : {}),
|
|
137
|
+
...(timedOut ? { timedOut: true } : {})
|
|
50
138
|
};
|
|
51
139
|
}
|
|
52
140
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.17",
|
|
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",
|