@mjasnikovs/pi-task 0.38.21 → 0.38.22

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.
@@ -148,7 +148,40 @@ export const DEFAULT_REASONING_TABLE = {
148
148
  // so the `off` arm decodes on sampling tuned for the `on` arm. That is the
149
149
  // regime this machine really runs pi-task in, so the result is
150
150
  // ecologically valid — it is NOT a clean comparison.
151
- research: 'off',
151
+ //
152
+ // OVERRIDDEN TO `medium` BY USER DECISION, 2026-08-27. THIS IS NOT A NEW
153
+ // MEASUREMENT — the trials above are unchanged and still say the arms tied.
154
+ // What changed is the prior. Rung 3 says "no reason found to pay for
155
+ // thinking"; it does not say thinking is worthless, and at n=12 an absent
156
+ // effect and an undetected one look identical.
157
+ //
158
+ // WHAT THE SAME LEDGER SAYS ABOUT THE CLOCK, re-read for this decision:
159
+ // medium is the FASTER arm on the mean, 62.9s vs 105.6s over the 12 paired
160
+ // stimuli (median 51.7s vs 87.7s), and it is faster on 6 of 12 by 1-265s
161
+ // while slower on 6 by 0-43s. The mean gap is entirely `off`'s TAIL: off's
162
+ // two slowest trials are 289s and 367s, medium's slowest is 131s. So the
163
+ // typical trial is a coin flip worth under a minute and the difference the
164
+ // arms actually have is in the tail.
165
+ //
166
+ // AND THE TAIL IS THE PATHOLOGY THIS CHANGE IS AIMED AT. mx5-n 2026-08-27:
167
+ // 40.7 of the research phase's 81.4 wall-clock minutes were thrown away on
168
+ // restarts, all of them in `worker:tooling` and `worker:context` — `files`
169
+ // and `apis` never restarted once. The worst was 550 tool calls over 20
170
+ // distinct files in 20 minutes for 25s of useful work.
171
+ //
172
+ // THE HONEST LIMIT OF THIS DECISION: no axis in
173
+ // scripts/live-reasoning-group-ab.ts scores tool-call count or repetition —
174
+ // the Trial record has no such field and RunWorkerResult exposes no count —
175
+ // so the ledger CANNOT say whether thinking prevents the wander. It was
176
+ // never asked. And the ledger's stimuli are the FILES worker, not the two
177
+ // that looped. Anyone flipping this back should note that the evidence for
178
+ // `off` is equally silent on the question.
179
+ //
180
+ // The structural fix for the wander shipped separately and does not depend
181
+ // on this cell: StallDetector is now wired into runWorker (it was in phase
182
+ // children only), so a rotation is killed ~8 calls into its second lap
183
+ // rather than at the 20-minute ceiling.
184
+ research: 'medium',
152
185
  // NOT MEASURED. DECIDED BY PRIOR, 2026-08-27 — the same prior that carries
153
186
  // every rung-3 cell in this table: thinking that buys nothing measurable is
154
187
  // not worth its tokens. `off`.
@@ -453,7 +486,40 @@ export const DEFAULT_REASONING_TABLE = {
453
486
  // carry the cell. The value is unchanged; only its standing improved, from
454
487
  // a stated prior to a measured win. Rescored from the original ledger on 2026-08-25 when the harness
455
488
  // moved to a forced two-way verdict; the trials are unchanged.
456
- implementation: 'off'
489
+ //
490
+ // OVERRIDDEN TO `medium` BY USER DECISION, 2026-08-27, AND THIS ONE
491
+ // OVERRIDES A MEASUREMENT RATHER THAN A PRIOR. Say so plainly: quality tied
492
+ // 12/20 in both arms, and the PAIRED clock — the statistic that matches this
493
+ // design — put `off` ahead in 9 of the 12 specs that pass in both arms,
494
+ // geometric mean 0.55x, p=0.0166. That is the rung-2 win that WROTE this
495
+ // cell `off`, and it is not withdrawn by anything measured since.
496
+ //
497
+ // WHAT THE DECISION COSTS, from those same numbers: expect implementation
498
+ // turns to take roughly 1.8x as long, for no measured quality gain. The
499
+ // quality CI is the widest in the table ([0.39, 0.78] at 12/20), so a real
500
+ // quality difference either way would have been invisible at n=20 — that is
501
+ // the room the decision is being made in, and it cuts both ways.
502
+ //
503
+ // THE STATED REASON FOR THE OVERRIDE is that the implementation turn was
504
+ // observed wandering in the same mx5-n run. That observation is NOT in this
505
+ // repo's evidence: the run's .pi-tasks logs were destroyed before they could
506
+ // be mined, so no loop was ever counted here and this cell must not claim
507
+ // one. It is the user's judgement, recorded as such.
508
+ //
509
+ // WHAT IS STRUCTURALLY TRUE, and checked: the implementation turn runs in
510
+ // the USER'S OWN SESSION (see task/implementation-thinking.ts and
511
+ // task/implementation-turn.ts), not as a child. It therefore has NO
512
+ // LoopDetector and NO StallDetector — only the per-tool-call command
513
+ // watchdog and the steer loop's resume cap. The 2026-08-27 fix that wired
514
+ // StallDetector into runWorker does NOT reach it. So of the groups in this
515
+ // table, `implementation` is the one where a rotation has no guard at all,
516
+ // and thinking is the only lever currently pointed at it.
517
+ //
518
+ // TO PUT THIS CELL BACK ON EVIDENCE, re-run
519
+ // scripts/live-implementation-thinking-ab.ts at a larger n. The ledger is
520
+ // /home/edgars/hub/ab-implab/impl-ledger.jsonl, 40 rows, b10618 — the odd
521
+ // build out, so a re-run on b10620 is not a replicate of it.
522
+ implementation: 'medium'
457
523
  };
458
524
  /**
459
525
  * A hand-edited or stale mode must not reach {@link resolveReasoning}'s switch as
@@ -1,5 +1,4 @@
1
- import { type ContextSnapshot, type SpawnFn } from '../shared/child-process.js';
2
- import { type LoopHit } from '../task/loop-detector.js';
1
+ import { type ContextSnapshot, type LoopHit, type SpawnFn } from '../shared/child-process.js';
3
2
  /**
4
3
  * Tool calls that can GROUND an APIS claim — i.e. return content a signature or
5
4
  * command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
@@ -125,6 +124,27 @@ export interface RunWorkerInput {
125
124
  threshold?: number;
126
125
  pathThreshold?: number;
127
126
  } | false;
127
+ /**
128
+ * Whole-run progress guard (task/stall-detector.ts). Default ON.
129
+ *
130
+ * WHY BOTH. LoopDetector judges ARGUMENTS over a 20-call window, so a child
131
+ * that rotates through MORE DISTINCT CALLS THAN THE WINDOW HOLDS is invisible
132
+ * to it — every key occurs once per window and the count never reaches the
133
+ * threshold. Measured: mx5-n 2026-08-27, worker:tooling made 550 calls over
134
+ * exactly 20 distinct files, ~36 reads each, and neither the exact rule nor
135
+ * the path rule ever tripped. It died 20 minutes later on the absolute
136
+ * progress ceiling, having done 25s of useful work.
137
+ *
138
+ * StallDetector judges RESULTS, which a rotating reader cannot vary. It was
139
+ * written for exactly this class and was wired only into phase children
140
+ * (task/child-runner.ts) until this option existed.
141
+ *
142
+ * Pass `false` to disable, or override the thresholds (tests, harnesses).
143
+ */
144
+ stallGuard?: {
145
+ limit?: number;
146
+ churnFactor?: number;
147
+ } | false;
128
148
  /**
129
149
  * Dead-backend stall guard override. Default ON: no output for
130
150
  * STALL_AFTER_MS → probe the model endpoints pi is configured with →
@@ -4,6 +4,7 @@ import { CommandWatchdog, commandTimeoutHint, realTimerDeps } from '../shared/co
4
4
  import { isGroundingRetrieval as isGrounding, workerChannel } from './worker-channels.js';
5
5
  import { childBaseArgs } from '../shared/child-extensions.js';
6
6
  import { LoopDetector } from '../task/loop-detector.js';
7
+ import { StallDetector, formatStallHint } from '../task/stall-detector.js';
7
8
  import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
8
9
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
9
10
  import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
@@ -282,9 +283,16 @@ const RESTART_RULES = [
282
283
  // re-spawn avoids it. Bounded by the shared restart budget.
283
284
  reason: 'loop',
284
285
  detect: s => s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
285
- { detail: `${s.loopHit.call.name} ×${s.loopHit.count}/${s.loopHit.windowSize}` }
286
+ {
287
+ // A stall hit carries no meaningful windowSize (rule 1 sets
288
+ // it to 0), so printing the loop shape would misname why the
289
+ // attempt died.
290
+ detail: s.loopHit.stall ?
291
+ `${s.loopHit.call.name} ${s.loopHit.stall} ×${s.loopHit.count}`
292
+ : `${s.loopHit.call.name} ×${s.loopHit.count}/${s.loopHit.windowSize}`
293
+ }
286
294
  : null,
287
- hint: s => formatLoopHint(s.loopHit),
295
+ hint: s => s.loopHit.stall ? formatStallHint(s.loopHit.stall) : formatLoopHint(s.loopHit),
288
296
  counters: { shared: true }
289
297
  },
290
298
  {
@@ -499,6 +507,16 @@ export async function runWorker(input) {
499
507
  const threshold = input.loop?.threshold ?? LOOP_THRESHOLD;
500
508
  return new LoopDetector(window, threshold, input.loop?.pathThreshold ?? threshold);
501
509
  })();
510
+ // Reset EACH attempt, like the loop detector: a restart discards the
511
+ // previous attempt's calls along with its text, so a fresh child must not
512
+ // inherit a dead streak it did not earn.
513
+ const stallDetector = input.stallGuard === false ?
514
+ null
515
+ : new StallDetector(input.stallGuard?.limit, input.stallGuard?.churnFactor);
516
+ // Arm the churn rule BEFORE the first tool call. pi's stream carries no
517
+ // context event (issue #16), so waiting for one leaves the rule
518
+ // permanently disarmed. The parent knows the window at spawn time.
519
+ stallDetector?.noteContext(input.contextWindow ?? 0);
502
520
  // Capture the hit the detector reports (it also returns it to the unified
503
521
  // runner, which kills the child on a hit). Without capturing it here the
504
522
  // SIGTERM that kill produces would surface as a bare non-zero exit the
@@ -543,9 +561,11 @@ export async function runWorker(input) {
543
561
  }
544
562
  if (isGrounding(call.name))
545
563
  groundingRetrievalCount++;
546
- if (!loopDetector)
547
- return null;
548
- const hit = loopDetector.record(call);
564
+ // Loop detector first: it names the offending call and its
565
+ // hint is the more specific one. The stall detector is the
566
+ // backstop for the thrash shapes a 20-call argument window
567
+ // cannot see.
568
+ const hit = loopDetector?.record(call) ?? stallDetector?.record(call) ?? null;
549
569
  if (hit && !loopHit)
550
570
  loopHit = hit;
551
571
  return hit;
@@ -565,9 +585,15 @@ export async function runWorker(input) {
565
585
  onToolResult: r => {
566
586
  timeout.progress();
567
587
  cmdWatch?.onEnd(r.toolCallId);
588
+ // The RESULT is what entered the child's context, so it —
589
+ // not the arguments — decides whether it learned anything.
590
+ stallDetector?.noteResult(r.text, r.isError);
568
591
  input.onToolResult?.(r);
569
592
  },
570
- onContextUsage: input.onContextUsage,
593
+ onContextUsage: snapshot => {
594
+ stallDetector?.noteContext(snapshot.contextWindow);
595
+ input.onContextUsage?.(snapshot);
596
+ },
571
597
  ...(input.contextWindow && input.contextWindow > 0 ?
572
598
  { contextWindow: input.contextWindow }
573
599
  : {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.21",
3
+ "version": "0.38.22",
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",