@mjasnikovs/pi-task 0.18.37 → 0.18.39

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.
@@ -12,6 +12,8 @@ import { childBaseArgs } from '../shared/child-extensions.js';
12
12
  import { LoopDetector } from './loop-detector.js';
13
13
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
14
14
  import { readSection, setTaskSection } from './task-io.js';
15
+ import { streamStallCause } from '../shared/stream-watchdog.js';
16
+ import { getConfig } from '../config/config.js';
15
17
  // ─── Loop detection constants ────────────────────────────────────────────────
16
18
  // Defined here (not in phases.ts) to avoid a circular dependency:
17
19
  // phases.ts → child-runner.ts → phases.ts
@@ -77,6 +79,11 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
77
79
  let loopHit;
78
80
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
79
81
  mode: 'json-events',
82
+ // A hung model stream reports nothing at all, so without this the
83
+ // phase child waits forever (mx5 run 14: ~2.9h of dead air). The kill
84
+ // is reported below as a connection-class cause, which routes it into
85
+ // the retry/backoff path this file already has for a LOUD disconnect.
86
+ streamInactivityMs: getConfig().streamInactivityMs,
80
87
  onLine,
81
88
  onContextUsage,
82
89
  onToolCall: call => {
@@ -95,12 +102,21 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
95
102
  // fails with the unhelpful "X child produced no output" — the raw
96
103
  // stdout/stderr that might contain the real error is discarded.
97
104
  const text = result.text || result.stdout.trim();
105
+ // The stream watchdog's kill leaves no provider error to report (that is the
106
+ // whole failure mode), so name it here rather than letting it surface as the
107
+ // meaningless "produced no output". Never overwrite a real reported cause.
108
+ const modelError = result.modelError
109
+ ?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
98
110
  return {
99
111
  text,
100
- exitCode: result.exitCode,
112
+ // WE killed this child, so its exit status describes our own SIGTERM, not
113
+ // the child's verdict. Report 0 and let `modelError` carry the cause —
114
+ // otherwise the wrappers' `exitCode !== 0` guard throws a bare "child
115
+ // failed" before the connection-error retry ever gets to look.
116
+ exitCode: result.streamStalled ? 0 : result.exitCode,
101
117
  stderr: result.stderr.trim(),
102
118
  loopHit,
103
- modelError: result.modelError,
119
+ modelError,
104
120
  // A tool call the model wrote as text (wrong dialect) never executed and
105
121
  // sailed past the structured-event guards above; flag it so the wrappers
106
122
  // can re-prompt instead of accepting the unexecuted call. Only meaningful
@@ -391,6 +391,11 @@ export function buildGateDeps(params) {
391
391
  // ceiling the main session uses, so one /task-config knob
392
392
  // covers implementation and gates alike.
393
393
  commandTimeoutMs: getConfig().requestTimeoutMs,
394
+ // Same reasoning one level up: a gate child with no
395
+ // wall-clock cap also needs the HUNG-STREAM bound, which
396
+ // the probe-based stall guard structurally cannot supply
397
+ // (a healthy endpoint reads as proof of life).
398
+ streamInactivityMs: getConfig().streamInactivityMs,
394
399
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
395
400
  onLine: line => {
396
401
  lastLine = line;
@@ -538,6 +543,9 @@ export function buildGateDeps(params) {
538
543
  // bash — wired anyway so a future tool grant can't quietly
539
544
  // re-open the hole.
540
545
  commandTimeoutMs: getConfig().requestTimeoutMs,
546
+ // Unbounded wall clock here too — the hung-stream
547
+ // bound is the only thing that ends a dead stream.
548
+ streamInactivityMs: getConfig().streamInactivityMs,
541
549
  // Exact-match loop guard only: pathThreshold Infinity
542
550
  // disables the path-revisit heuristic, so revisiting one
543
551
  // file (which IS this pass's job) never trips — only a
@@ -0,0 +1,32 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /**
3
+ * MAIN-SESSION adapter for the model-stream watchdog.
4
+ *
5
+ * WHY (mx5 run 14): three implementation turns died mid-turn — the session jsonl's
6
+ * last record is an ordinary assistant message, then silence forever, while the
7
+ * model container stayed Up(healthy). No error is ever thrown for this shape, so
8
+ * the connection-error retry (which needs a reported ModelError) cannot fire and
9
+ * the command watchdog, which only covers tool executions, never arms. The run sat
10
+ * dead for ~2.9h across the three until a human restarted it.
11
+ *
12
+ * HOW: pi's extension events ARE the stream. Any of them — a token delta, a
13
+ * thinking delta, a tool-call delta, the provider's response headers — resets the
14
+ * idle clock; only total silence for the configured window fires. On fire the turn
15
+ * is aborted and a follow-up user turn tells the model to CONTINUE from the
16
+ * transcript (its completed tool calls and results are already recorded, so a
17
+ * blind re-send would re-run them).
18
+ *
19
+ * ONE ABORT CHANNEL: the fire path goes through the command watchdog's existing
20
+ * {@link noteWatchdogAbort} flag and its WATCHDOG_CANCEL_MARKER, so
21
+ * steerUntilDone's already-fixed abort/steer race (b543d15) covers this watchdog
22
+ * too instead of racing a second, parallel abort mechanism.
23
+ *
24
+ * SUSPENDED DURING TOOLS: while a tool executes the model stream is legitimately
25
+ * idle — a 12-minute build emits nothing. That window belongs to the command
26
+ * watchdog (requestTimeoutMs); this one pauses between tool_execution_start and
27
+ * tool_execution_end so the two can never double-fire on the same silence.
28
+ *
29
+ * SCOPE: main session only. Children run `--no-extensions`, so their equivalent
30
+ * guard lives in runChild (shared/child-process.ts) and shares the same machine.
31
+ */
32
+ export declare function registerStreamWatchdog(pi: ExtensionAPI): void;
@@ -0,0 +1,90 @@
1
+ import { getConfig } from '../config/config.js';
2
+ import { realStreamTimerDeps, StreamWatchdog, streamStallReminder } from '../shared/stream-watchdog.js';
3
+ import { noteWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
4
+ /**
5
+ * MAIN-SESSION adapter for the model-stream watchdog.
6
+ *
7
+ * WHY (mx5 run 14): three implementation turns died mid-turn — the session jsonl's
8
+ * last record is an ordinary assistant message, then silence forever, while the
9
+ * model container stayed Up(healthy). No error is ever thrown for this shape, so
10
+ * the connection-error retry (which needs a reported ModelError) cannot fire and
11
+ * the command watchdog, which only covers tool executions, never arms. The run sat
12
+ * dead for ~2.9h across the three until a human restarted it.
13
+ *
14
+ * HOW: pi's extension events ARE the stream. Any of them — a token delta, a
15
+ * thinking delta, a tool-call delta, the provider's response headers — resets the
16
+ * idle clock; only total silence for the configured window fires. On fire the turn
17
+ * is aborted and a follow-up user turn tells the model to CONTINUE from the
18
+ * transcript (its completed tool calls and results are already recorded, so a
19
+ * blind re-send would re-run them).
20
+ *
21
+ * ONE ABORT CHANNEL: the fire path goes through the command watchdog's existing
22
+ * {@link noteWatchdogAbort} flag and its WATCHDOG_CANCEL_MARKER, so
23
+ * steerUntilDone's already-fixed abort/steer race (b543d15) covers this watchdog
24
+ * too instead of racing a second, parallel abort mechanism.
25
+ *
26
+ * SUSPENDED DURING TOOLS: while a tool executes the model stream is legitimately
27
+ * idle — a 12-minute build emits nothing. That window belongs to the command
28
+ * watchdog (requestTimeoutMs); this one pauses between tool_execution_start and
29
+ * tool_execution_end so the two can never double-fire on the same silence.
30
+ *
31
+ * SCOPE: main session only. Children run `--no-extensions`, so their equivalent
32
+ * guard lives in runChild (shared/child-process.ts) and shares the same machine.
33
+ */
34
+ export function registerStreamWatchdog(pi) {
35
+ // The ctx whose abort() ends the in-flight turn, refreshed on every event so
36
+ // the fire (which happens outside any handler) aborts the CURRENT operation.
37
+ let liveCtx;
38
+ const watchdog = new StreamWatchdog({
39
+ getTimeoutMs: () => getConfig().streamInactivityMs,
40
+ ...realStreamTimerDeps,
41
+ onFire: idleMs => {
42
+ const ctx = liveCtx;
43
+ liveCtx = undefined;
44
+ // Flag BEFORE the abort, exactly as the command watchdog does: the
45
+ // steer loop can otherwise observe the 'aborted' turn first and show
46
+ // a steering prompt to an empty room, wedging an unattended run.
47
+ if (ctx) {
48
+ noteWatchdogAbort();
49
+ ctx.abort();
50
+ }
51
+ pi.sendUserMessage(streamStallReminder(idleMs, WATCHDOG_CANCEL_MARKER), {
52
+ deliverAs: 'followUp'
53
+ });
54
+ }
55
+ });
56
+ // Any event proves the stream is alive. `arm` also (re)starts the machine, so
57
+ // a request that begins after a previous turn ended is watched again without
58
+ // needing a single canonical "request started" event.
59
+ const arm = (ctx) => {
60
+ if (ctx)
61
+ liveCtx = ctx;
62
+ watchdog.start();
63
+ watchdog.note();
64
+ };
65
+ pi.on('before_provider_request', (_e, ctx) => arm(ctx));
66
+ pi.on('after_provider_response', (_e, ctx) => arm(ctx));
67
+ pi.on('turn_start', (_e, ctx) => arm(ctx));
68
+ pi.on('message_start', (_e, ctx) => arm(ctx));
69
+ pi.on('message_update', (_e, ctx) => arm(ctx));
70
+ pi.on('message_end', (_e, ctx) => arm(ctx));
71
+ pi.on('tool_execution_start', (_e, ctx) => {
72
+ liveCtx = ctx;
73
+ watchdog.suspend();
74
+ });
75
+ pi.on('tool_execution_update', (_e, ctx) => {
76
+ liveCtx = ctx;
77
+ });
78
+ pi.on('tool_execution_end', (_e, ctx) => {
79
+ liveCtx = ctx;
80
+ watchdog.resume();
81
+ });
82
+ // Nothing is streaming between agent loops; stop so no timer can fire into an
83
+ // idle session (which would abort nothing and post a reminder to no one).
84
+ const stop = () => {
85
+ watchdog.stop();
86
+ liveCtx = undefined;
87
+ };
88
+ pi.on('agent_end', stop);
89
+ pi.on('session_shutdown', stop);
90
+ }
@@ -74,6 +74,16 @@ export interface RunWorkerInput {
74
74
  afterMs?: number;
75
75
  probe?: () => Promise<boolean>;
76
76
  } | false;
77
+ /**
78
+ * Stream-inactivity ceiling in ms (shared/stream-watchdog.ts). The stall guard
79
+ * above cannot catch a HUNG stream on a HEALTHY backend — it reads a reachable
80
+ * endpoint as proof of life, which is exactly what run 14's three hangs looked
81
+ * like. This one asks nothing of the backend: no output for this long (with
82
+ * tool executions excluded) ⇒ kill and restart the attempt with
83
+ * {@link streamStallHint}, inside the same shared restart budget.
84
+ * 0 / omitted = off.
85
+ */
86
+ streamInactivityMs?: number;
77
87
  }
78
88
  export interface RunWorkerResult {
79
89
  text: string;
@@ -129,6 +139,16 @@ export interface RunWorkerResult {
129
139
  toolName: string;
130
140
  timeoutMs: number;
131
141
  };
142
+ /**
143
+ * Set when the stream watchdog killed the worker's FINAL attempt: the model
144
+ * stream produced nothing for the configured window while no tool was running.
145
+ * Like loopHit/timedOut the text is partial — treat as a failure, and check it
146
+ * BEFORE `aborted` (the kill aborts too), or a hung backend is mislabeled a
147
+ * user cancel.
148
+ */
149
+ streamStalled?: {
150
+ idleMs: number;
151
+ };
132
152
  }
133
153
  /**
134
154
  * The per-command ceiling for attempt N, halving each time a hang recurs.
@@ -6,6 +6,7 @@ import { LoopDetector } from '../task/loop-detector.js';
6
6
  import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
7
7
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
8
8
  import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
9
+ import { streamStallHint } from '../shared/stream-watchdog.js';
9
10
  // `--mode json` makes pi emit structured events as they happen instead of
10
11
  // buffering the assistant text and flushing on exit. That matters for the
11
12
  // wait/work timing split: in text mode the first stdout chunk only arrives at
@@ -207,6 +208,9 @@ export async function runWorker(input) {
207
208
  ?? (() => probeModelEndpoints(discoverModelEndpoints()))
208
209
  }
209
210
  }),
211
+ ...(input.streamInactivityMs ?
212
+ { streamInactivityMs: input.streamInactivityMs }
213
+ : {}),
210
214
  onFirstByte: () => (tFirstByte = Date.now()),
211
215
  onToolCall: call => {
212
216
  cmdWatch?.onStart(call);
@@ -240,6 +244,7 @@ export async function runWorker(input) {
240
244
  const text = result.text ?? '';
241
245
  const timedOut = timeout.timedOut();
242
246
  const commandKill = cmdWatch?.killed();
247
+ const streamStalled = result.streamStalled;
243
248
  // A loop-kill gets the same restart-with-hint treatment every other phase
244
249
  // already gets (runPhaseWithLoopGuard) — name the offending call so the
245
250
  // re-spawn avoids it. Bounded by the shared restart budget.
@@ -265,6 +270,14 @@ export async function runWorker(input) {
265
270
  hangKills++;
266
271
  continue;
267
272
  }
273
+ // A hung model stream is restartable on the same budget. Checked before
274
+ // the wall-clock timeout because it is the more specific diagnosis (and
275
+ // its hint does not blame the model: nothing it did caused the hang).
276
+ if (streamStalled && !loopHit && restarts < MAX_LOOP_RESTARTS) {
277
+ hint = streamStallHint(streamStalled.idleMs);
278
+ restarts++;
279
+ continue;
280
+ }
268
281
  // A wall-clock timeout (the backstop for varied thrash the exact-match
269
282
  // detector misses) is also restartable, sharing the same budget. Skip when
270
283
  // a loop also tripped — the loop hint above is more specific.
@@ -293,6 +306,7 @@ export async function runWorker(input) {
293
306
  ...(loopHit ? { loopHit } : {}),
294
307
  ...(timedOut ? { timedOut: true } : {}),
295
308
  ...(result.stalled ? { stalled: true } : {}),
309
+ ...(streamStalled ? { streamStalled } : {}),
296
310
  ...(commandKill ?
297
311
  {
298
312
  commandTimedOut: {
@@ -5,6 +5,13 @@ import { resolvePackage as defaultResolvePackage } from './docs-resolve.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
6
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
7
7
  import { type SpawnFn } from '../shared/child-process.js';
8
+ /**
9
+ * The package NAME a module specifier belongs to — `hono/client` → `hono`,
10
+ * `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
11
+ * entry's package provenance, so a subpath lookup is matched against package.json's key
12
+ * and invalidated with its package rather than living forever unmatched.
13
+ */
14
+ export declare function packageRootOf(module: string): string;
8
15
  export interface PiWorkerDocsInternals {
9
16
  resolvePackage?: typeof defaultResolvePackage;
10
17
  ensureIndexed?: typeof defaultEnsureIndexed;
@@ -21,6 +21,16 @@ const Params = Type.Object({
21
21
  description: 'What to extract from the docs. The child pi reads ranked chunks and returns ONLY content answering this.'
22
22
  })
23
23
  });
24
+ /**
25
+ * The package NAME a module specifier belongs to — `hono/client` → `hono`,
26
+ * `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
27
+ * entry's package provenance, so a subpath lookup is matched against package.json's key
28
+ * and invalidated with its package rather than living forever unmatched.
29
+ */
30
+ export function packageRootOf(module) {
31
+ const parts = module.trim().split('/');
32
+ return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
33
+ }
24
34
  function pinDetails(pin) {
25
35
  return pin ? { versionSource: pin.source, declaredRange: pin.range } : {};
26
36
  }
@@ -249,6 +259,12 @@ export function registerPiWorkerDocs(pi, internals = {}) {
249
259
  cacheKey: params => params.module === '.' ?
250
260
  null
251
261
  : `${normalizeQuery(params.module)}::${normalizeQuery(params.query)}`,
262
+ // Package provenance for per-entry resume invalidation: a docs digest describes
263
+ // one package at one declared version, so a resume drops it only when THAT
264
+ // package moves — an unrelated install no longer discards it. Package names are
265
+ // matched against package.json verbatim (npm names are case-sensitive), unlike
266
+ // the cache key, which normalises for phrasing collisions.
267
+ cachePkg: params => (params.module === '.' ? undefined : packageRootOf(params.module)),
252
268
  // Only a completed lookup (child exited 0) is a real answer; not-installed,
253
269
  // no-chunks, resolve/cache errors, and aborts omit childExitCode:0 and fall
254
270
  // through to a live retry next time.
@@ -23,31 +23,32 @@ export declare function configureResearchRun(enabled: boolean): string | undefin
23
23
  */
24
24
  export declare function normalizeQuery(s: string): string;
25
25
  /**
26
- * A stable fingerprint of the project's declared dependency surface — the only input a
27
- * cached docs digest actually depends on (a digest summarises a package's INSTALLED
28
- * types/README at a pinned version). Built from package.json's dependency blocks with
29
- * keys sorted, so formatting churn or an unrelated field edit does not invalidate it,
30
- * while any add/remove/version-bump does.
26
+ * The project's declared dependency surface as a name→range map, flattened across every
27
+ * dependency block (a package listed in two blocks resolves to the first range seen, in
28
+ * block order — a real manifest does not disagree with itself, and a disagreement can
29
+ * only make the comparison stricter, i.e. re-fetch).
31
30
  *
32
- * Returns undefined when the manifest is missing or unparseable — the caller then has
33
- * NO positive evidence of freshness and must not reuse. Deliberately NOT the lockfile's
34
- * hash or mtime: a lockfile is rewritten by installs that do not change any resolved
35
- * version, which would defeat reuse for no correctness gain.
31
+ * Returns undefined when the manifest is missing or unparseable: the caller then cannot
32
+ * prove any package's version and must treat every package-scoped entry as unprovable.
33
+ * Deliberately NOT the lockfile: it is rewritten by installs that change no resolved
34
+ * version, which would drop digests for no correctness gain.
36
35
  */
37
- export declare function depsFingerprint(cwd: string): Promise<string | undefined>;
36
+ export declare function depsMap(cwd: string): Promise<Record<string, string> | undefined>;
38
37
  /**
39
- * Resume hook: reuse the interrupted run's cache id when — and only when — the file
40
- * proves it describes the same dependency surface. Returns the id now stamped into the
41
- * environment (reused or fresh), or undefined when caching is off.
38
+ * Resume hook: keep the interrupted run's cache id and PRUNE the entries the manifest
39
+ * has invalidated, rather than discarding the whole cache because anything moved. Returns
40
+ * the id now stamped into the environment (reused or fresh), how many entries survived,
41
+ * and how many were dropped; undefined id when caching is off.
42
42
  *
43
- * Every uncertain path falls through to a fresh id, which merely re-fetches:
44
- * caching disabled, no/corrupt cache file, a file with no fingerprint (written before
45
- * this shipped), an unreadable manifest, or a fingerprint that no longer matches.
43
+ * Falls through to a fresh id only where there is nothing to reuse or nothing to reason
44
+ * with: caching disabled, no/corrupt cache file, or a file predating per-entry package
45
+ * provenance (`pkgv`) whose docs entries cannot be told apart from its search entries.
46
46
  */
47
47
  export declare function resumeResearchRun(cwd: string, enabled: boolean): Promise<{
48
48
  runId: string | undefined;
49
49
  reused: boolean;
50
50
  entries: number;
51
+ dropped: number;
51
52
  }>;
52
53
  /**
53
54
  * Look up a cached result for `key` in the current run. Returns undefined on a miss,
@@ -61,6 +62,13 @@ export declare function lookupResearch(cwd: string, runId: string, key: string):
61
62
  * Store a successful result under `key` for the current run. A file written for a
62
63
  * different run id is discarded and started fresh (first write of a new run drops the
63
64
  * prior run's contents — self-healing per-run isolation without an explicit clear).
65
+ *
66
+ * `pkg` is the npm package the answer is ABOUT, supplied by package-scoped tools (docs).
67
+ * Its declared version is resolved HERE, at store time, so a package installed mid-run
68
+ * is stamped with the version its own digest was taken against — not with whatever the
69
+ * manifest happened to say at the run's first write. That is what makes a later resume
70
+ * able to prune this one entry instead of the whole file.
71
+ *
64
72
  * Best-effort: any failure is swallowed, leaving the caller's live result untouched.
65
73
  */
66
- export declare function storeResearch(cwd: string, runId: string, key: string, text: string, details: unknown): Promise<void>;
74
+ export declare function storeResearch(cwd: string, runId: string, key: string, text: string, details: unknown, pkg?: string): Promise<void>;