@mjasnikovs/pi-task 0.38.27 → 0.38.29

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.
@@ -4,10 +4,21 @@
4
4
  * the planning and gate children; the single-task widget (TaskRunner) calls it
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
- import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
7
  import type { ContextSnapshot } from '../shared/child-process.js';
9
- /** The parent session's context window, or 0 when the model doesn't expose it. */
10
- export declare function getParentContextWindow(ctx: ExtensionCommandContext): number;
8
+ /**
9
+ * The parent session's context window, or 0 when the model doesn't expose it.
10
+ *
11
+ * The parameter is the STRUCTURAL MINIMUM this reads, not one of pi's context
12
+ * interfaces. It took `ExtensionCommandContext` and immediately cast it away,
13
+ * which is a lie that also excluded the plain `ExtensionContext` a TOOL is
14
+ * handed — the very caller (`workers/pi-worker.ts`) that needs the window to arm
15
+ * the churn rule. Both pi contexts satisfy this shape.
16
+ */
17
+ export declare function getParentContextWindow(ctx: {
18
+ model?: {
19
+ contextWindow?: number;
20
+ };
21
+ }): number;
11
22
  /**
12
23
  * Fold a raw context snapshot into a display snapshot: prefer the child's
13
24
  * own contextWindow, else the last known one, else the parent session's; then
@@ -4,9 +4,17 @@
4
4
  * the planning and gate children; the single-task widget (TaskRunner) calls it
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
- /** The parent session's context window, or 0 when the model doesn't expose it. */
7
+ /**
8
+ * The parent session's context window, or 0 when the model doesn't expose it.
9
+ *
10
+ * The parameter is the STRUCTURAL MINIMUM this reads, not one of pi's context
11
+ * interfaces. It took `ExtensionCommandContext` and immediately cast it away,
12
+ * which is a lie that also excluded the plain `ExtensionContext` a TOOL is
13
+ * handed — the very caller (`workers/pi-worker.ts`) that needs the window to arm
14
+ * the churn rule. Both pi contexts satisfy this shape.
15
+ */
8
16
  export function getParentContextWindow(ctx) {
9
- return (ctx.model?.contextWindow ?? 0);
17
+ return ctx.model?.contextWindow ?? 0;
10
18
  }
11
19
  /**
12
20
  * Fold a raw context snapshot into a display snapshot: prefer the child's
@@ -105,6 +105,7 @@ export function makeGateChild(deps) {
105
105
  // burned two attempts reads exactly like one that ran clean.
106
106
  onRestart: rs => log(`=== ${deps.kind} RESTART (attempt ${rs.attempt} discarded)`
107
107
  + ` reason=${rs.reason} wall=${rs.wallMs}ms`
108
+ + ` discarded=${rs.partialChars}ch`
108
109
  + (rs.detail ? ` — ${rs.detail}` : '')
109
110
  + ' ==='),
110
111
  onLine: line => {
@@ -679,6 +679,7 @@ export async function phaseResearch(deps, refined) {
679
679
  */
