@mjasnikovs/pi-task 0.13.16 → 0.13.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.
- package/dist/task/auto-orchestrator.d.ts +24 -0
- package/dist/task/auto-orchestrator.js +65 -1
- package/dist/task/auto-prompts.js +7 -0
- package/dist/task/child-runner.d.ts +1 -0
- package/dist/task/child-runner.js +1 -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,30 @@ 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 AND any per-task decisions into every
|
|
38
|
+
* decomposed task title. A title is ALL a per-task pipeline ever sees, so both
|
|
39
|
+
* the design doc the feature pointed at and the user's clarification choices have
|
|
40
|
+
* to ride along or they're invisible downstream — this is how an "Implement
|
|
41
|
+
* @design.md" run built a generic `posts` table the spec never mentioned, and how
|
|
42
|
+
* a "do not use vite" clarification got silently overridden by the doc's own
|
|
43
|
+
* vite.config.ts.
|
|
44
|
+
*
|
|
45
|
+
* Precedence is the crux: a clarification is a CORRECTION to a (possibly stale)
|
|
46
|
+
* spec doc, so the decisions clause is marked as overriding the doc, while the doc
|
|
47
|
+
* stays authoritative for everything the decisions don't touch. Decompose scopes
|
|
48
|
+
* each decision to the task(s) it governs, so most titles carry none. No readable
|
|
49
|
+
* refs and no decisions → title unchanged, so a doc-less /task-auto behaves
|
|
50
|
+
* exactly as before.
|
|
51
|
+
*/
|
|
52
|
+
export declare function attachSpecRefs(titles: string[], refs: string[]): string[];
|
|
29
53
|
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
30
54
|
export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
|
|
31
55
|
export declare function requestAutoCancel(): void;
|
|
@@ -51,6 +51,66 @@ 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
|
+
/** A trailing "[decisions: …]" clause decompose may attach to a task line. */
|
|
79
|
+
const DECISIONS_RE = /\s*\[decisions:\s*(.+?)\]\s*$/i;
|
|
80
|
+
/**
|
|
81
|
+
* Thread the feature's spec references AND any per-task decisions into every
|
|
82
|
+
* decomposed task title. A title is ALL a per-task pipeline ever sees, so both
|
|
83
|
+
* the design doc the feature pointed at and the user's clarification choices have
|
|
84
|
+
* to ride along or they're invisible downstream — this is how an "Implement
|
|
85
|
+
* @design.md" run built a generic `posts` table the spec never mentioned, and how
|
|
86
|
+
* a "do not use vite" clarification got silently overridden by the doc's own
|
|
87
|
+
* vite.config.ts.
|
|
88
|
+
*
|
|
89
|
+
* Precedence is the crux: a clarification is a CORRECTION to a (possibly stale)
|
|
90
|
+
* spec doc, so the decisions clause is marked as overriding the doc, while the doc
|
|
91
|
+
* stays authoritative for everything the decisions don't touch. Decompose scopes
|
|
92
|
+
* each decision to the task(s) it governs, so most titles carry none. No readable
|
|
93
|
+
* refs and no decisions → title unchanged, so a doc-less /task-auto behaves
|
|
94
|
+
* exactly as before.
|
|
95
|
+
*/
|
|
96
|
+
export function attachSpecRefs(titles, refs) {
|
|
97
|
+
const list = refs.map(r => '@' + r).join(' ');
|
|
98
|
+
return titles.map(t => {
|
|
99
|
+
if (t.includes('| spec:') || t.includes('| decisions'))
|
|
100
|
+
return t; // already threaded
|
|
101
|
+
const dm = DECISIONS_RE.exec(t);
|
|
102
|
+
const base = dm ? t.slice(0, dm.index).trimEnd() : t;
|
|
103
|
+
const decisions = dm ? dm[1].trim() : '';
|
|
104
|
+
let out = base;
|
|
105
|
+
if (decisions) {
|
|
106
|
+
out += ` | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): ${decisions}`;
|
|
107
|
+
}
|
|
108
|
+
if (refs.length > 0) {
|
|
109
|
+
out += ` | spec: ${list} — otherwise authoritative; read it and follow it over this title wherever they differ`;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
54
114
|
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
55
115
|
export async function planAuto(ctx, cwd, feature, deps) {
|
|
56
116
|
// clarify — sequential & adaptive: ask one question at a time, feeding every
|
|
@@ -138,7 +198,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
138
198
|
const clarifications = answers.join('\n');
|
|
139
199
|
// decompose
|
|
140
200
|
const listRaw = await deps.runChild('auto-decompose', 'read', AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications));
|
|
141
|
-
|
|
201
|
+
// Thread the feature's spec doc(s) into every title so each per-task
|
|
202
|
+
// pipeline — which only ever sees its title — reads the real spec instead of
|
|
203
|
+
// a lossy one-line paraphrase of it.
|
|
204
|
+
const refs = await readableMentions(cwd, feature);
|
|
205
|
+
const titles = attachSpecRefs(parseDecomposeList(listRaw), refs);
|
|
142
206
|
if (titles.length === 0) {
|
|
143
207
|
announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
|
|
144
208
|
return null;
|
|
@@ -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
|
|
@@ -81,4 +82,10 @@ RULES:
|
|
|
81
82
|
- Order tasks so earlier ones unblock later ones (foundations first).
|
|
82
83
|
- Each task should be independently implementable as a single /task run.
|
|
83
84
|
- Prefer a handful of substantial tasks over many trivial ones.
|
|
85
|
+
- When a CLARIFICATION decision governs a task, append it to that task's line as
|
|
86
|
+
"[decisions: <directive>]" — include ONLY the decisions that bear on that task,
|
|
87
|
+
and attach a cross-cutting decision to EACH task it governs. Most tasks carry
|
|
88
|
+
none. These are explicit user choices that may contradict the referenced spec
|
|
89
|
+
doc; phrase them as imperative directives (e.g. "use Bun's built-in bundler, do
|
|
90
|
+
not add vite"). Do NOT invent decisions — only restate ones from CLARIFICATIONS.
|
|
84
91
|
- Output the checkbox list and NOTHING else (no preamble, no numbering).`;
|
|
@@ -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 `
|
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.18",
|
|
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",
|