@mjasnikovs/pi-task 0.27.0 → 0.28.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- ![pi-task — Deterministic AI Workflows. Every request runs a fixed pipeline: refine to clarify and structure it, research to gather information in parallel, grill to cross-examine the findings, compose to write the implementation spec, critique to check it for quality and completeness.](https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/hero.svg)
3
+ ![pi-task — Deterministic Local AI Workflows. Every request runs a fixed pipeline: refine to clarify and structure it, research to gather information in parallel, grill to cross-examine the findings, compose to write the implementation spec, critique to check it for quality and completeness.](https://raw.githubusercontent.com/mjasnikovs/pi-task/main/assets/hero.svg)
4
4
 
5
5
  # pi-task
6
6
 
package/assets/card.png CHANGED
Binary file
package/assets/card.svg CHANGED
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 'Liberation Sans', Arial, sans-serif" role="img" aria-label="pi-task — Deterministic AI Workflows. Five fixed phases: refine, research, grill, compose, critique.">
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 'Liberation Sans', Arial, sans-serif" role="img" aria-label="pi-task — Deterministic Local AI Workflows. Five fixed phases: refine, research, grill, compose, critique.">
2
2
  <defs>
3
3
  <clipPath id="frame"><rect x="0" y="0" width="960" height="600"/></clipPath>
4
4
  <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
@@ -14,8 +14,8 @@
14
14
  <path d="M13.5 17H34.5M19.5 17V32M28.5 17V32"/>
15
15
  </g>
16
16
  <text x="98" y="62" font-size="36" font-weight="700" fill="#e6edf3" letter-spacing="-0.5">pi-task</text>
17
- <text x="44" y="258" font-size="104" font-weight="700" fill="#e6edf3" letter-spacing="-4">Deterministic</text>
18
- <text x="44" y="372" font-size="104" font-weight="700" fill="url(#hl)" letter-spacing="-4">AI Workflows.</text>
17
+ <text x="44" y="268" font-size="88" font-weight="700" fill="#e6edf3" letter-spacing="-4">Deterministic</text>
18
+ <text x="44" y="365" font-size="88" font-weight="700" fill="url(#hl)" letter-spacing="-4">Local AI Workflows.</text>
19
19
  <rect x="44" y="404" width="104" height="7" rx="3.5" fill="#8957e5"/>
20
20
  <rect x="98.2" y="452" width="66" height="66" rx="14.5" fill="#1e1633" stroke="#a371f7" stroke-opacity="0.28" stroke-width="1.2"/>
21
21
  <g transform="translate(113.8,467.6) scale(1.45)" fill="none" stroke="#a371f7" stroke-width="1.31" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h4"/></g>
package/assets/hero.png CHANGED
Binary file
package/assets/hero.svg CHANGED
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 'Liberation Sans', Arial, sans-serif" role="img" aria-label="pi-task — Deterministic AI Workflows. Every request runs a fixed pipeline: refine to clarify and structure it, research to gather information in parallel, grill to cross-examine the findings, compose to write the implementation spec, critique to check it for quality and completeness.">
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 'Liberation Sans', Arial, sans-serif" role="img" aria-label="pi-task — Deterministic Local AI Workflows. Every request runs a fixed pipeline: refine to clarify and structure it, research to gather information in parallel, grill to cross-examine the findings, compose to write the implementation spec, critique to check it for quality and completeness.">
2
2
  <defs>
3
3
  <clipPath id="frame"><rect x="0" y="0" width="960" height="600"/></clipPath>
4
4
  <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
@@ -14,12 +14,13 @@
14
14
  <path d="M13.5 17H34.5M19.5 17V32M28.5 17V32"/>
15
15
  </g>
16
16
  <text x="90" y="63" font-size="30" font-weight="700" fill="#e6edf3" letter-spacing="-0.5">pi-task</text>
17
- <text x="44" y="212" font-size="62" font-weight="700" fill="#e6edf3" letter-spacing="-2">Deterministic</text>
18
- <text x="44" y="280" font-size="62" font-weight="700" fill="url(#hl)" letter-spacing="-2">AI Workflows.</text>
19
- <rect x="44" y="312" width="72" height="4" rx="2" fill="#8957e5"/>
20
- <text x="44" y="356" font-size="17" fill="#8b949e">pi-task orchestrates every request</text>
21
- <text x="44" y="381" font-size="17" fill="#8b949e">through a fixed pipeline to produce</text>
22
- <text x="44" y="406" font-size="17" fill="#8b949e">implementation specs you can trust.</text>
17
+ <text x="44" y="186" font-size="62" font-weight="700" fill="#e6edf3" letter-spacing="-2">Deterministic</text>
18
+ <text x="44" y="254" font-size="62" font-weight="700" fill="url(#hl)" letter-spacing="-2">Local AI</text>
19
+ <text x="44" y="322" font-size="62" font-weight="700" fill="url(#hl)" letter-spacing="-2">Workflows.</text>
20
+ <rect x="44" y="354" width="72" height="4" rx="2" fill="#8957e5"/>
21
+ <text x="44" y="398" font-size="17" fill="#8b949e">pi-task orchestrates every request</text>
22
+ <text x="44" y="423" font-size="17" fill="#8b949e">through a fixed pipeline to produce</text>
23
+ <text x="44" y="448" font-size="17" fill="#8b949e">implementation specs you can trust.</text>
23
24
  <rect x="470.0" y="132" width="74" height="74" rx="16.3" fill="#1e1633" stroke="#a371f7" stroke-opacity="0.28" stroke-width="1.2"/>
24
25
  <g transform="translate(489.0,151.0) scale(1.5)" fill="none" stroke="#a371f7" stroke-width="1.27" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h4"/></g>
25
26
  <text x="507.0" y="227" text-anchor="middle" font-size="11.5" font-weight="700" fill="#e6edf3" letter-spacing="0.4">REFINE</text>
@@ -400,6 +400,14 @@ export function buildGateDeps(params) {
400
400
  // (a healthy endpoint reads as proof of life).
401
401
  streamInactivityMs: getConfig().streamInactivityMs,
402
402
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
403
+ // A discarded attempt is otherwise invisible here too: the
404
+ // returned exitCode/text describe the FINAL attempt, so a
405
+ // gate child that burned two attempts and its wall clock
406
+ // reads exactly like one that ran clean.
407
+ onRestart: rs => log(`=== ${kind} RESTART (attempt ${rs.attempt} discarded)`
408
+ + ` reason=${rs.reason} wall=${rs.wallMs}ms`
409
+ + (rs.detail ? ` — ${rs.detail}` : '')
410
+ + ' ==='),
403
411
  onLine: line => {
404
412
  // `lastLine` feeds the LIVE status widget and is not
405
413
  // logging — it stays outside the gate, or a quiet
@@ -618,6 +626,12 @@ export function buildGateDeps(params) {
618
626
  // file (which IS this pass's job) never trips — only a
619
627
  // literally-identical call repeated past threshold does.
620
628
  loop: { pathThreshold: Number.POSITIVE_INFINITY },
629
+ // Same reasoning as the gate child: without this a
630
+ // discarded attempt leaves no trace anywhere.
631
+ onRestart: rs => logEnforce(`=== enforce RESTART (attempt ${rs.attempt} discarded)`
632
+ + ` reason=${rs.reason} wall=${rs.wallMs}ms`
633
+ + (rs.detail ? ` — ${rs.detail}` : '')
634
+ + ' ==='),
621
635
  onLine: line => {
622
636
  // `lastLine` drives the live widget, not the trail.
623
637
  lastLine = line;
@@ -10,6 +10,7 @@ import { runWorker } from '../workers/pi-worker-core.js';
10
10
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
11
11
  import { search as defaultSearch } from '../workers/search-core.js';
12
12
  import { extractEnrichTargets } from './enrichment.js';
13
+ import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
13
14
  import { isIntegrationUnknown } from './unknown-routing.js';
14
15
  import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
15
16
  import { demoteUnsourcedAttributions } from './context-attribution.js';
@@ -560,6 +561,13 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
560
561
  // the pattern nexxtasks exists to prevent. To re-run the experiment, restore the block
561
562
  // this comment replaces — see the git history of this file and the PROMPT 4 entry in
562
563
  // nexxtasks.txt RESULTS.
564
+ // nexttask 5B fan-out bounds. Both read their env ONCE per research phase, so
565
+ // every worker in a run sees the same policy and a harness cannot half-apply
566
+ // an arm; both are null in the shipped configuration.
567
+ const fanoutBudget = projectDocsBudget();
568
+ const fanoutTimeout = fanoutTimeoutPolicy();
569
+ const carryForward = workerCarryForward();
570
+ const progressCeilingMs = workerProgressCeilingMs();
563
571
  let doneCount = 0;
564
572
  const updateProgress = () => {
565
573
  doneCount++;
@@ -572,9 +580,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
572
580
  // per-worker measurement — waitMs the worker's own cold-start, workMs its
573
581
  // generation+tool-call cost. Under the opt-in parallel mode the numbers are
574
582
  // wall-clock-relative (queueing shows up in waitMs).
583
+ //
584
+ // Restarted attempts get their OWN row. Without one the widget contradicts
585
+ // itself: mx5 run 18 printed `workers 722.2s` over a longest member reading
586
+ // `worker:apis work 239.3s`, because wait/work describe the final attempt
587
+ // while the phase clock counts all three. The discarded time is the whole
588
+ // gap, so naming it is what closes the widget.
575
589
  const recordWorker = (label, p) => p.then(r => {
576
590
  deps.recordSubStep?.(`${label} wait`, r.waitMs);
577
591
  deps.recordSubStep?.(`${label} work`, r.workMs);
592
+ if (r.attempts > 1) {
593
+ deps.recordSubStep?.(`${label} discarded (${r.attempts - 1} restart${r.attempts > 2 ? 's' : ''})`, Math.max(0, r.totalWallMs - r.waitMs - r.workMs));
594
+ }
578
595
  return r;
579
596
  });
580
597
  // Run the four workers ONE AT A TIME. Settled by an A/B on the local
@@ -617,9 +634,15 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
617
634
  prompt: prior => appendNoThink(orientation.block
618
635
  + promptHeader
619
636
  + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined)
620
- + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')),
637
+ + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
638
+ // 5B CAP arm — empty unless PI_TASK_PROJECT_DOCS_BUDGET is
639
+ // set. The tool-side half lives in pi-worker-docs.ts; a
640
+ // budget enforced without being announced would just read
641
+ // to the worker as a broken tool.
642
+ + (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget))),
621
643
  tools: 'read,grep,find,ls,pi-worker-docs'
622
644
  + (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
645
+ fanoutBounded: true,
623
646
  extensions: [
624
647
  DOCS_EXTENSION_PATH,
625
648
  ...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
@@ -738,6 +761,32 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
738
761
  spawn: deps.spawn,
739
762
  ...(spec.tools ? { tools: spec.tools } : {}),
740
763
  ...(spec.extensions ? { extensions: spec.extensions } : {}),
764
+ // 5B SCALE arm — null unless both env vars are set. Only the
765
+ // docs-capable worker can fan out, so only it can be scaled.
766
+ ...(spec.fanoutBounded && fanoutTimeout ? { fanoutTimeout } : {}),
767
+ // 5B RESCUE arm — off unless its env vars are set. Applies to
768
+ // EVERY research worker, not just the docs-capable one: any
769
+ // worker that gets killed loses its work the same way.
770
+ ...(carryForward ? { carryForward: true } : {}),
771
+ ...(progressCeilingMs !== null ?
772
+ { progressTimeoutCeilingMs: progressCeilingMs }
773
+ : {}),
774
+ // One line per DISCARDED attempt. The `done` line below reports
775
+ // the final attempt only, so a worker that timed out twice at
776
+ // 240s and then answered used to log exactly like a clean one —
777
+ // 8 minutes of burned compute recoverable only by subtracting
778
+ // its own wait+work from the start/done timestamps.
779
+ onCarryForward: ci => {
780
+ deps.logDebug?.(`${spec.label}: CARRY-FORWARD injected into attempt ${ci.attempt}`
781
+ + ` (${ci.chars} chars onto a ${ci.promptCharsBefore}-char prompt)`);
782
+ },
783
+ onRestart: rs => {
784
+ deps.logDebug?.(`${spec.label}: RESTART (attempt ${rs.attempt} discarded)`
785
+ + ` reason=${rs.reason} wall=${rs.wallMs}ms`
786
+ + ` wait=${rs.waitMs}ms work=${rs.workMs}ms`
787
+ + (rs.detail ? ` — ${rs.detail}` : ''));
788
+ deps.onChildOutput?.(`${spec.label}: restart (${rs.reason})`);
789
+ },
741
790
  onLine: line => {
742
791
  // The one 'stream' site in this file: raw research-worker
743
792
  // output. Every other logDebug here records a decision.
@@ -838,6 +887,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
838
887
  }
839
888
  }
840
889
  deps.logDebug?.(`${spec.label}: done exit=${r.exitCode} wait=${r.waitMs}ms work=${r.workMs}ms`
890
+ // attempts/total are the pair that makes wait+work honest: they are
891
+ // the FINAL attempt's split, and only `total` sees the discarded ones.
892
+ + ` attempts=${r.attempts} total=${r.totalWallMs}ms`
893
+ + (r.restarts.length > 0 ?
894
+ ` restarts=[${r.restarts.map(x => x.reason).join(',')}]`
895
+ : '')
896
+ // Attribution for the RESCUE arm: a run with zero restarts was
897
+ // never killed (the progress deadline did it), while a run that
898
+ // restarted and salvaged was killed but kept its work. Without
899
+ // this the two are indistinguishable in the logs, and "0
900
+ // timeouts" cannot be traced to the half that earned it.
901
+ + (r.salvagedFromDiscardedAttempt ? ' salvaged=1' : '')
841
902
  + (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
842
903
  + (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
843
904
  updateProgress();
@@ -0,0 +1,121 @@
1
+ /**
2
+ * nexttask 5B — the two candidate bounds on worker:apis's project-source fan-out.
3
+ *
4
+ * ⚠ NOT WIRED. Both levers here are OFF unless their env var is set, so the
5
+ * shipped path is bit-for-bit what it was. They exist so
6
+ * `scripts/live-research-fanout-budget-ab.ts` can run them against the shipped
7
+ * baseline in the SAME build — the alternative (dist surgery) measures a patched
8
+ * copy of the code and not the code. Nothing may read these outside that harness
9
+ * until it reports PASS; a lever wired on argument rather than measurement is the
10
+ * failure mode nexttasks exists to prevent.
11
+ *
12
+ * THE FAULT THEY TARGET (mx5 run 18, measured — scripts/research-restart-baserate.ts):
13
+ * `worker:apis` fans out `pi-worker-docs(module: ".")` project-source lookups, each
14
+ * of which spawns its own summarising child, and the per-worker wall-clock cap is
15
+ * 240s. Pearson r(project lookups, worker wall clock) = 0.909 over 24 tasks. 0-4
16
+ * lookups never timed out; every worker at >=46 lookups burned the FULL restart
17
+ * budget — 3 attempts, 720s, two of them discarded whole. The 240s ceiling and a
18
+ * 46-call fan-out are jointly unsatisfiable, so the timeout is not a backstop
19
+ * there, it is the guaranteed outcome.
20
+ *
21
+ * TWO WAYS TO MAKE THEM SATISFIABLE, and the A/B — not this comment — decides:
22
+ *
23
+ * CAP bound the fan-out to fit the ceiling. Told to the worker upfront
24
+ * (projectDocsBudgetNotice) and enforced in the tool
25
+ * (projectDocsBudgetExhausted), because run 18 shows the prompt alone
26
+ * does not bind: the same worker is ALREADY told "be decisive" by
27
+ * WORKER_TIMEOUT_HINT on every restart.
28
+ * SCALE bound the ceiling to fit the fan-out: each project-source lookup
29
+ * pushes the deadline out, up to a hard ceiling, so a worker that is
30
+ * making progress is not killed for making progress.
31
+ *
32
+ * The risk each carries, and why the A/B's quality invariant is load-bearing: CAP
33
+ * can produce a faster worker that ships a THINNER APIS section, which is a
34
+ * regression wearing a win's clothes (memory/apis-contract-stage2-failed.md: a
35
+ * lever moved behaviour 20/20 while fabricating 15% of it). SCALE can simply
36
+ * spend the extra time and still time out, buying nothing.
37
+ *
38
+ * ─────────────────────────────────────────────────────────────────────────────
39
+ * BOTH OF THE ABOVE ANSWER THE WRONG QUESTION. Kept for the record and for the
40
+ * A/B's other arms, but they are not the fix.
41
+ *
42
+ * They argue about how long a worker may run. The actual defect is what happens
43
+ * when it runs out: the attempt is killed and everything it produced is THROWN
44
+ * AWAY, and the re-spawn is given a hint but no findings — so it re-reads the
45
+ * same files against the same clock and dies in the same place. That is why
46
+ * every worker at >=46 lookups burned the FULL budget rather than converging.
47
+ * The r=0.909 correlation measures the amnesia, not an over-long task.
48
+ *
49
+ * Judged against "the worker must return its work", CAP makes the worker read
50
+ * LESS — lowering the requirement so the metric goes green — and SCALE is a
51
+ * per-file constant that dies on one big file, and, being wall-clock, makes
52
+ * answer quality a function of the user's hardware: the same task on a slower
53
+ * local model loses its work and degrades. No constant fixes that.
54
+ *
55
+ * RESCUE (pi-worker-core.ts) carry the killed attempt's findings into the
56
+ * next one and never return less than the best attempt produced, so a
57
+ * restart CONVERGES instead of repeating; and deadline on lack of
58
+ * PROGRESS rather than elapsed time, so "slow" and "stuck" stop being
59
+ * the same verdict. Being stuck is already detected separately and
60
+ * correctly by the output-stall probe (STALL_AFTER_MS), which resets
61
+ * on progress and only kills when the model endpoint is unreachable.
62
+ *
63
+ * Its risk is its own, and the same quality invariant catches it: a half-written
64
+ * entry replayed under "work already done" is exactly how a fabrication gets
65
+ * laundered into a final answer. Hence the carry is framed as unverified, and
66
+ * ungrounded-symbol and anti-synthesis counts gate the arm.
67
+ */
68
+ /** Max project-source (`module: "."`) docs lookups per worker ATTEMPT. Unset = no cap. */
69
+ export declare const PROJECT_DOCS_BUDGET_ENV = "PI_TASK_PROJECT_DOCS_BUDGET";
70
+ /** Deadline extension granted per project-source lookup, in ms. Unset = no extension. */
71
+ export declare const FANOUT_TIMEOUT_PER_LOOKUP_ENV = "PI_TASK_FANOUT_TIMEOUT_PER_LOOKUP_MS";
72
+ /** Hard ceiling the extensions may never push the deadline past, in ms. */
73
+ export declare const FANOUT_TIMEOUT_CEILING_ENV = "PI_TASK_FANOUT_TIMEOUT_CEILING_MS";
74
+ /** RESCUE: carry a killed attempt's findings into the re-spawn, and salvage its output. `1` = on. */
75
+ export declare const WORKER_CARRY_FORWARD_ENV = "PI_TASK_WORKER_CARRY_FORWARD";
76
+ /** RESCUE: deadline on lack of progress instead of elapsed time. Value = absolute ceiling, ms. */
77
+ export declare const WORKER_PROGRESS_CEILING_ENV = "PI_TASK_WORKER_PROGRESS_CEILING_MS";
78
+ type Env = (key: string) => string | undefined;
79
+ /**
80
+ * The CAP arm's budget, or null when the lever is off (the shipped default).
81
+ * A non-numeric or non-positive value is off too: a typo'd env var must not
82
+ * silently cap the worker at 0 lookups.
83
+ */
84
+ export declare function projectDocsBudget(env?: Env): number | null;
85
+ /**
86
+ * The SCALE arm's policy, or null when off. Both halves are required: an
87
+ * extension with no ceiling is an unbounded worker, which is the one thing the
88
+ * 240s cap exists to prevent.
89
+ */
90
+ export declare function fanoutTimeoutPolicy(env?: Env): {
91
+ perLookupMs: number;
92
+ ceilingMs: number;
93
+ } | null;
94
+ /**
95
+ * The RESCUE arm's two halves, or nulls when off.
96
+ *
97
+ * Separated because they are independent claims and the A/B should be able to
98
+ * attribute a win: RESCUE_CARRY says a restart should keep what the killed
99
+ * attempt found, RESCUE_PROGRESS_CEILING says a worker should be killed for
100
+ * going quiet rather than for taking a while. Either can pay off without the
101
+ * other, and either can fabricate or hang without the other.
102
+ */
103
+ export declare function workerCarryForward(env?: Env): boolean;
104
+ /**
105
+ * The absolute backstop for the progress-based deadline, or null when off.
106
+ * Required rather than defaulted: a progress deadline with no ceiling is an
107
+ * unbounded worker, which is the one thing the fixed cap exists to prevent.
108
+ */
109
+ export declare function workerProgressCeilingMs(env?: Env): number | null;
110
+ /**
111
+ * The upfront half of the CAP arm, appended to the APIS worker's prompt.
112
+ *
113
+ * Upfront and NUMERIC on purpose. The worker cannot ration a budget it learns
114
+ * about only when it is spent, and "be decisive" — which it already receives on
115
+ * every timeout restart — is exactly the unquantified version that run 18 shows
116
+ * it ignoring until the third attempt.
117
+ */
118
+ export declare function projectDocsBudgetNotice(budget: number): string;
119
+ /** The enforcement half: what the tool returns once the budget is spent. */
120
+ export declare function projectDocsBudgetExhausted(budget: number): string;
121
+ export {};
@@ -0,0 +1,148 @@
1
+ /**
2
+ * nexttask 5B — the two candidate bounds on worker:apis's project-source fan-out.
3
+ *
4
+ * ⚠ NOT WIRED. Both levers here are OFF unless their env var is set, so the
5
+ * shipped path is bit-for-bit what it was. They exist so
6
+ * `scripts/live-research-fanout-budget-ab.ts` can run them against the shipped
7
+ * baseline in the SAME build — the alternative (dist surgery) measures a patched
8
+ * copy of the code and not the code. Nothing may read these outside that harness
9
+ * until it reports PASS; a lever wired on argument rather than measurement is the
10
+ * failure mode nexttasks exists to prevent.
11
+ *
12
+ * THE FAULT THEY TARGET (mx5 run 18, measured — scripts/research-restart-baserate.ts):
13
+ * `worker:apis` fans out `pi-worker-docs(module: ".")` project-source lookups, each
14
+ * of which spawns its own summarising child, and the per-worker wall-clock cap is
15
+ * 240s. Pearson r(project lookups, worker wall clock) = 0.909 over 24 tasks. 0-4
16
+ * lookups never timed out; every worker at >=46 lookups burned the FULL restart
17
+ * budget — 3 attempts, 720s, two of them discarded whole. The 240s ceiling and a
18
+ * 46-call fan-out are jointly unsatisfiable, so the timeout is not a backstop
19
+ * there, it is the guaranteed outcome.
20
+ *
21
+ * TWO WAYS TO MAKE THEM SATISFIABLE, and the A/B — not this comment — decides:
22
+ *
23
+ * CAP bound the fan-out to fit the ceiling. Told to the worker upfront
24
+ * (projectDocsBudgetNotice) and enforced in the tool
25
+ * (projectDocsBudgetExhausted), because run 18 shows the prompt alone
26
+ * does not bind: the same worker is ALREADY told "be decisive" by
27
+ * WORKER_TIMEOUT_HINT on every restart.
28
+ * SCALE bound the ceiling to fit the fan-out: each project-source lookup
29
+ * pushes the deadline out, up to a hard ceiling, so a worker that is
30
+ * making progress is not killed for making progress.
31
+ *
32
+ * The risk each carries, and why the A/B's quality invariant is load-bearing: CAP
33
+ * can produce a faster worker that ships a THINNER APIS section, which is a
34
+ * regression wearing a win's clothes (memory/apis-contract-stage2-failed.md: a
35
+ * lever moved behaviour 20/20 while fabricating 15% of it). SCALE can simply
36
+ * spend the extra time and still time out, buying nothing.
37
+ *
38
+ * ─────────────────────────────────────────────────────────────────────────────
39
+ * BOTH OF THE ABOVE ANSWER THE WRONG QUESTION. Kept for the record and for the
40
+ * A/B's other arms, but they are not the fix.
41
+ *
42
+ * They argue about how long a worker may run. The actual defect is what happens
43
+ * when it runs out: the attempt is killed and everything it produced is THROWN
44
+ * AWAY, and the re-spawn is given a hint but no findings — so it re-reads the
45
+ * same files against the same clock and dies in the same place. That is why
46
+ * every worker at >=46 lookups burned the FULL budget rather than converging.
47
+ * The r=0.909 correlation measures the amnesia, not an over-long task.
48
+ *
49
+ * Judged against "the worker must return its work", CAP makes the worker read
50
+ * LESS — lowering the requirement so the metric goes green — and SCALE is a
51
+ * per-file constant that dies on one big file, and, being wall-clock, makes
52
+ * answer quality a function of the user's hardware: the same task on a slower
53
+ * local model loses its work and degrades. No constant fixes that.
54
+ *
55
+ * RESCUE (pi-worker-core.ts) carry the killed attempt's findings into the
56
+ * next one and never return less than the best attempt produced, so a
57
+ * restart CONVERGES instead of repeating; and deadline on lack of
58
+ * PROGRESS rather than elapsed time, so "slow" and "stuck" stop being
59
+ * the same verdict. Being stuck is already detected separately and
60
+ * correctly by the output-stall probe (STALL_AFTER_MS), which resets
61
+ * on progress and only kills when the model endpoint is unreachable.
62
+ *
63
+ * Its risk is its own, and the same quality invariant catches it: a half-written
64
+ * entry replayed under "work already done" is exactly how a fabrication gets
65
+ * laundered into a final answer. Hence the carry is framed as unverified, and
66
+ * ungrounded-symbol and anti-synthesis counts gate the arm.
67
+ */
68
+ /** Max project-source (`module: "."`) docs lookups per worker ATTEMPT. Unset = no cap. */
69
+ export const PROJECT_DOCS_BUDGET_ENV = 'PI_TASK_PROJECT_DOCS_BUDGET';
70
+ /** Deadline extension granted per project-source lookup, in ms. Unset = no extension. */
71
+ export const FANOUT_TIMEOUT_PER_LOOKUP_ENV = 'PI_TASK_FANOUT_TIMEOUT_PER_LOOKUP_MS';
72
+ /** Hard ceiling the extensions may never push the deadline past, in ms. */
73
+ export const FANOUT_TIMEOUT_CEILING_ENV = 'PI_TASK_FANOUT_TIMEOUT_CEILING_MS';
74
+ /** RESCUE: carry a killed attempt's findings into the re-spawn, and salvage its output. `1` = on. */
75
+ export const WORKER_CARRY_FORWARD_ENV = 'PI_TASK_WORKER_CARRY_FORWARD';
76
+ /** RESCUE: deadline on lack of progress instead of elapsed time. Value = absolute ceiling, ms. */
77
+ export const WORKER_PROGRESS_CEILING_ENV = 'PI_TASK_WORKER_PROGRESS_CEILING_MS';
78
+ const defaultEnv = key => process.env[key];
79
+ function positiveInt(raw) {
80
+ if (raw === undefined)
81
+ return null;
82
+ const n = Number(raw);
83
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
84
+ }
85
+ /**
86
+ * The CAP arm's budget, or null when the lever is off (the shipped default).
87
+ * A non-numeric or non-positive value is off too: a typo'd env var must not
88
+ * silently cap the worker at 0 lookups.
89
+ */
90
+ export function projectDocsBudget(env = defaultEnv) {
91
+ return positiveInt(env(PROJECT_DOCS_BUDGET_ENV));
92
+ }
93
+ /**
94
+ * The SCALE arm's policy, or null when off. Both halves are required: an
95
+ * extension with no ceiling is an unbounded worker, which is the one thing the
96
+ * 240s cap exists to prevent.
97
+ */
98
+ export function fanoutTimeoutPolicy(env = defaultEnv) {
99
+ const perLookupMs = positiveInt(env(FANOUT_TIMEOUT_PER_LOOKUP_ENV));
100
+ const ceilingMs = positiveInt(env(FANOUT_TIMEOUT_CEILING_ENV));
101
+ return perLookupMs !== null && ceilingMs !== null ? { perLookupMs, ceilingMs } : null;
102
+ }
103
+ /**
104
+ * The RESCUE arm's two halves, or nulls when off.
105
+ *
106
+ * Separated because they are independent claims and the A/B should be able to
107
+ * attribute a win: RESCUE_CARRY says a restart should keep what the killed
108
+ * attempt found, RESCUE_PROGRESS_CEILING says a worker should be killed for
109
+ * going quiet rather than for taking a while. Either can pay off without the
110
+ * other, and either can fabricate or hang without the other.
111
+ */
112
+ export function workerCarryForward(env = defaultEnv) {
113
+ return env(WORKER_CARRY_FORWARD_ENV) === '1';
114
+ }
115
+ /**
116
+ * The absolute backstop for the progress-based deadline, or null when off.
117
+ * Required rather than defaulted: a progress deadline with no ceiling is an
118
+ * unbounded worker, which is the one thing the fixed cap exists to prevent.
119
+ */
120
+ export function workerProgressCeilingMs(env = defaultEnv) {
121
+ return positiveInt(env(WORKER_PROGRESS_CEILING_ENV));
122
+ }
123
+ /**
124
+ * The upfront half of the CAP arm, appended to the APIS worker's prompt.
125
+ *
126
+ * Upfront and NUMERIC on purpose. The worker cannot ration a budget it learns
127
+ * about only when it is spent, and "be decisive" — which it already receives on
128
+ * every timeout restart — is exactly the unquantified version that run 18 shows
129
+ * it ignoring until the third attempt.
130
+ */
131
+ export function projectDocsBudgetNotice(budget) {
132
+ return (`\n\nLOOKUP BUDGET: you may make at most ${budget} project-source `
133
+ + '`pi-worker-docs` calls (module: ".") in this attempt. They are the most '
134
+ + 'expensive thing you can do — each one runs a separate model pass — and '
135
+ + 'past the budget the tool returns nothing at all. Spend them only on '
136
+ + 'symbols you will actually list in APIS, batch related questions about '
137
+ + 'one file into a single call, and read a file directly with `read` when '
138
+ + 'you just need to see it. When the budget runs out, write your answer '
139
+ + 'from what you already have.');
140
+ }
141
+ /** The enforcement half: what the tool returns once the budget is spent. */
142
+ export function projectDocsBudgetExhausted(budget) {
143
+ return (`PROJECT LOOKUP BUDGET SPENT — you have used all ${budget} project-source `
144
+ + 'docs lookups for this attempt and no further ones will run. Do NOT retry '
145
+ + 'this call or rephrase it; it will return this same message. Use `read` or '
146
+ + '`grep` if you must see one more file, then write your answer now from what '
147
+ + 'you already retrieved.');
148
+ }
@@ -2,6 +2,35 @@ import { type ContextSnapshot, type SpawnFn } from '../shared/child-process.js';
2
2
  import { type LoopHit } from '../task/loop-detector.js';
3
3
  /** True when a tool call retrieves content an APIS entry could be grounded in. */
4
4
  export declare function isGroundingRetrieval(toolName: string): boolean;
5
+ /**
6
+ * Does this partial output carry ANSWER CONTENT, or is it the model clearing its
7
+ * throat?
8
+ *
9
+ * Salvage originally kept the LONGEST partial, which is not the same question. On
10
+ * the live carry arm, TASK_0020 and TASK_0021 both timed out on all three
11
+ * attempts and salvage shipped this as the section:
12
+ *
13
+ * "Now let me get more details on the specific APIs and components I need:"
14
+ *
15
+ * — a preamble sentence, which beats an empty string on length and carries
16
+ * nothing. Both trials scored 2 entries and DEGRADED, against 22 and 5 for the
17
+ * same fixtures in baseline.
18
+ *
19
+ * A research worker's answer is a list of lines that each name something and
20
+ * describe it. The test is therefore structural, not lexical: at least two lines
21
+ * that look like entries — a name, then a gap, then a description. Prose wraps
22
+ * at no particular column and does not repeat that shape.
23
+ */
24
+ export declare function hasAnswerContent(text: string): boolean;
25
+ /**
26
+ * Frame a discarded attempt's output as work already done.
27
+ *
28
+ * Kept deliberately blunt about status. Appended text loses to preserved text
29
+ * when the two disagree, so the carry must not read as a finished answer the
30
+ * model can simply re-emit: it is labelled partial, unverified, and truncated
31
+ * when it is.
32
+ */
33
+ export declare function formatCarryForward(text: string): string | null;
5
34
  export interface RunWorkerInput {
6
35
  prompt: string;
7
36
  cwd: string;
@@ -88,15 +117,90 @@ export interface RunWorkerInput {
88
117
  streamInactivityMs?: number;
89
118
  /** Backoff sleep, injectable so tests don't wait out the real delays. */
90
119
  sleepFor?: (ms: number) => Promise<void>;
120
+ /**
121
+ * SCALE arm of nexttask 5B — OFF unless set, and set only by the harness that
122
+ * is measuring it (src/task/research-fanout-budget.ts explains both arms).
123
+ * Each project-source `pi-worker-docs` call pushes this attempt's deadline out
124
+ * by `perLookupMs`, never past `ceilingMs` from the attempt's start: a worker
125
+ * that is making retrieval progress is not killed for making it, while a
126
+ * worker that is thrashing still hits a hard bound.
127
+ */
128
+ fanoutTimeout?: {
129
+ perLookupMs: number;
130
+ ceilingMs: number;
131
+ };
132
+ /**
133
+ * Absolute backstop that turns `timeoutMs` from "total time allowed" into
134
+ * "time allowed WITHOUT PROGRESS". A tool call or a line of output re-arms
135
+ * the deadline; only a worker that goes quiet for `timeoutMs` — or exceeds
136
+ * this ceiling outright — is killed.
137
+ *
138
+ * This is the difference between "took too long" and "stopped working". The
139
+ * first is a property of the machine (a slower local model, a bigger file)
140
+ * and must not cost the user their answer; the second is a real fault, and
141
+ * one the output-stall probe already catches on its own terms.
142
+ */
143
+ progressTimeoutCeilingMs?: number;
144
+ /**
145
+ * Carry a killed attempt's findings into the re-spawn, and never return less
146
+ * than the best attempt produced. OFF by default so the shipped path is
147
+ * unchanged while the A/B runs — see src/task/research-fanout-budget.ts.
148
+ */
149
+ carryForward?: boolean;
150
+ /**
151
+ * Called when a carried-forward partial is INJECTED into an attempt's prompt
152
+ * — once per attempt that receives one. Distinct from `onRestart`, which says
153
+ * an attempt was thrown away; this says the next one was actually handed its
154
+ * findings. The two are separately observable because they can diverge: a
155
+ * restart whose partial had no answer content injects nothing.
156
+ */
157
+ onCarryForward?: (info: {
158
+ attempt: number;
159
+ chars: number;
160
+ promptCharsBefore: number;
161
+ }) => void;
162
+ /**
163
+ * Called once per DISCARDED attempt, at the moment the worker decides to
164
+ * re-spawn — the only window in which a restart is observable at all.
165
+ *
166
+ * WHY: every restart branch below throws away a whole attempt's wall clock
167
+ * along with its text, and `waitMs`/`workMs` describe the FINAL attempt only.
168
+ * With no hook here those attempts were structurally invisible: mx5 run 18
169
+ * burned 30 wall-clock timeouts / 120 minutes of compute that appeared in no
170
+ * log and no timing widget, and 21 of the 23 affected workers reported
171
+ * `exit=0` — clean successes as far as the run could tell. The discrepancy
172
+ * was only recoverable by subtracting reported wait+work from the timestamps
173
+ * of the `start` and `done` lines around it.
174
+ */
175
+ onRestart?: (restart: WorkerRestart) => void;
91
176
  /**
92
177
  * Connection-error restart budget. Defaults to MAX_LOOP_RESTARTS, and even
93
- * then the SHARED `restarts` counter is what actually binds — a worker that
178
+ * then the SHARED restart counter is what actually binds — a worker that
94
179
  * already spent the budget looping does not get extra lives here. 0 turns the
95
180
  * retry off, which is how scripts/connection-retry-ab.ts gets a baseline arm
96
181
  * out of a build that already ships the retry.
97
182
  */
98
183
  connectionRetries?: number;
99
184
  }
185
+ /**
186
+ * Why an attempt was thrown away. One value per restart branch in runWorker, so
187
+ * a log line naming the reason points at exactly one piece of code.
188
+ */
189
+ export type WorkerRestartReason = 'loop' | 'command-timeout' | 'stream-stall' | 'worker-timeout' | 'connection-error' | 'leaked-tool-call';
190
+ /** One DISCARDED attempt: its cause and the wall clock it consumed and lost. */
191
+ export interface WorkerRestart {
192
+ /** 1-based number of the attempt being discarded (the 1st restart ends attempt 1). */
193
+ attempt: number;
194
+ reason: WorkerRestartReason;
195
+ /** Wall clock this attempt spent before it was killed — time with no output. */
196
+ wallMs: number;
197
+ /** The discarded attempt's own spawn → first-byte split. */
198
+ waitMs: number;
199
+ /** The discarded attempt's own first-byte → exit split. */
200
+ workMs: number;
201
+ /** Reason-specific diagnosis: the looping call, the hung tool, the error text. */
202
+ detail?: string;
203
+ }
100
204
  export interface RunWorkerResult {
101
205
  text: string;
102
206
  exitCode: number;
@@ -130,14 +234,44 @@ export interface RunWorkerResult {
130
234
  * Milliseconds between spawn and the child's first stdout chunk. When
131
235
  * multiple workers run concurrently and the upstream model API queues at
132
236
  * some concurrency cap, this is the queue-wait portion of the run.
237
+ *
238
+ * FINAL ATTEMPT ONLY — a restarted attempt's clock is discarded with its
239
+ * text. `waitMs + workMs` is therefore NOT the worker's wall clock whenever
240
+ * `attempts > 1`; `totalWallMs` is.
133
241
  */
134
242
  waitMs: number;
135
243
  /**
136
244
  * Milliseconds between first stdout chunk and process exit — the
137
245
  * generation/tool-call portion, independent of queue wait. Equals total
138
- * elapsed when the child never produced output.
246
+ * elapsed when the child never produced output. Final attempt only, same as
247
+ * `waitMs`.
139
248
  */
140
249
  workMs: number;
250
+ /**
251
+ * How many attempts (spawns) this call made, including the one that produced
252
+ * `text`. 1 for a worker that ran clean. Always `restarts.length + 1`.
253
+ */
254
+ attempts: number;
255
+ /**
256
+ * The worker's TRUE wall clock: entry to return, spanning every discarded
257
+ * attempt and every connection backoff. `totalWallMs - waitMs - workMs` is
258
+ * the time this worker spent on output that was thrown away.
259
+ */
260
+ totalWallMs: number;
261
+ /**
262
+ * One entry per discarded attempt, in order — empty on a clean run. The only
263
+ * record that a restart happened: the returned `exitCode`/`text` describe the
264
+ * final attempt and look identical whether it was the first or the third.
265
+ */
266
+ restarts: ReadonlyArray<WorkerRestart>;
267
+ /**
268
+ * True when `text` came from a DISCARDED attempt rather than the final one,
269
+ * because the final attempt returned less. The answer is real output the
270
+ * worker produced, but it was cut off mid-flight, so it is likelier to be
271
+ * incomplete than a clean return — callers that grade completeness should
272
+ * treat it as partial rather than as a finished answer.
273
+ */
274
+ salvagedFromDiscardedAttempt: boolean;
141
275
  /**
142
276
  * How many GROUNDING retrieval tool calls the FINAL attempt made — the calls
143
277
  * that returned content an APIS entry could be cited from (see
@@ -66,6 +66,91 @@ const STALL_AFTER_MS = 180_000;
66
66
  const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
67
67
  + 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
68
68
  + 'then write your answer now. Do not re-explore ground you have already covered.]';
69
+ /**
70
+ * How much of a discarded attempt's answer is carried into the next one.
71
+ *
72
+ * A restart used to hand the re-spawn nothing but a hint — which is why
73
+ * WORKER_TIMEOUT_HINT above can tell a worker "do not re-explore ground you have
74
+ * already covered" while giving it no record of what that ground was. It could
75
+ * not comply. mx5 run 18 shows the cost: on tasks with >=46 project-source
76
+ * lookups, 5 of 5 workers burned the FULL restart budget, because every attempt
77
+ * re-read the same files against the same clock and died in the same place.
78
+ *
79
+ * Carrying the partial answer forward is what makes a restart converge instead
80
+ * of repeat. The risk it takes is real and is the thing the A/B measures: a
81
+ * half-written or speculative entry, replayed under "already established", is
82
+ * exactly how a fabrication gets laundered into a final answer. That is what the
83
+ * ungrounded-symbol and anti-synthesis guards are pointed at, so the carry is
84
+ * framed as findings to VERIFY-or-DROP rather than as settled fact.
85
+ */
86
+ const CARRY_FORWARD_LIMIT = 24_000;
87
+ /**
88
+ * Restart reasons whose partial output is worth keeping.
89
+ *
90
+ * A clock kill (`worker-timeout`), a hung tool (`command-timeout`), an idle
91
+ * stream (`stream-stall`) and a dropped socket (`connection-error`) all discard
92
+ * work the model genuinely did. A loop kill and a leaked tool call do not — the
93
+ * first is by definition the same call repeated, the second is malformed
94
+ * protocol text, and replaying either would feed the failure back to itself.
95
+ */
96
+ const CARRY_FORWARD_REASONS = new Set([
97
+ 'worker-timeout',
98
+ 'command-timeout',
99
+ 'stream-stall',
100
+ 'connection-error'
101
+ ]);
102
+ /**
103
+ * Does this partial output carry ANSWER CONTENT, or is it the model clearing its
104
+ * throat?
105
+ *
106
+ * Salvage originally kept the LONGEST partial, which is not the same question. On
107
+ * the live carry arm, TASK_0020 and TASK_0021 both timed out on all three
108
+ * attempts and salvage shipped this as the section:
109
+ *
110
+ * "Now let me get more details on the specific APIs and components I need:"
111
+ *
112
+ * — a preamble sentence, which beats an empty string on length and carries
113
+ * nothing. Both trials scored 2 entries and DEGRADED, against 22 and 5 for the
114
+ * same fixtures in baseline.
115
+ *
116
+ * A research worker's answer is a list of lines that each name something and
117
+ * describe it. The test is therefore structural, not lexical: at least two lines
118
+ * that look like entries — a name, then a gap, then a description. Prose wraps
119
+ * at no particular column and does not repeat that shape.
120
+ */
121
+ export function hasAnswerContent(text) {
122
+ const entryish = text
123
+ .split('\n')
124
+ .map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').trim())
125
+ .filter(l => /^\S.*?(?:\s{2,}|\s+[—–-]\s+)\S/.test(l) && !/[.:]$/.test(l));
126
+ return entryish.length >= 2;
127
+ }
128
+ /**
129
+ * Frame a discarded attempt's output as work already done.
130
+ *
131
+ * Kept deliberately blunt about status. Appended text loses to preserved text
132
+ * when the two disagree, so the carry must not read as a finished answer the
133
+ * model can simply re-emit: it is labelled partial, unverified, and truncated
134
+ * when it is.
135
+ */
136
+ export function formatCarryForward(text) {
137
+ const body = text.trim();
138
+ if (body.length === 0)
139
+ return null;
140
+ const truncated = body.length > CARRY_FORWARD_LIMIT;
141
+ // Keep the TAIL: the model writes progressively, so the end of a partial
142
+ // answer is the furthest it got and the best statement of where to resume.
143
+ const kept = truncated ? body.slice(body.length - CARRY_FORWARD_LIMIT) : body;
144
+ return ('[WORK ALREADY DONE — from your previous attempt, which was cut off before '
145
+ + 'it could answer. These findings came from real reads of this project, so '
146
+ + 'do NOT gather them again; spend your time on what is still missing. They '
147
+ + 'are PARTIAL and UNVERIFIED: keep every item you can confirm, and drop any '
148
+ + 'item you cannot — do not carry an unconfirmed item into your answer, and '
149
+ + 'do not treat this as your answer.'
150
+ + (truncated ? ' (Earlier portion omitted; this is the most recent part.)' : '')
151
+ + ']\n'
152
+ + kept);
153
+ }
69
154
  const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
70
155
  /**
71
156
  * Combine an external abort signal with an internal wall-clock timeout into one
@@ -73,17 +158,25 @@ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
73
158
  * only when the timer fired (not when the external signal aborted), so the caller
74
159
  * can restart on a timeout but not on a user cancel.
75
160
  */
76
- function workerTimeout(external, ms) {
161
+ function workerTimeout(external, ms,
162
+ /**
163
+ * Absolute backstop for the progress-based deadline. When set, `ms` stops
164
+ * meaning "total time allowed" and starts meaning "time allowed WITHOUT
165
+ * PROGRESS"; this is the hard limit no amount of progress can pass.
166
+ */
167
+ absoluteCeilingMs) {
77
168
  const ctrl = new AbortController();
78
169
  let timedOut = false;
170
+ const armed = ms > 0 && Number.isFinite(ms);
171
+ const started = Date.now();
172
+ let deadline = started + ms;
173
+ const fire = () => {
174
+ timedOut = true;
175
+ ctrl.abort();
176
+ };
79
177
  // ms <= 0 (or non-finite) disables the wall-clock timeout: no timer is armed,
80
178
  // so only the external signal can abort and timedOut() stays false forever.
81
- const timer = ms > 0 && Number.isFinite(ms) ?
82
- setTimeout(() => {
83
- timedOut = true;
84
- ctrl.abort();
85
- }, ms)
86
- : undefined;
179
+ let timer = armed ? setTimeout(fire, ms) : undefined;
87
180
  const onExternal = () => ctrl.abort();
88
181
  if (external) {
89
182
  if (external.aborted)
@@ -94,6 +187,41 @@ function workerTimeout(external, ms) {
94
187
  return {
95
188
  signal: ctrl.signal,
96
189
  timedOut: () => timedOut,
190
+ // SCALE arm of nexttask 5B, inert unless a caller calls it: push the
191
+ // deadline out, never past `started + ceilingMs`. A disabled timeout
192
+ // (nothing armed) stays disabled — extending "never" is meaningless — and
193
+ // an already-fired timer is not resurrected.
194
+ extend: (byMs, ceilingMs) => {
195
+ if (!armed || timedOut || ctrl.signal.aborted)
196
+ return;
197
+ const next = Math.min(deadline + byMs, started + ceilingMs);
198
+ if (next <= deadline)
199
+ return;
200
+ deadline = next;
201
+ clearTimeout(timer);
202
+ timer = setTimeout(fire, Math.max(0, deadline - Date.now()));
203
+ },
204
+ // PROGRESS-BASED DEADLINE. A worker that is making tool calls and
205
+ // emitting text is not stuck — it is slow, and how slow is a property of
206
+ // the user's machine, not of the task. Killing it on total elapsed time
207
+ // makes answer quality depend on the hardware: the same task on a slower
208
+ // local model loses its work and degrades, which no per-file constant can
209
+ // fix. Being STUCK is already detected separately and correctly, by the
210
+ // output-stall probe (STALL_AFTER_MS), which resets on progress and only
211
+ // kills when the model endpoint is unreachable.
212
+ progress: () => {
213
+ if (absoluteCeilingMs === undefined)
214
+ return;
215
+ if (!armed || timedOut || ctrl.signal.aborted)
216
+ return;
217
+ const next = Math.min(Date.now() + ms, started + absoluteCeilingMs);
218
+ if (next <= deadline)
219
+ return;
220
+ deadline = next;
221
+ clearTimeout(timer);
222
+ timer = setTimeout(fire, Math.max(0, deadline - Date.now()));
223
+ },
224
+ budgetMs: () => deadline - started,
97
225
  cleanup: () => {
98
226
  clearTimeout(timer);
99
227
  external?.removeEventListener('abort', onExternal);
@@ -188,19 +316,49 @@ export async function runWorker(input) {
188
316
  // runPhaseWithLoopGuard: a runaway worker gets re-spawned with a corrective
189
317
  // hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
190
318
  // keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
191
- let restarts = 0;
319
+ let restartBudgetSpent = 0;
192
320
  // Watchdog kills specifically — drives the ceiling halving. Kept apart from
193
- // `restarts` (the shared budget) so a loop-caused restart doesn't shorten
321
+ // `restartBudgetSpent` (the shared budget) so a loop-caused restart doesn't shorten
194
322
  // the rope of a child that has never hung (see commandCeilingForAttempt).
195
323
  let hangKills = 0;
196
324
  // Connection-error restarts specifically — drives the backoff schedule (and
197
325
  // lets a harness set the budget to 0 without touching the shared counter).
198
326
  let connRetries = 0;
199
327
  let leakRetries = 0;
328
+ // Entry-to-return wall clock. tAttemptStart below is per-attempt (it is what
329
+ // waitMs/workMs are measured from); this one is the only thing that sees the
330
+ // attempts that were killed and re-spawned.
331
+ const tRunStart = Date.now();
332
+ const restarts = [];
333
+ // The best partial answer any discarded attempt produced, RAW. Without this a
334
+ // restart is amnesiac: it re-reads the same files against the same clock and
335
+ // dies in the same place (see CARRY_FORWARD_LIMIT). Held unformatted because
336
+ // it has two consumers — the next attempt's prompt, which wants it wrapped in
337
+ // the carry-forward framing, and the final return, which must never emit that
338
+ // framing as if it were the worker's answer.
339
+ // Held in a box, not a bare `let`: the only writer is the `noteRestart`
340
+ // closure below, and TypeScript narrows a closure-assigned `let` back to its
341
+ // initialiser at the return site.
342
+ const salvage = { text: null };
200
343
  for (;;) {
201
- const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
344
+ const carried = salvage.text === null ? null : formatCarryForward(salvage.text);
345
+ // Announce the INJECTION, not just the restart. Without this, "the carry
346
+ // reached the re-spawn" can only be inferred from entry counts — and
347
+ // inferring what a worker did from what it produced is the exact gap 5A
348
+ // exists to close. The prompt goes to the child on stdin, so no log
349
+ // downstream of here can show it.
350
+ if (carried !== null) {
351
+ input.onCarryForward?.({
352
+ attempt: restarts.length + 1,
353
+ chars: carried.length,
354
+ promptCharsBefore: input.prompt.length
355
+ });
356
+ }
357
+ const prompt = [hint, carried, input.prompt]
358
+ .filter((p) => p !== null)
359
+ .join('\n\n');
202
360
  const invocation = getPiInvocation([...baseArgs], prompt);
203
- const tStart = Date.now();
361
+ const tAttemptStart = Date.now();
204
362
  let tFirstByte = null;
205
363
  // loop === false turns the guard off entirely (detector is null and no
206
364
  // tool call is ever flagged); otherwise build a detector from the override
@@ -221,7 +379,7 @@ export async function runWorker(input) {
221
379
  // discarded with its text, so the count must describe only the attempt
222
380
  // whose text this call returns.
223
381
  let groundingRetrievalCount = 0;
224
- const timeout = workerTimeout(input.signal, timeoutMs);
382
+ const timeout = workerTimeout(input.signal, timeoutMs, input.progressTimeoutCeilingMs);
225
383
  // Per-tool-call watchdog for this attempt (null when off). Its abort is
226
384
  // OR'd with the worker timeout / external cancel into the child's signal.
227
385
  const cmdWatch = commandWatch(commandCeilingForAttempt(input.commandTimeoutMs ?? 0, hangKills));
@@ -245,6 +403,14 @@ export async function runWorker(input) {
245
403
  onFirstByte: () => (tFirstByte = Date.now()),
246
404
  onToolCall: call => {
247
405
  cmdWatch?.onStart(call);
406
+ // A tool call is the worker working. Inert unless the
407
+ // caller opted into a progress-based deadline.
408
+ timeout.progress();
409
+ if (input.fanoutTimeout
410
+ && call.name === 'pi-worker-docs'
411
+ && call.args?.module === '.') {
412
+ timeout.extend(input.fanoutTimeout.perLookupMs, input.fanoutTimeout.ceilingMs);
413
+ }
248
414
  if (isGroundingRetrieval(call.name))
249
415
  groundingRetrievalCount++;
250
416
  if (!loopDetector)
@@ -254,16 +420,23 @@ export async function runWorker(input) {
254
420
  loopHit = hit;
255
421
  return hit;
256
422
  },
257
- onLine: input.onLine,
258
- // Always wired when the watchdog is on the sink only emits
259
- // tool_execution_end if a handler exists, and without it every
260
- // timer would stay armed and fire on a finished command.
261
- onToolResult: cmdWatch ?
262
- r => {
263
- cmdWatch.onEnd(r.toolCallId);
264
- input.onToolResult?.(r);
265
- }
266
- : input.onToolResult,
423
+ // Output is the other half of "still working": a worker
424
+ // writing its answer is making progress even when it has no
425
+ // more tool calls to make.
426
+ onLine: line => {
427
+ timeout.progress();
428
+ input.onLine?.(line);
429
+ },
430
+ // Always wired now (it used to be conditional on the command
431
+ // watchdog): the sink only emits tool_execution_end if a
432
+ // handler exists, and a completed tool call is the clearest
433
+ // progress signal there is. Without it a worker whose tool
434
+ // calls all succeed would still look idle to the deadline.
435
+ onToolResult: r => {
436
+ timeout.progress();
437
+ cmdWatch?.onEnd(r.toolCallId);
438
+ input.onToolResult?.(r);
439
+ },
267
440
  onContextUsage: input.onContextUsage
268
441
  }, input.spawn);
269
442
  }
@@ -272,8 +445,36 @@ export async function runWorker(input) {
272
445
  cmdWatch?.clear();
273
446
  }
274
447
  const tEnd = Date.now();
275
- const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
448
+ const effectiveCapMs = timeout.budgetMs();
449
+ const waitMs = tFirstByte === null ? tEnd - tAttemptStart : tFirstByte - tAttemptStart;
276
450
  const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
451
+ // Record + announce a discarded attempt. Called from every `continue`
452
+ // branch below, so a restart cannot be added without becoming visible.
453
+ const noteRestart = (reason, detail) => {
454
+ const record = {
455
+ attempt: restarts.length + 1,
456
+ reason,
457
+ wallMs: tEnd - tAttemptStart,
458
+ waitMs,
459
+ workMs,
460
+ ...(detail ? { detail } : {})
461
+ };
462
+ restarts.push(record);
463
+ input.onRestart?.(record);
464
+ // Harvest here rather than in each branch: `noteRestart` is the one
465
+ // place every `continue` already has to pass through, so a restart
466
+ // path cannot be added that silently drops the attempt's work.
467
+ // Longest-wins — a later attempt killed early should not replace a
468
+ // fuller answer an earlier one had already reached.
469
+ if (input.carryForward === true && CARRY_FORWARD_REASONS.has(reason)) {
470
+ const partial = text.trim();
471
+ // Longest-with-CONTENT wins. Length alone let a preamble sentence
472
+ // become the answer — see hasAnswerContent.
473
+ if (partial.length > (salvage.text?.length ?? 0) && hasAnswerContent(partial)) {
474
+ salvage.text = partial;
475
+ }
476
+ }
477
+ };
277
478
  const text = result.text ?? '';
278
479
  const timedOut = timeout.timedOut();
279
480
  const commandKill = cmdWatch?.killed();
@@ -281,16 +482,17 @@ export async function runWorker(input) {
281
482
  // A loop-kill gets the same restart-with-hint treatment every other phase
282
483
  // already gets (runPhaseWithLoopGuard) — name the offending call so the
283
484
  // re-spawn avoids it. Bounded by the shared restart budget.
284
- if (loopHit && restarts < MAX_LOOP_RESTARTS) {
485
+ if (loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
285
486
  hint = formatLoopHint(loopHit);
286
- restarts++;
487
+ restartBudgetSpent++;
488
+ noteRestart('loop', `${loopHit.call.name} ×${loopHit.count}/${loopHit.windowSize}`);
287
489
  continue;
288
490
  }
289
491
  // A hung COMMAND is restartable too, on the same budget, but checked
290
492
  // before the whole-worker timeout because its hint is the specific one:
291
493
  // bound the command. (The two can't be confused — a watchdog kill leaves
292
494
  // timeout.timedOut() false, since that flag tracks only its own timer.)
293
- if (commandKill && !loopHit && restarts < MAX_LOOP_RESTARTS) {
495
+ if (commandKill && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
294
496
  hint = commandTimeoutHint(commandKill.toolName, commandKill.timeoutMs, {
295
497
  commandDetail: commandKill.detail,
296
498
  // Nothing reverts the tree between attempts, so a child that can
@@ -299,24 +501,30 @@ export async function runWorker(input) {
299
501
  // gate logger uses — decided by tools, not by phase.
300
502
  editsMayPersist: /\b(?:edit|bash|write)\b/.test(tools)
301
503
  });
302
- restarts++;
504
+ restartBudgetSpent++;
303
505
  hangKills++;
506
+ noteRestart('command-timeout', `${commandKill.toolName} > ${commandKill.timeoutMs}ms`
507
+ + (commandKill.detail ? `: ${commandKill.detail}` : ''));
304
508
  continue;
305
509
  }
306
510
  // A hung model stream is restartable on the same budget. Checked before
307
511
  // the wall-clock timeout because it is the more specific diagnosis (and
308
512
  // its hint does not blame the model: nothing it did caused the hang).
309
- if (streamStalled && !loopHit && restarts < MAX_LOOP_RESTARTS) {
513
+ if (streamStalled && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
310
514
  hint = streamStallHint(streamStalled.idleMs);
311
- restarts++;
515
+ restartBudgetSpent++;
516
+ noteRestart('stream-stall', `idle ${streamStalled.idleMs}ms`);
312
517
  continue;
313
518
  }
314
519
  // A wall-clock timeout (the backstop for varied thrash the exact-match
315
520
  // detector misses) is also restartable, sharing the same budget. Skip when
316
521
  // a loop also tripped — the loop hint above is more specific.
317
- if (timedOut && !loopHit && restarts < MAX_LOOP_RESTARTS) {
522
+ if (timedOut && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
318
523
  hint = WORKER_TIMEOUT_HINT;
319
- restarts++;
524
+ restartBudgetSpent++;
525
+ // The EFFECTIVE cap, which the SCALE arm moves — reporting the
526
+ // configured one would misname why this attempt died.
527
+ noteRestart('worker-timeout', `cap ${effectiveCapMs}ms`);
320
528
  continue;
321
529
  }
322
530
  // A connection-class model error is restartable on the same budget, exactly
@@ -342,10 +550,13 @@ export async function runWorker(input) {
342
550
  // would only delay the report.
343
551
  if (result.modelError
344
552
  && isConnectionError(result.modelError)
345
- && restarts < MAX_LOOP_RESTARTS
553
+ && restartBudgetSpent < MAX_LOOP_RESTARTS
346
554
  && connRetries < (input.connectionRetries ?? MAX_LOOP_RESTARTS)) {
555
+ // Noted BEFORE the backoff sleep, so the record's wallMs stays the
556
+ // attempt's own clock; the sleep lands in totalWallMs, where it belongs.
557
+ noteRestart('connection-error', result.modelError.slice(0, 120));
347
558
  await (input.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(connRetries));
348
- restarts++;
559
+ restartBudgetSpent++;
349
560
  connRetries++;
350
561
  continue;
351
562
  }
@@ -356,15 +567,44 @@ export async function runWorker(input) {
356
567
  if (leaked && leakRetries < MAX_LEAK_RETRIES) {
357
568
  hint = leakedToolCallHint(leaked);
358
569
  leakRetries++;
570
+ noteRestart('leaked-tool-call', leaked.trim().slice(0, 80));
359
571
  continue;
360
572
  }
573
+ // SALVAGE. The run used to return the LAST attempt's text unconditionally,
574
+ // so a worker whose final attempt was killed early reported nothing at all
575
+ // — even when a discarded attempt had produced a usable answer that was
576
+ // still in hand at the moment it was thrown away. A restart budget is
577
+ // meant to buy more chances at an answer, not to overwrite a good attempt
578
+ // with a worse one.
579
+ //
580
+ // Gated on the final attempt having FAILED, not on it being shorter. A
581
+ // worker that finished cleanly has answered, and a short answer is a
582
+ // legitimate answer — length would let a long half-finished fragment
583
+ // override a concise correct one, which is the opposite of the fix.
584
+ const finalAttemptFailed = timedOut === true
585
+ || result.aborted
586
+ || result.modelError !== undefined
587
+ || result.stalled === true
588
+ || streamStalled !== undefined
589
+ || commandKill !== undefined
590
+ || loopHit !== undefined
591
+ || text.trim().length === 0;
592
+ const answer = (finalAttemptFailed
593
+ && salvage.text !== null
594
+ && salvage.text.length > text.trim().length) ?
595
+ salvage.text
596
+ : text;
361
597
  return {
362
- text,
598
+ text: answer,
599
+ salvagedFromDiscardedAttempt: answer !== text,
363
600
  exitCode: result.exitCode,
364
601
  stderr: result.stderr.trim(),
365
602
  aborted: result.aborted,
366
603
  waitMs,
367
604
  workMs,
605
+ attempts: restarts.length + 1,
606
+ totalWallMs: Date.now() - tRunStart,
607
+ restarts,
368
608
  sawOutput: tFirstByte !== null,
369
609
  groundingRetrievalCount,
370
610
  ...(result.modelError ? { modelError: result.modelError } : {}),
@@ -14,6 +14,7 @@ import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
14
14
  import { logDocsAnswer } from './typeonly-log.js';
15
15
  import { normalizeQuery } from './research-cache.js';
16
16
  import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
17
+ import { projectDocsBudget, projectDocsBudgetExhausted } from '../task/research-fanout-budget.js';
17
18
  const childArgs = () => [...childBaseArgs(), '--no-tools'];
18
19
  const RENDER_QUERY_MAX = 100;
19
20
  const Params = Type.Object({
@@ -57,6 +58,14 @@ function pinDetails(pin) {
57
58
  return pin ? { versionSource: pin.source, declaredRange: pin.range } : {};
58
59
  }
59
60
  export function registerPiWorkerDocs(pi, internals = {}) {
61
+ // CAP arm of nexttask 5B — OFF unless PI_TASK_PROJECT_DOCS_BUDGET is set, and
62
+ // then per-ATTEMPT by construction: the extension is loaded into a fresh pi
63
+ // child on every spawn, so a restarted attempt starts this counter at 0. The
64
+ // budget it enforces is the one the worker was told about in its prompt
65
+ // (projectDocsBudgetNotice) — enforcement without the notice would be a
66
+ // silent tool failure, and the notice without enforcement is what run 18
67
+ // already shows does not bind.
68
+ let projectLookups = 0;
60
69
  makeWorkerTool(pi, {
61
70
  name: 'pi-worker-docs',
62
71
  label: 'Pi Worker Docs',
@@ -103,6 +112,15 @@ export function registerPiWorkerDocs(pi, internals = {}) {
103
112
  const spawn = internals.spawn ?? defaultSpawn;
104
113
  // ── Project source lookup ───────────────────────────────────────
105
114
  if (params.module === '.') {
115
+ const budget = projectDocsBudget();
116
+ if (budget !== null && ++projectLookups > budget) {
117
+ // Refused BEFORE any work: the point of the cap is the child
118
+ // spawn and the model pass this branch would otherwise run.
119
+ return {
120
+ text: projectDocsBudgetExhausted(budget),
121
+ details: { budgetSpent: true }
122
+ };
123
+ }
106
124
  const openCache = internals.openCache ?? defaultOpenCache;
107
125
  let cache;
108
126
  let cacheError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
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",