@mjasnikovs/pi-task 0.18.42 → 0.18.43
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/shared/child-process.js +4 -2
- package/dist/shared/stream-watchdog.d.ts +27 -8
- package/dist/shared/stream-watchdog.js +43 -13
- package/dist/task/auto-io.d.ts +9 -1
- package/dist/task/auto-io.js +9 -2
- package/dist/task/auto-orchestrator.js +10 -2
- package/dist/task/final-gate-progress.js +18 -1
- package/dist/task/stream-watchdog.js +10 -4
- package/package.json +1 -1
|
@@ -276,11 +276,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
276
276
|
{
|
|
277
277
|
...opts,
|
|
278
278
|
onToolCall: call => {
|
|
279
|
-
|
|
279
|
+
// Keyed: a child's tool batch runs in parallel too, so the
|
|
280
|
+
// first result must not un-pause a still-running sibling.
|
|
281
|
+
streamWatch.suspend(call.toolCallId);
|
|
280
282
|
return opts.onToolCall ? opts.onToolCall(call) : null;
|
|
281
283
|
},
|
|
282
284
|
onToolResult: r => {
|
|
283
|
-
streamWatch.resume();
|
|
285
|
+
streamWatch.resume(r.toolCallId);
|
|
284
286
|
opts.onToolResult?.(r);
|
|
285
287
|
}
|
|
286
288
|
}
|
|
@@ -64,20 +64,39 @@ export declare class StreamWatchdog {
|
|
|
64
64
|
private timer;
|
|
65
65
|
private lastEvent;
|
|
66
66
|
private armedMs;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
67
|
+
/**
|
|
68
|
+
* The tool calls currently executing. While ANY is running the model stream is
|
|
69
|
+
* legitimately idle, and that window belongs to the command watchdog, not to
|
|
70
|
+
* this one — without it a 10-minute build looks identical to a hung stream.
|
|
71
|
+
*
|
|
72
|
+
* A SET, not a boolean: pi runs a tool batch in parallel (agent-loop.js
|
|
73
|
+
* `executeToolCallsParallel` emits every tool_execution_start up front, then one
|
|
74
|
+
* end per call as each settles, and answers an immediate call inline while an
|
|
75
|
+
* earlier one is still running). A boolean would be cleared by the FIRST end and
|
|
76
|
+
* leave the still-running sibling — the long build — exposed to a false fire.
|
|
77
|
+
* Same per-toolCallId idiom the command watchdog uses.
|
|
78
|
+
*/
|
|
79
|
+
private readonly active;
|
|
80
|
+
/** Nesting depth for callers that cannot supply an id, counted so an unkeyed
|
|
81
|
+
* pair nests the same way a keyed one does. */
|
|
82
|
+
private keyless;
|
|
71
83
|
private fired;
|
|
84
|
+
private get suspended();
|
|
72
85
|
constructor(deps: StreamWatchdogDeps);
|
|
73
86
|
/** Begin watching a model request. No-op when the watchdog is off or already armed. */
|
|
74
87
|
start(): void;
|
|
75
88
|
/** Any stream event of any kind: resets the idle clock. */
|
|
76
89
|
note(): void;
|
|
77
|
-
/** A tool started executing — pause the idle clock until it
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
/** A tool started executing — pause the idle clock until it (and every sibling
|
|
91
|
+
* still running) ends. `key` is the tool call id where the caller has one. */
|
|
92
|
+
suspend(key?: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* A tool finished. The clock only restarts once the LAST one does — a fast tool
|
|
95
|
+
* settling first says nothing about a sibling that is still running. Unmatched
|
|
96
|
+
* ends (a watchdog armed mid-batch never saw the start) are ignored rather than
|
|
97
|
+
* clearing the whole set.
|
|
98
|
+
*/
|
|
99
|
+
resume(key?: string): void;
|
|
81
100
|
/** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
|
|
82
101
|
stop(): void;
|
|
83
102
|
/** @internal Exposed for the poll callback and tests. */
|
|
@@ -59,11 +59,26 @@ export class StreamWatchdog {
|
|
|
59
59
|
timer;
|
|
60
60
|
lastEvent = 0;
|
|
61
61
|
armedMs = 0;
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The tool calls currently executing. While ANY is running the model stream is
|
|
64
|
+
* legitimately idle, and that window belongs to the command watchdog, not to
|
|
65
|
+
* this one — without it a 10-minute build looks identical to a hung stream.
|
|
66
|
+
*
|
|
67
|
+
* A SET, not a boolean: pi runs a tool batch in parallel (agent-loop.js
|
|
68
|
+
* `executeToolCallsParallel` emits every tool_execution_start up front, then one
|
|
69
|
+
* end per call as each settles, and answers an immediate call inline while an
|
|
70
|
+
* earlier one is still running). A boolean would be cleared by the FIRST end and
|
|
71
|
+
* leave the still-running sibling — the long build — exposed to a false fire.
|
|
72
|
+
* Same per-toolCallId idiom the command watchdog uses.
|
|
73
|
+
*/
|
|
74
|
+
active = new Set();
|
|
75
|
+
/** Nesting depth for callers that cannot supply an id, counted so an unkeyed
|
|
76
|
+
* pair nests the same way a keyed one does. */
|
|
77
|
+
keyless = 0;
|
|
66
78
|
fired = false;
|
|
79
|
+
get suspended() {
|
|
80
|
+
return this.active.size > 0 || this.keyless > 0;
|
|
81
|
+
}
|
|
67
82
|
constructor(deps) {
|
|
68
83
|
this.deps = deps;
|
|
69
84
|
}
|
|
@@ -80,7 +95,8 @@ export class StreamWatchdog {
|
|
|
80
95
|
return;
|
|
81
96
|
this.armedMs = ms;
|
|
82
97
|
this.fired = false;
|
|
83
|
-
this.
|
|
98
|
+
this.active.clear();
|
|
99
|
+
this.keyless = 0;
|
|
84
100
|
this.lastEvent = this.deps.now();
|
|
85
101
|
this.timer = this.deps.schedule(() => this.check(), pollIntervalMs(ms));
|
|
86
102
|
}
|
|
@@ -88,21 +104,35 @@ export class StreamWatchdog {
|
|
|
88
104
|
note() {
|
|
89
105
|
this.lastEvent = this.deps.now();
|
|
90
106
|
}
|
|
91
|
-
/** A tool started executing — pause the idle clock until it
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
/** A tool started executing — pause the idle clock until it (and every sibling
|
|
108
|
+
* still running) ends. `key` is the tool call id where the caller has one. */
|
|
109
|
+
suspend(key) {
|
|
110
|
+
if (key === undefined)
|
|
111
|
+
this.keyless++;
|
|
112
|
+
else
|
|
113
|
+
this.active.add(key);
|
|
94
114
|
}
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
/**
|
|
116
|
+
* A tool finished. The clock only restarts once the LAST one does — a fast tool
|
|
117
|
+
* settling first says nothing about a sibling that is still running. Unmatched
|
|
118
|
+
* ends (a watchdog armed mid-batch never saw the start) are ignored rather than
|
|
119
|
+
* clearing the whole set.
|
|
120
|
+
*/
|
|
121
|
+
resume(key) {
|
|
122
|
+
if (key === undefined)
|
|
123
|
+
this.keyless = Math.max(0, this.keyless - 1);
|
|
124
|
+
else
|
|
125
|
+
this.active.delete(key);
|
|
126
|
+
if (!this.suspended)
|
|
127
|
+
this.note();
|
|
99
128
|
}
|
|
100
129
|
/** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
|
|
101
130
|
stop() {
|
|
102
131
|
if (this.timer !== undefined)
|
|
103
132
|
this.deps.cancel(this.timer);
|
|
104
133
|
this.timer = undefined;
|
|
105
|
-
this.
|
|
134
|
+
this.active.clear();
|
|
135
|
+
this.keyless = 0;
|
|
106
136
|
}
|
|
107
137
|
/** @internal Exposed for the poll callback and tests. */
|
|
108
138
|
check() {
|
package/dist/task/auto-io.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TaskState } from './task-types.js';
|
|
1
2
|
import type { AutoResumeCandidate } from './resume-gap.js';
|
|
2
3
|
export interface TaskEntry {
|
|
3
4
|
index: number;
|
|
@@ -63,7 +64,14 @@ export declare function insertTaskAfter(cwd: string, id: string, afterIndex: num
|
|
|
63
64
|
* Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
|
|
64
65
|
* last-write time the resume banner reports (see resume-gap.ts). Null when there
|
|
65
66
|
* is nothing resumable.
|
|
67
|
+
*
|
|
68
|
+
* `states` narrows which states count as resumable, and the UNATTENDED path passes
|
|
69
|
+
* UNATTENDED_STATES so the search answers the question that path actually asks —
|
|
70
|
+
* "is there an IN-FLIGHT run to pick up?". Selecting the newest human-resumable run
|
|
71
|
+
* and only then refusing it on state let one failed run shadow an in-flight one
|
|
72
|
+
* behind it: the boot hook refused every restart and the in-flight run stayed in
|
|
73
|
+
* exactly the dead air this feature exists to end.
|
|
66
74
|
*/
|
|
67
|
-
export declare function findResumableAutoDetailed(cwd: string): Promise<AutoResumeCandidate | null>;
|
|
75
|
+
export declare function findResumableAutoDetailed(cwd: string, states?: readonly TaskState[]): Promise<AutoResumeCandidate | null>;
|
|
68
76
|
/** Id-only form of {@link findResumableAutoDetailed}. */
|
|
69
77
|
export declare function findResumableAuto(cwd: string): Promise<string | null>;
|
package/dist/task/auto-io.js
CHANGED
|
@@ -198,8 +198,15 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
|
|
|
198
198
|
* Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
|
|
199
199
|
* last-write time the resume banner reports (see resume-gap.ts). Null when there
|
|
200
200
|
* is nothing resumable.
|
|
201
|
+
*
|
|
202
|
+
* `states` narrows which states count as resumable, and the UNATTENDED path passes
|
|
203
|
+
* UNATTENDED_STATES so the search answers the question that path actually asks —
|
|
204
|
+
* "is there an IN-FLIGHT run to pick up?". Selecting the newest human-resumable run
|
|
205
|
+
* and only then refusing it on state let one failed run shadow an in-flight one
|
|
206
|
+
* behind it: the boot hook refused every restart and the in-flight run stayed in
|
|
207
|
+
* exactly the dead air this feature exists to end.
|
|
201
208
|
*/
|
|
202
|
-
export async function findResumableAutoDetailed(cwd) {
|
|
209
|
+
export async function findResumableAutoDetailed(cwd, states = RESUMABLE_STATES) {
|
|
203
210
|
await ensureTasksDir(cwd);
|
|
204
211
|
const entries = await fsp.readdir(tasksDir(cwd));
|
|
205
212
|
const candidates = [];
|
|
@@ -212,7 +219,7 @@ export async function findResumableAutoDetailed(cwd) {
|
|
|
212
219
|
const fm = parseFrontMatter(raw);
|
|
213
220
|
if (!fm)
|
|
214
221
|
continue;
|
|
215
|
-
if (!
|
|
222
|
+
if (!states.includes(fm.state))
|
|
216
223
|
continue;
|
|
217
224
|
const st = await fsp.stat(path.join(tasksDir(cwd), f));
|
|
218
225
|
candidates.push({ id: m[1], state: fm.state, lastWriteMs: st.mtimeMs });
|
|
@@ -15,7 +15,7 @@ import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT }
|
|
|
15
15
|
import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
|
|
16
16
|
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
17
17
|
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
|
|
18
|
-
import { decideResume } from './resume-gap.js';
|
|
18
|
+
import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
|
|
19
19
|
import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
|
|
20
20
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
21
21
|
import { readTextFile } from '../shared/fs-text.js';
|
|
@@ -1600,7 +1600,15 @@ async function handleTaskAutoResume(args, ctx) {
|
|
|
1600
1600
|
// `--unattended` is the boot-hook path: no human decided to continue this
|
|
1601
1601
|
// run, so it resumes in-flight states only and refuses the rest by name.
|
|
1602
1602
|
const unattended = /(^|\s)--unattended(\s|$)/.test(args);
|
|
1603
|
-
|
|
1603
|
+
// Unattended asks a narrower question — "is there an IN-FLIGHT run?" — so it
|
|
1604
|
+
// searches those states directly. Picking the newest human-resumable run and
|
|
1605
|
+
// refusing it on state let a failed run shadow an in-flight one behind it.
|
|
1606
|
+
// With nothing in flight, fall back to the newest resumable run so the refusal
|
|
1607
|
+
// still names it instead of claiming there is nothing here.
|
|
1608
|
+
const eligible = unattended ?
|
|
1609
|
+
await findResumableAutoDetailed(cwd, UNATTENDED_STATES)
|
|
1610
|
+
: await findResumableAutoDetailed(cwd);
|
|
1611
|
+
const candidate = eligible ?? (unattended ? await findResumableAutoDetailed(cwd) : null);
|
|
1604
1612
|
const decision = decideResume(candidate, Date.now(), unattended);
|
|
1605
1613
|
ctx.ui.notify(decision.banner, decision.level);
|
|
1606
1614
|
// An unattended refusal happens with nobody watching the terminal — the
|
|
@@ -51,12 +51,28 @@ const VOLATILE = [
|
|
|
51
51
|
[/\bpids?\s*[:=]?\s*\d+/g, '<pid>'],
|
|
52
52
|
[/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]):\d+/g, '<addr>'],
|
|
53
53
|
[/\bport\s*[:=]?\s*\d{2,5}\b/g, '<port>'],
|
|
54
|
-
[/:\d{2,5}\b/g, ':<port>'],
|
|
55
54
|
// Long hex / uuid-ish ids (sha, container id, request id).
|
|
56
55
|
[/\b0x[0-9a-f]+\b/g, '<hex>'],
|
|
57
56
|
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, '<uuid>'],
|
|
58
57
|
[/\b[0-9a-f]{7,40}\b/g, '<hex>']
|
|
59
58
|
];
|
|
59
|
+
/**
|
|
60
|
+
* A bare `:NNNN` port, which the localhost/`port N` rules above do not reach (a
|
|
61
|
+
* failure that just says "no listener on :3000").
|
|
62
|
+
*
|
|
63
|
+
* NOT applied when the colon follows a SOURCE LOCATION — `src/db.ts:41`,
|
|
64
|
+
* `Cart.tsx:88:12`. A failure detail embeds the failing command's output tail
|
|
65
|
+
* verbatim (final-gate.ts `r.tail`), and tsc/eslint/bun-test tails are mostly
|
|
66
|
+
* file:line. Collapsing those made two DIFFERENT defects in one file — the second
|
|
67
|
+
* uncovered by fixing the first — compare equal, which reads as non-progress and
|
|
68
|
+
* demotes a genuinely fixable check to UNOBSERVED debt. A moved error is progress.
|
|
69
|
+
*/
|
|
70
|
+
const BARE_PORT = /(\S*?):(\d{2,5})\b/g;
|
|
71
|
+
/** A path (has a separator) or a filename with an extension ⇒ a source location. */
|
|
72
|
+
const SOURCE_LOCATION = /[/\\]|\.[a-z][a-z0-9]{0,4}$/;
|
|
73
|
+
function collapseBarePorts(s) {
|
|
74
|
+
return s.replace(BARE_PORT, (whole, prefix) => SOURCE_LOCATION.test(prefix) ? whole : `${prefix}:<port>`);
|
|
75
|
+
}
|
|
60
76
|
/**
|
|
61
77
|
* Comparison key for a gate failure entry: lowercased, volatile substrings erased,
|
|
62
78
|
* whitespace collapsed. Two entries with the same key are "the same failure" for
|
|
@@ -66,6 +82,7 @@ export function normalizeFailureDetail(detail) {
|
|
|
66
82
|
let s = detail.toLowerCase();
|
|
67
83
|
for (const [re, repl] of VOLATILE)
|
|
68
84
|
s = s.replace(re, repl);
|
|
85
|
+
s = collapseBarePorts(s);
|
|
69
86
|
return s.replace(/\s+/g, ' ').trim();
|
|
70
87
|
}
|
|
71
88
|
/** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
|
|
@@ -68,16 +68,22 @@ export function registerStreamWatchdog(pi) {
|
|
|
68
68
|
pi.on('message_start', (_e, ctx) => arm(ctx));
|
|
69
69
|
pi.on('message_update', (_e, ctx) => arm(ctx));
|
|
70
70
|
pi.on('message_end', (_e, ctx) => arm(ctx));
|
|
71
|
-
|
|
71
|
+
// Keyed by toolCallId: a tool BATCH runs in parallel, so the clock must stay
|
|
72
|
+
// paused until the last call settles, not the first (see StreamWatchdog.resume).
|
|
73
|
+
const callId = (e) => {
|
|
74
|
+
const id = e?.toolCallId;
|
|
75
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
76
|
+
};
|
|
77
|
+
pi.on('tool_execution_start', (e, ctx) => {
|
|
72
78
|
liveCtx = ctx;
|
|
73
|
-
watchdog.suspend();
|
|
79
|
+
watchdog.suspend(callId(e));
|
|
74
80
|
});
|
|
75
81
|
pi.on('tool_execution_update', (_e, ctx) => {
|
|
76
82
|
liveCtx = ctx;
|
|
77
83
|
});
|
|
78
|
-
pi.on('tool_execution_end', (
|
|
84
|
+
pi.on('tool_execution_end', (e, ctx) => {
|
|
79
85
|
liveCtx = ctx;
|
|
80
|
-
watchdog.resume();
|
|
86
|
+
watchdog.resume(callId(e));
|
|
81
87
|
});
|
|
82
88
|
// Nothing is streaming between agent loops; stop so no timer can fire into an
|
|
83
89
|
// idle session (which would abort nothing and post a reminder to no one).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.43",
|
|
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",
|