@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.
@@ -276,11 +276,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
276
276
  {
277
277
  ...opts,
278
278
  onToolCall: call => {
279
- streamWatch.suspend();
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
- /** True while a TOOL is executing: the model stream is legitimately idle then,
68
- * and that window belongs to the command watchdog, not to this one. Without
69
- * this, a 10-minute build would look identical to a hung stream. */
70
- private suspended;
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 ends. */
78
- suspend(): void;
79
- /** A tool finished — the stream is expected to resume; restart the clock. */
80
- resume(): void;
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
- /** True while a TOOL is executing: the model stream is legitimately idle then,
63
- * and that window belongs to the command watchdog, not to this one. Without
64
- * this, a 10-minute build would look identical to a hung stream. */
65
- suspended = false;
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.suspended = false;
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 ends. */
92
- suspend() {
93
- this.suspended = true;
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
- /** A tool finished — the stream is expected to resume; restart the clock. */
96
- resume() {
97
- this.suspended = false;
98
- this.note();
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.suspended = false;
134
+ this.active.clear();
135
+ this.keyless = 0;
106
136
  }
107
137
  /** @internal Exposed for the poll callback and tests. */
108
138
  check() {
@@ -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>;
@@ -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 (!RESUMABLE_STATES.includes(fm.state))
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
- const candidate = await findResumableAutoDetailed(cwd);
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
- pi.on('tool_execution_start', (_e, ctx) => {
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', (_e, ctx) => {
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.42",
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",