@mjasnikovs/pi-task 0.27.0 → 0.28.0
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/dist/task/gate-deps.js +14 -0
- package/dist/task/phases.js +62 -1
- package/dist/task/research-fanout-budget.d.ts +121 -0
- package/dist/task/research-fanout-budget.js +148 -0
- package/dist/workers/pi-worker-core.d.ts +136 -2
- package/dist/workers/pi-worker-core.js +274 -34
- package/dist/workers/pi-worker-docs.js +18 -0
- package/package.json +1 -1
package/dist/task/gate-deps.js
CHANGED
|
@@ -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;
|
package/dist/task/phases.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
319
|
+
let restartBudgetSpent = 0;
|
|
192
320
|
// Watchdog kills specifically — drives the ceiling halving. Kept apart from
|
|
193
|
-
// `
|
|
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
|
|
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
|
|
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
|
-
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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 &&
|
|
485
|
+
if (loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
|
|
285
486
|
hint = formatLoopHint(loopHit);
|
|
286
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 &&
|
|
513
|
+
if (streamStalled && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
|
|
310
514
|
hint = streamStallHint(streamStalled.idleMs);
|
|
311
|
-
|
|
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 &&
|
|
522
|
+
if (timedOut && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
|
|
318
523
|
hint = WORKER_TIMEOUT_HINT;
|
|
319
|
-
|
|
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
|
-
&&
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.28.0",
|
|
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",
|