680
680
  const drive = (spec, prior) => runResearchWorker(spec, {
681
681
  runWorker: runWorkerFn,
682
+ contextWindow: deps.contextWindow ?? 'unknown',
682
683
  cwd: deps.cwd,
683
684
  taskId: deps.taskId,
684
685
  signal: deps.signal,
@@ -65,6 +65,13 @@ export interface ResearchWorkerSpec {
65
65
  */
66
66
  export interface ResearchWorkerRun {
67
67
  runWorker: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
68
+ /**
69
+ * The parent session's context window, forwarded to every worker child.
70
+ * `PhaseDeps.contextWindow` already carried it and this driver dropped it, so
71
+ * the churn rule was dead for all four workers — see
72
+ * `RunWorkerInput.contextWindow`.
73
+ */
74
+ contextWindow: number | 'unknown';
68
75
  cwd: string;
69
76
  taskId: string;
70
77
  signal: AbortSignal;
@@ -247,6 +247,7 @@ export async function runResearchWorker(spec, run, prior = []) {
247
247
  const runOnce = (extraPreamble) => run.record(spec.label, run.runWorker(spec.label, {
248
248
  prompt: extraPreamble ? `${extraPreamble}\n\n${basePrompt}` : basePrompt,
249
249
  cwd: run.cwd,
250
+ contextWindow: run.contextWindow,
250
251
  signal: run.signal,
251
252
  spawn: run.spawn,
252
253
  // ONE CELL PER WORKER since 2026-08-28. They used to share
@@ -286,6 +287,7 @@ export async function runResearchWorker(spec, run, prior = []) {
286
287
  onRestart: rs => {
287
288
  run.logDebug?.(`${spec.label}: RESTART (attempt ${rs.attempt} discarded)`
288
289
  + ` reason=${rs.reason} wall=${rs.wallMs}ms`
290
+ + ` discarded=${rs.partialChars}ch`
289
291
  + ` wait=${rs.waitMs}ms work=${rs.workMs}ms`
290
292
  + (rs.detail ? ` — ${rs.detail}` : ''));
291
293
  run.onChildOutput?.(`${spec.label}: restart (${rs.reason})`);
@@ -82,12 +82,29 @@ export interface RunWorkerInput {
82
82
  */
83
83
  onContextUsage?: (snapshot: ContextSnapshot) => void;
84
84
  /**
85
- * The worker child's context window in tokens. pi's event stream carries no
86
- * window (issue #16), so a caller that wants a progress bar rather than a
87
- * bare token count has to supply the parent session's — which is the child's
88
- * too, since workers are spawned without `-m`.
85
+ * The worker child's context window in tokens, or `'unknown'` when the
86
+ * caller genuinely has none.
87
+ *
88
+ * REQUIRED, and required for the same reason `profile` below is. This was
89
+ * `contextWindow?: number`, and two of the three production call sites simply
90
+ * did not write it: `pi-worker.ts` and `research-worker.ts`. pi's event
91
+ * stream carries no window (issue #16), so `noteContext` only ever saw 0, and
92
+ * `StallDetector`'s CONTEXT CHURN rule — gated on a positive window
93
+ * (`stall-detector.ts`) — could never fire for the ad-hoc worker or for any
94
+ * of the four research workers. Nothing was red. The optional was the whole
95
+ * defect: a rule that silently does not exist reads exactly like a rule that
96
+ * exists and did not trip.
97
+ *
98
+ * WHY A WORD AND NOT `0` OR `null`. Both of those are what a caller types
99
+ * when it has not thought about the question, and both disarm the rule
100
+ * silently — which is the state this replaces. `'unknown'` cannot be typed by
101
+ * accident, is greppable, and shows up in a diff as a decision.
102
+ *
103
+ * Two consumers read it: the churn rule, and the caller's progress bar, which
104
+ * shows a bare token count without a window. Both degrade exactly as before
105
+ * on `'unknown'`.
89
106
  */
90
- contextWindow?: number;
107
+ contextWindow: number | 'unknown';
91
108
  /**
92
109
  * WHICH KIND of worker child this is — the whole guard policy, in one word.
93
110
  *
@@ -178,6 +195,23 @@ export interface WorkerRestart {
178
195
  workMs: number;
179
196
  /** Reason-specific diagnosis: the looping call, the hung tool, the error text. */
180
197
  detail?: string;
198
+ /**
199
+ * Characters of ANSWER TEXT this attempt had produced at the moment it was
200
+ * thrown away.
201
+ *
202
+ * Recorded whatever `carryForward` says, because the DISCARD is the thing a
203
+ * reader cannot otherwise see. A restart line reported how long an attempt
204
+ * ran and why it died, and never what died with it — so "the guards worked
205
+ * and the run still returned 52 characters" and "the guards worked and the
206
+ * run threw away a finished answer" print identically. Measured on the
207
+ * ad-hoc `pi-worker` corpus: T024 lost an attempt to a dropped model socket
208
+ * at 275s and returned 52 chars over 620s; nothing in the run said whether
209
+ * those 275s held anything.
210
+ *
211
+ * It is an OBSERVATION, not a decision: harvesting into `salvage` is still
212
+ * gated on the profile, and this number changes no behaviour.
213
+ */
214
+ partialChars: number;
181
215
  }
182
216
  export interface RunWorkerResult {
183
217
  text: string;
@@ -20,6 +20,14 @@ import { applyOverride, WORKER_PROFILES } from './worker-profiles.js';
20
20
  // model starts producing — making waitMs the real queue/cold-start cost and
21
21
  // workMs the real generation+tool-call cost.
22
22
  const DEFAULT_TOOLS = 'read,grep,find,ls';
23
+ /**
24
+ * The one place `'unknown'` becomes the 0 both consumers already treat as
25
+ * "no window". Written once so a future reader cannot re-introduce the optional
26
+ * by handling the union at only one of the two sites that read it.
27
+ */
28
+ function contextWindowTokens(cw) {
29
+ return cw === 'unknown' || cw <= 0 ? 0 : cw;
30
+ }
23
31
  /**
24
32
  * Tool calls that can GROUND an APIS claim — i.e. return content a signature or
25
33
  * command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
@@ -500,7 +508,7 @@ export async function runWorker(input) {
500
508
  // Arm the churn rule BEFORE the first tool call. pi's stream carries no
501
509
  // context event (issue #16), so waiting for one leaves the rule
502
510
  // permanently disarmed. The parent knows the window at spawn time.
503
- stallDetector?.noteContext(input.contextWindow ?? 0);
511
+ stallDetector?.noteContext(contextWindowTokens(input.contextWindow));
504
512
  // Capture the hit the detector reports (it also returns it to the unified
505
513
  // runner, which kills the child on a hit). Without capturing it here the
506
514
  // SIGTERM that kill produces would surface as a bare non-zero exit the
@@ -579,8 +587,8 @@ export async function runWorker(input) {
579
587
  stallDetector?.noteContext(snapshot.contextWindow);
580
588
  input.onContextUsage?.(snapshot);
581
589
  },
582
- ...(input.contextWindow && input.contextWindow > 0 ?
583
- { contextWindow: input.contextWindow }
590
+ ...(contextWindowTokens(input.contextWindow) > 0 ?
591
+ { contextWindow: contextWindowTokens(input.contextWindow) }
584
592
  : {})
585
593
  }, input.spawn);
586
594
  }
@@ -601,6 +609,7 @@ export async function runWorker(input) {
601
609
  wallMs: tEnd - tAttemptStart,
602
610
  waitMs,
603
611
  workMs,
612
+ partialChars: text.trim().length,
604
613
  ...(detail ? { detail } : {})
605
614
  };
606
615
  restarts.push(record);
@@ -11,6 +11,7 @@ import { Type } from '@sinclair/typebox';
11
11
  import { getConfig } from '../config/config.js';
12
12
  import { groupThinkingArgs } from '../config/reasoning-args.js';
13
13
  import { runWorker } from './pi-worker-core.js';
14
+ import { getParentContextWindow } from '../task/context-usage.js';
14
15
  import { childFailureReason, formatChildFailure, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
15
16
  const RENDER_PROMPT_MAX = 120;
16
17
  const WorkerParams = Type.Object({
@@ -54,6 +55,11 @@ export function registerPiWorker(pi) {
54
55
  // not policy for the same reason the gate's two ceilings are: the
55
56
  // number is the user's, the decision to arm it is the profile's.
56
57
  policyInputs: { streamInactivityMs: getConfig().streamInactivityMs },
58
+ // The session's own window, handed down. The child is spawned
59
+ // without `-m`, so the parent's model IS the child's model and
60
+ // its window is the honest one. Without this the churn rule
61
+ // cannot fire — see RunWorkerInput.contextWindow.
62
+ contextWindow: getParentContextWindow(ctx) || 'unknown',
57
63
  thinking: groupThinkingArgs('research')
58
64
  });
59
65
  const details = { exitCode: result.exitCode };
@@ -183,12 +183,13 @@ export const WORKER_PROFILES = {
183
183
  + "answer quality a function of the user's hardware: the same prompt on a "
184
184
  + 'slower local model loses its work and degrades, which is the argument '
185
185
  + 'research-fanout-budget.ts already records against every wall-clock lever '
186
- + '("no constant fixes that"). It is worse than that the constant was '
187
- + 'sized against "~25-130s on the local backend", and MEASURED on 37 '
188
- + 'replayed real prompts after only a MODEL swap on the SAME machine: '
189
- + 'median 76s, p90 371s, max 442s, with 14/37 above the 130s it was '
190
- + 'calibrated to and 8/37 (22%) past the cap outright. A bound that has to '
191
- + 'be re-measured whenever the model changes is the wrong bound. '
186
+ + '("no constant fixes that"). THAT ARGUMENT IS WHAT DECIDES THIS, and it '
187
+ + 'needs no measurement. The constant was also already stale: it was sized '
188
+ + 'against "~25-130s on the local backend" its only stated basis, with no '
189
+ + 'entry in magicknumbers.md and replaying 28 real recorded prompts after '
190
+ + 'nothing but a MODEL swap on the SAME machine gave median 56s, p90 160s, '
191
+ + 'max 280s, with 5/28 above the 130s it was calibrated to. A bound that '
192
+ + 'must be re-measured whenever the model changes is the wrong bound. '
192
193
  + "What replaces it is `stream-stall`, armed from the user's own "
193
194
  + '`stuck reply retry` setting: it kills on SILENCE, never on slowness — '
194
195
  + '"one token every 30s is a working local model and must never be killed; '
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.27",
3
+ "version": "0.38.29",
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",