bareloop 0.5.0 → 0.6.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/CHANGELOG.md +746 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -234
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +356 -20
- package/src/planrun.js +978 -98
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +107 -7
- package/types/planrun.d.ts +51 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/planrun.js
CHANGED
|
@@ -14,18 +14,22 @@
|
|
|
14
14
|
// (fs.deny on the gate audit / .smoke / .litectx, unchanged).
|
|
15
15
|
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
|
-
import { readFileSync } from 'node:fs';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
17
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { join, resolve, relative } from 'node:path';
|
|
20
|
+
import { Gate } from 'bareguard';
|
|
20
21
|
import { LiteCtx } from 'litectx';
|
|
21
|
-
import { runClose, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
22
|
-
import { validatePlan } from './plan.js';
|
|
23
|
-
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS } from './plan.js';
|
|
22
|
+
import { runClose, runStages, ralph, CLOSE_FAULTS } from './ralph.js';
|
|
23
|
+
import { validatePlan, legalScopes, stageClose } from './plan.js';
|
|
24
|
+
import { WRITE_VERBS, EXIT_TYPES, MAX_EXITS_PER_STEP, MAX_PLAN_STEPS, MAX_SCOPE_MENU, STEP_MODELS } from './plan.js';
|
|
24
25
|
import { snapshotScope, evalExits } from './exits.js';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { createRoot } from './root.js';
|
|
27
|
+
import { createStallWatch, STALL_MS, MAX_STALLS } from './stall.js';
|
|
28
|
+
import { TOOL_MENU, STORE_VERBS, checkMenu } from './job.js';
|
|
29
|
+
import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, PERSONA_TOOLS, strategyFor } from './tools.js';
|
|
30
|
+
import { globToPrefix, redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
28
31
|
import { extractArtifact } from './text.js';
|
|
32
|
+
import { createClock, isWallTimeout } from './clock.js';
|
|
29
33
|
|
|
30
34
|
const require = createRequire(import.meta.url);
|
|
31
35
|
const { Loop, wireGate, HaltError } = require('bare-agent');
|
|
@@ -48,6 +52,11 @@ const SCOUT_BLOB_MAX = 8000;
|
|
|
48
52
|
const NATIVE_READ_CAP = 24 * 1024;
|
|
49
53
|
/** native-only strategy: tell the worker WHY whole-file reads fail here and to navigate by symbol */
|
|
50
54
|
const NATIVE_READ_STRATEGY = '\nINTERFACE LIMIT: on this surface a whole-file read of a large file is TRUNCATED before you see it — a shell_read of a file over ~24KB returns only its start followed by a truncation notice. This is NOT the whole file. To read a function IN FULL, always use ctx_recall(<symbol>) then ctx_get(<pointer>) — that returns the entire function no matter how large the file is. To locate a line, use shell_grep(<pattern>). Never try to understand a file over ~400 lines by reading it whole; recall its symbols instead.';
|
|
55
|
+
/** F59 — below this a scout survey is treated as absent. Sized off the archive, not
|
|
56
|
+
* taste: the 15 truncated scouts produced 0/74/86 bytes; every real survey was
|
|
57
|
+
* 5991-8056. 200 sits between the two populations with an order of magnitude of
|
|
58
|
+
* daylight on each side, so it cannot be a tuned-to-one-case threshold. */
|
|
59
|
+
const SCOUT_MIN_BYTES = 200;
|
|
51
60
|
/** feed-forward artifact bound per step (prompt ingredient, spine-bound) */
|
|
52
61
|
const ARTIFACT_MAX = 2000;
|
|
53
62
|
/** the wallet floor below which a replan is a stop, not an adaptation (review
|
|
@@ -55,9 +64,101 @@ const ARTIFACT_MAX = 2000;
|
|
|
55
64
|
* just burns another draft and mislabels the money-cut as "exits still red"
|
|
56
65
|
* (F45 class) — the honest terminal there is cap-halt. */
|
|
57
66
|
const MONEY_MIN = 0.001;
|
|
67
|
+
/**
|
|
68
|
+
* A (PRD v1.27; design record §3.4, answered §6.2) — the replan trigger. A step
|
|
69
|
+
* that has consumed this share of the run's REMAINING money or time with its exits
|
|
70
|
+
* still red gets no further attempt; the planner re-allocates what is left instead.
|
|
71
|
+
*
|
|
72
|
+
* 50% is hamr's number (*"50% is fine"*), and it was **re-confirmed with its own
|
|
73
|
+
* inertness on the table**: F63 replayed 18 archived spines / 54 steps / 101 judged
|
|
74
|
+
* attempts and found it would have fired 0 times (near misses 0.35 · 0.35 · 0.40 ·
|
|
75
|
+
* 0.45). Shown that, hamr's call was *"keep 0.5"* (2026-07-26).
|
|
76
|
+
*
|
|
77
|
+
* So this is a GUARD against a step that eats the run, deliberately set above the
|
|
78
|
+
* observed population rather than tuned to it — lowering it to 0.35 because that is
|
|
79
|
+
* where those four points sit would be fitting the number to the data (the standing
|
|
80
|
+
* no-fit-to-pass rule), and threshold-setting is arbiter territory either way. It
|
|
81
|
+
* has not hardened by surviving unexamined; it survived being examined.
|
|
82
|
+
*
|
|
83
|
+
* This changes the replan TRIGGER only. The hard ceiling of ONE replan is
|
|
84
|
+
* unchanged — unlimited replanning launders thrash as adaptation (v1.12).
|
|
85
|
+
*/
|
|
86
|
+
const VARIANCE_THRESHOLD = 0.5;
|
|
58
87
|
|
|
59
88
|
/** @typedef {Error & {category?: string, lib?: string}} CategorizedError */
|
|
60
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The materials block: what the run has to spend, as a BALANCE and nothing else.
|
|
92
|
+
*
|
|
93
|
+
* hamr's correction (design record addendum 2, verbatim): *"why would you tell it
|
|
94
|
+
* every round that you have x time per round? what if there is a heavier round?
|
|
95
|
+
* why don't you tell it same like cost, you have x left from cost/time instead of
|
|
96
|
+
* making it race against time?"*
|
|
97
|
+
*
|
|
98
|
+
* So: NO rate, NO per-round allowance, NO derived round count. F57 measured a 150x
|
|
99
|
+
* spread on the verification gaps (3.8s to 561s), so a per-round constant
|
|
100
|
+
* describes almost no real round — a heavy round would break the planner's
|
|
101
|
+
* arithmetic with nothing to tell it. A balance is self-correcting: after a heavy
|
|
102
|
+
* round the number is simply lower. A rate is also a stopwatch, and racing a clock
|
|
103
|
+
* is the rush-or-fake incentive the v1.12 §5 prompt contract exists to remove.
|
|
104
|
+
*
|
|
105
|
+
* At DRAFT the balance is the total, so this is scale only — the planner is NOT
|
|
106
|
+
* asked to allocate time, because sizing time at draft requires the rate that was
|
|
107
|
+
* just removed. A plan that does not fit is what the meter is for (addendum 2).
|
|
108
|
+
* @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [m]
|
|
109
|
+
*/
|
|
110
|
+
function materialsBlock(m) {
|
|
111
|
+
if (!m) return '';
|
|
112
|
+
const lines = [];
|
|
113
|
+
if (typeof m.balanceUsd === 'number') lines.push(`- money left for the whole run: $${m.balanceUsd.toFixed(2)}`);
|
|
114
|
+
if (typeof m.remainingMs === 'number') lines.push(`- time left for the whole run: ${Math.round(m.remainingMs / 60_000)} minutes`);
|
|
115
|
+
if (!lines.length) return '';
|
|
116
|
+
// The progress line only exists at REPLAN: it is the variance the meter read,
|
|
117
|
+
// and it is the adaptation channel (addendum 2). At draft there is no progress.
|
|
118
|
+
if (m.progress) lines.push(`- where the run got to: ${m.progress}`);
|
|
119
|
+
return `\nWhat this run has left to spend — plan within it:\n${lines.join('\n')}\nThese are totals for the run, not per-step allowances. Nothing here is a rate: a step's cost depends on what it does.\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* W-2 — the progress TREND, read off the two most recent close gaps.
|
|
124
|
+
*
|
|
125
|
+
* A run stopped by its wall hands the human a lever choice, and the two levers
|
|
126
|
+
* point opposite ways: more TIME only helps work that was still moving, and a goal
|
|
127
|
+
* the run cannot reach does not become reachable by being given longer. The
|
|
128
|
+
* cheapest honest discriminator is already in the fix loop's hand — consecutive
|
|
129
|
+
* close output. A byte-identical gap means the last attempt moved nothing the
|
|
130
|
+
* arbiter can see; a different one means the tree was still changing under it.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately NOT a new instrument (no red counting, no red-set diffing): two
|
|
133
|
+
* strings the loop already holds, compared for equality. `unknown` is the honest
|
|
134
|
+
* reading when only one grade exists — never rounded up to "stalled", which would
|
|
135
|
+
* recommend rewriting a goal on no evidence at all (F6's rule, applied to a trend
|
|
136
|
+
* instead of a number).
|
|
137
|
+
* @param {string[]} gaps close gaps, oldest first
|
|
138
|
+
* @returns {{trend: 'stalled'|'moving'|'unknown', reading: string, lever: string}}
|
|
139
|
+
*/
|
|
140
|
+
function gapTrend(gaps) {
|
|
141
|
+
if (gaps.length < 2) {
|
|
142
|
+
return {
|
|
143
|
+
trend: 'unknown',
|
|
144
|
+
reading: 'only one close grade exists, so there is nothing to compare',
|
|
145
|
+
lever: 'read the last close output before choosing between more time and a different goal',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const [prev, last] = gaps.slice(-2);
|
|
149
|
+
return prev === last
|
|
150
|
+
? {
|
|
151
|
+
trend: 'stalled',
|
|
152
|
+
reading: 'the last two close outputs are byte-identical — the previous attempt moved nothing the close can see',
|
|
153
|
+
lever: 'more time alone is unlikely to help; revise the goal/spec first',
|
|
154
|
+
}
|
|
155
|
+
: {
|
|
156
|
+
trend: 'moving',
|
|
157
|
+
reading: 'the last two close outputs differ — the tree was still changing under the close when time ran out',
|
|
158
|
+
lever: 'the work was progressing; raising maxWallMs is the lever that fits',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
61
162
|
/**
|
|
62
163
|
* The plan-drafting prompt: a schema DESCRIPTION built from the live validator
|
|
63
164
|
* menus — never a copyable example (the drafter must author, not echo; the
|
|
@@ -65,10 +166,27 @@ const MONEY_MIN = 0.001;
|
|
|
65
166
|
* arbiter territory the planner never sees.
|
|
66
167
|
* @param {any} job @param {string} scoutBlob @param {any[]|null} reds
|
|
67
168
|
* @param {number} maxStepRounds @param {string|null} failure replan context
|
|
169
|
+
* @param {string[]|undefined} scopes the offered `tree-changed` menu (`legalScopes`).
|
|
170
|
+
* Choose-don't-describe (§4): the agent picks a value, never authors a glob and
|
|
171
|
+
* guesses its shape. Omitted, it derives from the signed fence — the SAME
|
|
172
|
+
* fail-closed derivation `validatePlan` uses, so prompt and validator can never
|
|
173
|
+
* disagree about what was offered.
|
|
174
|
+
* @param {{ balanceUsd?: number|null, remainingMs?: number|null, progress?: string }} [materials]
|
|
175
|
+
* what the run has LEFT (T/A). Omitted → no materials block at all, which is the
|
|
176
|
+
* pre-T behaviour and keeps every existing caller byte-identical.
|
|
177
|
+
* @param {number} [capRuns] the shell's attempt cap — the ceiling `attempts` may only
|
|
178
|
+
* TIGHTEN. Interpolated the way every sibling bound is: `validatePlan` reds outside
|
|
179
|
+
* `1..capRuns`, and a prompt that says only "integer" spends a round teaching it.
|
|
180
|
+
* Defaults to validatePlan's own default so existing callers are unchanged.
|
|
68
181
|
*/
|
|
69
|
-
function planPrompt(job, scoutBlob, reds, maxStepRounds, failure) {
|
|
182
|
+
export function planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopes, materials, capRuns = 3) {
|
|
183
|
+
const scopeMenu = Array.isArray(scopes) && scopes.length ? scopes : legalScopes(job.writeScope ?? []);
|
|
70
184
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
71
|
-
|
|
185
|
+
// W4: the menu comes off the STAGED close — the same one derivation `runPlan`
|
|
186
|
+
// executes and `validatePlan` accepts against. Reading `job.close` raw here left
|
|
187
|
+
// the legacy object form offering nothing while the runner preflighted a stage.
|
|
188
|
+
// (`?? []`: a close naming no command stages nothing, so it offers nothing)
|
|
189
|
+
const checkNames = checkMenu(stageClose(job.close) ?? []).map((m) => m.name);
|
|
72
190
|
const doc = `DRAFT-PLAN
|
|
73
191
|
You are planning how to accomplish a goal in a repository, as an ordered list of bounded
|
|
74
192
|
steps (schema "plan-v1"). The plan is pure declarative JSON validated by a strict schema;
|
|
@@ -79,21 +197,40 @@ Shape: { "schema": "plan-v1", "steps": [ ... 1..${MAX_PLAN_STEPS} steps ... ] }
|
|
|
79
197
|
strictly in array order. Each step (no other fields exist):
|
|
80
198
|
- "id": kebab-case slug, unique
|
|
81
199
|
- "action": the step's task, precise enough for a worker that sees ONLY this step
|
|
82
|
-
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit
|
|
200
|
+
- "tools": non-empty unique subset of ${JSON.stringify(ceiling)} (write/edit change the tree; recall/get/impact/related/recent search and navigate the repository index; compress/peek read cheaply; stash/remember/forget park and record notes across steps)
|
|
83
201
|
- "rounds": integer 1..${maxStepRounds} — the step's per-attempt tool-round bound
|
|
84
202
|
- "target": the step's deliverable path (REQUIRED when tools include write/edit), inside ${JSON.stringify(job.writeScope)}
|
|
203
|
+
- "model" (optional): ${STEP_MODELS.join(' | ')} — a cheaper tier for mechanical steps; omit for the default
|
|
204
|
+
- "attempts" (optional): integer 1..${capRuns} — TIGHTEN this step's retry cap below the shell's; you may never raise it
|
|
205
|
+
- "scope" (optional): narrow this step's WRITE fence — copy one value from the offered scopes below.
|
|
206
|
+
A narrowed "scope" is the ONLY ground this step can write: its "target" must sit
|
|
207
|
+
inside it, and a "tree-changed" exit's scope must not be disjoint from it (one that
|
|
208
|
+
CONTAINS it is fine) — otherwise every write the step could make is denied, or the
|
|
209
|
+
exit can never pass, on every attempt.
|
|
85
210
|
- "exit": 1..${MAX_EXITS_PER_STEP} form checks that ALL must pass (AND), each one of:
|
|
86
211
|
{"type":"artifact-written","path":"...","pattern":"optional regex"}
|
|
87
|
-
{"type":"tree-changed","scope":"
|
|
212
|
+
{"type":"tree-changed","scope":"<copy one value from the offered scopes below>"}
|
|
88
213
|
{"type":"json-valid","path":"..."}
|
|
89
|
-
{"type":"check-passes","name":"one
|
|
214
|
+
{"type":"check-passes","name":"<copy one name from the check menu below>"}
|
|
215
|
+
Check menu — a check-passes "name" is exactly one of ${JSON.stringify(checkNames)}; copy
|
|
216
|
+
it character for character. No other name exists, and you cannot add one.
|
|
90
217
|
A check-passes on a write-granted step MUST be paired with a tree-changed exit
|
|
91
218
|
(the repository starts green — a lone check would pass on the untouched tree).
|
|
219
|
+
A check-passes may ONLY appear on a step whose tools include write or edit: a
|
|
220
|
+
failing check is reported back to THAT step's worker for another attempt, so
|
|
221
|
+
the step must be able to act on the report. Do not plan a separate read-only
|
|
222
|
+
"verify" step — attach the check to the step that does the fixing; the final
|
|
223
|
+
verification after your plan is not yours to author.
|
|
92
224
|
Reference checks by NAME only; you cannot author or modify one.
|
|
93
225
|
|
|
226
|
+
Offered "scope" values for tree-changed — copy ONE of these exactly, character for
|
|
227
|
+
character. No other value is accepted, and patterns of your own (like "src/*.js")
|
|
228
|
+
are not accepted:
|
|
229
|
+
${scopeMenu.map((s) => ` ${JSON.stringify(s)}`).join('\n')}
|
|
230
|
+
|
|
94
231
|
Goal:
|
|
95
232
|
${job.goal}
|
|
96
|
-
|
|
233
|
+
${materialsBlock(materials)}
|
|
97
234
|
Repository survey (from a read-only scout):
|
|
98
235
|
${scoutBlob || '(no scout notes)'}`;
|
|
99
236
|
let p = doc;
|
|
@@ -103,7 +240,7 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
103
240
|
}
|
|
104
241
|
|
|
105
242
|
/**
|
|
106
|
-
* Execute a validateJob-GREEN plan-shape job (goal/verdictType/close
|
|
243
|
+
* Execute a validateJob-GREEN plan-shape job (goal/verdictType/close).
|
|
107
244
|
* Called by runJob after the approval gate, validation, and the smoke — this
|
|
108
245
|
* function owns the plan flow only; the caller owns the job-end record and the
|
|
109
246
|
* one ledger (every provider round is emitted here as `worker-round`, which
|
|
@@ -124,16 +261,52 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
124
261
|
* @param {() => number} opts.remainingUsd the one wallet: what is left of the signed budget right now
|
|
125
262
|
* @param {() => boolean} [opts.isUnpriced] has any round come back with a null cost? (F6) — the
|
|
126
263
|
* plan flow bails IN-FLIGHT on the first unpriced round instead of burning the whole plan
|
|
264
|
+
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier factory
|
|
265
|
+
* (tier ∈ STEP_MODELS → a provider). A plan naming a tier with no factory supplied is an
|
|
266
|
+
* interpreter-red STOP — never silently run the default tier as if the choice was honoured.
|
|
127
267
|
* @param {number} [opts.capRuns] shell-owned per-step attempt cap
|
|
128
268
|
* @param {number} [opts.closeTimeoutMs] close/check wall-clock cap (shell territory)
|
|
129
269
|
* @param {number} [opts.maxStepRounds] the shell's per-step rounds ceiling (validatePlan's bound)
|
|
270
|
+
* @param {number} [opts.scoutRounds] the read-only survey's round bound (F59: the LAST round is
|
|
271
|
+
* reserved — a scout that spends every round on tools gets one toolless round to write its
|
|
272
|
+
* survey, because the bound halts it mid-tool-use and text is its only deliverable)
|
|
273
|
+
* @param {boolean} [opts.layerRoot=false] Layer R — the within-run ratchet (src/root.js),
|
|
274
|
+
* scoped PER STEP's ralph loop (each micro-wheel is the Layer-1 atom). Shell-assembled from
|
|
275
|
+
* the step's own books: per-attempt write-sets from the F32 workerWrites audit (teed for
|
|
276
|
+
* same-path rewrites, which the cumulative audit alone cannot see) and the red-set from the
|
|
277
|
+
* exit evaluator's own gap. Defaults OFF (decided 2026-07-21, F41): fixation is extinct on
|
|
278
|
+
* every current job, so ON has never won its A/B; `true` is the ON/experimental arm, and the
|
|
279
|
+
* first plan-flow job to emit `root-injected` runs the pre-registered ON-vs-OFF acceptance
|
|
280
|
+
* read (the Layer R default-flip, LAYERS.md ⚠). Excluded on native (clipipe): the native
|
|
281
|
+
* worker has no onToolResult seam, so the tee cannot settle and same-path rewrites are blind.
|
|
282
|
+
* @param {() => number} [opts.now] the wall clock's time source, injected. The real clock is the
|
|
283
|
+
* default; a caller supplies this to drive time deterministically (the same seam `createClock`
|
|
284
|
+
* already exposes — a run's terminal cannot otherwise be exercised without waiting out a cap
|
|
285
|
+
* whose floor is one close timeout).
|
|
130
286
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
131
287
|
* 'check-red' | 'close-red' | 'close-unsupported' | 'pricing-red' | 'cap-halt' |
|
|
132
|
-
* 'provider-red' | 'interpreter-red' | `step-red:<id>`
|
|
288
|
+
* 'wall-halt' | 'provider-red' | 'interpreter-red' | 'step-stalled' | `step-red:<id>`
|
|
133
289
|
*/
|
|
134
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40 }) {
|
|
290
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, emit, remainingUsd, isUnpriced = () => false, capRuns = 3, closeTimeoutMs, maxStepRounds = 40, layerRoot = false, scoutRounds = SCOUT_ROUNDS, now }) {
|
|
135
291
|
workdir = resolve(workdir);
|
|
136
|
-
|
|
292
|
+
// ONE spelling of the redaction, housed next to the inventory it reads
|
|
293
|
+
// (src/validate.js) — the same helper the isolate verbs scrub the litectx store
|
|
294
|
+
// with. Two hand-spelled copies of "what a secret looks like" is exactly how
|
|
295
|
+
// detection and redaction drift apart.
|
|
296
|
+
const scrub = redactSecrets;
|
|
297
|
+
// The ctx verbs' spine channel, scrubbed at the WIRING. Every field a ctx-tool
|
|
298
|
+
// event carries is MODEL-CHOSEN text — a recall query, a stash id, a symbol, a
|
|
299
|
+
// path the worker spelled — and the spine is append-only: a log that captures a
|
|
300
|
+
// key captures it forever. Scrubbing the emitter (once, at construction) rather
|
|
301
|
+
// than the seventeen call sites is what makes the leak inexpressible: a new ctx
|
|
302
|
+
// verb cannot forget it. Same ONE inventory (SECRET_PATTERNS) the close output
|
|
303
|
+
// is scrubbed with — never a second spelling of what a secret looks like.
|
|
304
|
+
// Shallow by shape: the events are flat records of strings, string arrays
|
|
305
|
+
// (`paths`) and numbers, and numbers pass through untouched.
|
|
306
|
+
const emitCtx = (/** @type {string} */ type, /** @type {any} */ data) => emit(type, Object.fromEntries(
|
|
307
|
+
Object.entries(data ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrub(v)
|
|
308
|
+
: (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? scrub(x) : x)) : v)]),
|
|
309
|
+
));
|
|
137
310
|
|
|
138
311
|
// Which worker surface? `clipipe-subscription` drives tools NATIVELY (the CLI
|
|
139
312
|
// owns the turn cycle, BA-16); every other provider runs the Loop. The close,
|
|
@@ -147,10 +320,17 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
147
320
|
// hard-class), and a gold close carries no `cmd`; running `close.cmd.trim()`
|
|
148
321
|
// on it would TypeError out of runJob with NO job-end (the spine would dangle,
|
|
149
322
|
// no spend recorded). Refuse a non-predicate close cleanly, before any tokens.
|
|
150
|
-
|
|
323
|
+
// A STAGED close (PRD v1.28) is an ordered list of command stages and is the
|
|
324
|
+
// go-forward shape; the object form survives only for the declared-but-locked
|
|
325
|
+
// verdict classes (gold/rubric/hitl), which name no command to run.
|
|
326
|
+
// W4: the staging itself lives in ONE place (`stageClose`), shared with
|
|
327
|
+
// `planPrompt` and `validatePlan` — a second copy here is exactly how the
|
|
328
|
+
// runner came to execute a stage neither of the other two could see.
|
|
329
|
+
const stagedClose = stageClose(job.close);
|
|
330
|
+
if (!stagedClose) {
|
|
151
331
|
emit('escalation', {
|
|
152
332
|
category: 'close-unsupported', decisionReady: true,
|
|
153
|
-
decision: `The job's close is a ${job.close.type} close — the plan flow executes
|
|
333
|
+
decision: `The job's close is a ${job.close.type} close — the plan flow executes commands whose exit codes are truth (a staged close, or a single predicate).`,
|
|
154
334
|
options: ['restate the close as a predicate', 'wait for the verdict-classes rung'],
|
|
155
335
|
});
|
|
156
336
|
return 'close-unsupported';
|
|
@@ -170,12 +350,100 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
170
350
|
return 'interpreter-red';
|
|
171
351
|
}
|
|
172
352
|
|
|
173
|
-
|
|
174
|
-
|
|
353
|
+
// T (PRD v1.27/v1.29) — the run's wall clock, STARTED HERE, before the close
|
|
354
|
+
// precheck. The precheck is real wall time and F62 measured close/check gaps as
|
|
355
|
+
// high as 328s on an archived run: a clock created after it would under-report
|
|
356
|
+
// elapsed by that much and hand the planner a balance the run does not have. Time
|
|
357
|
+
// the operator's own instruments too, or the budget is measured against a
|
|
358
|
+
// different run than the one that happened.
|
|
359
|
+
//
|
|
360
|
+
// `job.maxWallMs` is operator input with budgetUsd's status and has NO DEFAULT
|
|
361
|
+
// (hamr's ruling): absent means honestly time-unbounded, and the record says so
|
|
362
|
+
// rather than leaving a reader to infer it. Both numbers are reported — addendum 1
|
|
363
|
+
// measured that a deadline is only readable BETWEEN rounds, so enforcement is
|
|
364
|
+
// requested + the close's own worst case and quoting one number would be F6 in a
|
|
365
|
+
// time coat.
|
|
366
|
+
//
|
|
367
|
+
// W5 — that worst case is per STAGE. `runStages` hands each stage the full
|
|
368
|
+
// `closeTimeoutMs`, so a run whose deadline trips just before a 4-stage close can
|
|
369
|
+
// overshoot by four of them, and the clock is told the count rather than assuming
|
|
370
|
+
// one. `stagedClose` is the SAME staging the runner executes (never a second
|
|
371
|
+
// count), and it is non-null here by the close-unsupported guard above — a close
|
|
372
|
+
// that names no command escalated before this line, so there is no null to thread.
|
|
373
|
+
const clock = createClock({ maxWallMs: job.maxWallMs ?? null, closeStages: stagedClose.length, ...(now ? { now } : {}) });
|
|
374
|
+
const closeTimeoutForReport = closeTimeoutMs ?? 120_000;
|
|
375
|
+
emit('wall-clock', {
|
|
376
|
+
...clock.report(closeTimeoutForReport),
|
|
377
|
+
meaning: clock.bounded
|
|
378
|
+
? 'a between-round deadline; a close already in flight when it trips runs to completion'
|
|
379
|
+
: 'NO time cap was set — time-unbounded by explicit operator choice, never by a default',
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* F64 — ONE categoriser for a throw out of a provider call, so the seams cannot
|
|
384
|
+
* drift. A `HaltError` is the budget gate. The run's OWN deadline coming back as
|
|
385
|
+
* bare-agent's `TimeoutError` (`callTimeoutMs()` is derived from the wall) is a
|
|
386
|
+
* GOVERNANCE stop and rides out as `wall-halt` — filing it `provider-red` would
|
|
387
|
+
* discard the row as a casualty (never evidence, F45/F48) and hand the human a
|
|
388
|
+
* transport-debugging option list for a run that simply ran out of time. A
|
|
389
|
+
* thrower that named its own category keeps it (the standing typed-attribution
|
|
390
|
+
* rule); only an UNNAMED throw is classified here.
|
|
391
|
+
* @param {any} e
|
|
392
|
+
* @returns {{ err: CategorizedError, category: string }} the same throw, stamped,
|
|
393
|
+
* plus the category as a plain string (so a caller reading it never has to
|
|
394
|
+
* re-widen an optional field back to a value)
|
|
395
|
+
*/
|
|
396
|
+
const categorize = (e) => {
|
|
397
|
+
const err = /** @type {CategorizedError} */ (e);
|
|
398
|
+
const category = e instanceof HaltError ? 'cap-halt'
|
|
399
|
+
: (err.category ?? (isWallTimeout(err, clock) ? 'wall-halt' : 'provider-red'));
|
|
400
|
+
err.category = category;
|
|
401
|
+
return { err, category };
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/** the run-level TIME record, one shape wherever the wall stops the run.
|
|
405
|
+
* `cutMidCall` splits the two readings: `true` = the deadline landed INSIDE a
|
|
406
|
+
* provider call (F64, seen as its timeout), `false` = read between steps, the
|
|
407
|
+
* ordinary path. Both are the same stop; conflating them would hide which
|
|
408
|
+
* instrument saw it. */
|
|
409
|
+
const emitWallHalt = (/** @type {object} */ extra) => emit('wall-halt', {
|
|
410
|
+
...clock.report(closeTimeoutForReport),
|
|
411
|
+
meaning: 'not under cap — not "can\'t"',
|
|
412
|
+
cutMidCall: false,
|
|
413
|
+
...extra,
|
|
414
|
+
});
|
|
415
|
+
/** the wall's decision-ready levers, BYTE-IDENTICAL to ralph's own `wall-halt`
|
|
416
|
+
* entry (ralph.js DECISIONS). One category must hand a human ONE option list: which
|
|
417
|
+
* site read the clock is an implementation detail, and a shorter list here quietly
|
|
418
|
+
* withheld the second lever (revise the goal) from every stop this file escalates.
|
|
419
|
+
* Both levers are spec edits — a changed spec hash the runner refuses until it is
|
|
420
|
+
* re-approved — which is why each carries its own re-approval note, and why the
|
|
421
|
+
* caller's detail carries the progress trend that says which lever fits. */
|
|
422
|
+
const WALL_OPTIONS = [
|
|
423
|
+
'raise maxWallMs and rerun (resume-to-cap; a spec edit, so the new hash needs re-approval)',
|
|
424
|
+
'revise the goal/spec so the work fits the time (same re-approval)',
|
|
425
|
+
'abandon the task',
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
const closeOpts = { timeoutMs: closeTimeoutMs, cwd: workdir };
|
|
429
|
+
/** @type {string|undefined} the stage that rendered the LAST close verdict (Layer R's red-set source) */
|
|
430
|
+
let closeStage;
|
|
431
|
+
/** @type {any} the LAST close verdict itself (W-2). A wall stop KEEPS the grade
|
|
432
|
+
* the arbiter has already rendered rather than re-deriving one: past the deadline
|
|
433
|
+
* the run's answer is whatever the close last said, and nothing after the deadline
|
|
434
|
+
* is allowed to change it (hamr: *"keep the grade we already have and stop"*). */
|
|
435
|
+
let lastCloseVerdict;
|
|
436
|
+
/** the close, as ONE verdict: every stage in order, first red wins */
|
|
437
|
+
const judgeClose = async () => {
|
|
438
|
+
const v = await runStages(stagedClose, scrub, closeOpts);
|
|
439
|
+
closeStage = v.stage;
|
|
440
|
+
lastCloseVerdict = v;
|
|
441
|
+
return v;
|
|
442
|
+
};
|
|
175
443
|
|
|
176
444
|
// ── 0a. close precheck (close-first, F17): already-green is a DISTINCT
|
|
177
445
|
// record, zero tokens; a forbidden-zone verdict escalates before any spend
|
|
178
|
-
const pre =
|
|
446
|
+
const pre = await judgeClose();
|
|
179
447
|
emit('close-precheck', { ...pre });
|
|
180
448
|
if (pre.verdict === 'satisfied') return 'already-green';
|
|
181
449
|
const preFault = Object.hasOwn(CLOSE_FAULTS, pre.verdict) ? CLOSE_FAULTS[pre.verdict] : undefined;
|
|
@@ -184,16 +452,25 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
184
452
|
return 'close-red';
|
|
185
453
|
}
|
|
186
454
|
|
|
187
|
-
// ── 0b.
|
|
188
|
-
// before tokens — an unrunnable
|
|
189
|
-
//
|
|
190
|
-
// first). Red and green are both fine: checks decide nothing.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
455
|
+
// ── 0b. check preflight ($0, deterministic): every OFFERABLE stage runs once
|
|
456
|
+
// before tokens — an unrunnable ruler would fault mid-plan after real spend (a
|
|
457
|
+
// frozen rule without a wired detector is prose; here the detector runs
|
|
458
|
+
// first). Red and green are both fine: checks decide nothing and mint nothing.
|
|
459
|
+
// The menu is DERIVED from the close (PRD v1.28) — nobody authored it, so the
|
|
460
|
+
// ruler and the real inspection cannot drift apart.
|
|
461
|
+
const menu = checkMenu(stagedClose);
|
|
462
|
+
emit('check-menu', {
|
|
463
|
+
offered: menu.map((m) => m.name),
|
|
464
|
+
...(menu.length < stagedClose.length
|
|
465
|
+
? { hidden: stagedClose.filter((s2) => s2.offer === false).map((s2) => s2.name), meaning: 'a stage that cannot stand alone as a ruler (a precondition) is not offered — a partial menu is acceptable, never a failure' }
|
|
466
|
+
: {}),
|
|
467
|
+
});
|
|
468
|
+
for (const m of menu) {
|
|
469
|
+
const v = await runStages(m.run, scrub, closeOpts);
|
|
470
|
+
emit('check-preflight', { name: m.name, verdict: v.verdict, ...(m.run.length > 1 ? { chain: m.run.map((s2) => s2.name) } : {}) });
|
|
194
471
|
const f = Object.hasOwn(CLOSE_FAULTS, v.verdict) ? CLOSE_FAULTS[v.verdict] : undefined;
|
|
195
472
|
if (f) {
|
|
196
|
-
emit('escalation', { category: f.category, decisionReady: true, decision: `
|
|
473
|
+
emit('escalation', { category: f.category, decisionReady: true, decision: `Close stage "${m.name}" rendered no judgment at preflight — every plan referencing it would fault mid-run. ${f.decision}`, options: f.options, detail: `${m.name}: ${v.detail ?? ''}` });
|
|
197
474
|
return 'check-red';
|
|
198
475
|
}
|
|
199
476
|
}
|
|
@@ -202,16 +479,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
202
479
|
const ceiling = Array.isArray(job.tools) ? job.tools : [...TOOL_MENU];
|
|
203
480
|
const fencePrefixes = job.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
|
|
204
481
|
const auditPath = join(workdir, 'gate-audit.jsonl');
|
|
205
|
-
const
|
|
482
|
+
const chainByName = new Map(menu.map((m) => [m.name, m.run]));
|
|
483
|
+
// Choose-don't-describe (§4): the offered `tree-changed` scopes, enumerated from
|
|
484
|
+
// the signed fence plus the directories that actually exist beneath it. ONE menu
|
|
485
|
+
// object feeds both the drafting prompt and the validator, so what was offered
|
|
486
|
+
// and what is accepted can never drift apart. Discovery is best-effort — a
|
|
487
|
+
// missing or unreadable fence directory degrades to the signed entries alone
|
|
488
|
+
// (which `snapshotScope` already handles as an empty snapshot), never a throw.
|
|
489
|
+
const discoveredDirs = job.writeScope.flatMap((/** @type {string} */ g) => {
|
|
490
|
+
const prefix = globToPrefix(g);
|
|
491
|
+
try {
|
|
492
|
+
return readdirSync(join(workdir, prefix), { recursive: true, withFileTypes: true })
|
|
493
|
+
.filter((d) => d.isDirectory())
|
|
494
|
+
.map((d) => `${prefix}/${relative(join(workdir, prefix), join(d.parentPath ?? d.path, d.name))}`);
|
|
495
|
+
} catch { return []; }
|
|
496
|
+
});
|
|
497
|
+
const scopeMenu = legalScopes(job.writeScope, discoveredDirs);
|
|
498
|
+
// No silent caps: MAX_SCOPE_MENU can drop deep directories, and a menu that
|
|
499
|
+
// reads as complete when it is not is the blind-instrument class. Report BOTH
|
|
500
|
+
// counts so a truncation is visible in the record rather than inferred.
|
|
501
|
+
const offerable = legalScopes(job.writeScope, discoveredDirs, Infinity).length;
|
|
502
|
+
emit('scope-menu', {
|
|
503
|
+
offered: scopeMenu,
|
|
504
|
+
...(offerable > scopeMenu.length
|
|
505
|
+
? { truncated: true, offerableCount: offerable, cap: MAX_SCOPE_MENU, meaning: 'deep directories were dropped from the menu; they remain reachable via a parent scope' }
|
|
506
|
+
: { truncated: false }),
|
|
507
|
+
});
|
|
206
508
|
|
|
207
509
|
/** the check-passes seam evalExits delegates to: the FULL runClose machinery
|
|
208
510
|
* per signed check; a forbidden-zone verdict rides out as `fault` by name so
|
|
209
511
|
* the micro-loop escalates (or F32-routes a crash) instead of faking a gap */
|
|
210
512
|
const runCheck = async (/** @type {string} */ name) => {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
513
|
+
const chain = chainByName.get(name);
|
|
514
|
+
// UNREACHABLE through runPlan and deliberately kept: validatePlan reds
|
|
515
|
+
// `check-unknown` against `checkMenu(spec.close)` — the SAME derivation that
|
|
516
|
+
// built this map from the SAME close — so a name that got here was already
|
|
517
|
+
// proven to be on the menu. Defence in depth against a future second
|
|
518
|
+
// derivation, not a live path; the reachable guard is the validator's, and
|
|
519
|
+
// that one is mutation-covered.
|
|
520
|
+
if (!chain) return { pass: false, fault: 'failed', gap: `no offered close stage named "${name}"` };
|
|
521
|
+
const v = await runStages(chain, scrub, closeOpts);
|
|
522
|
+
emit('check-run', { name, verdict: v.verdict, ...(v.stage && v.stage !== name ? { stage: v.stage } : {}), ...(v.exitCode !== undefined ? { exitCode: v.exitCode } : {}) });
|
|
215
523
|
if (v.verdict === 'satisfied') return { pass: true };
|
|
216
524
|
if (v.verdict === 'needs_revision') return { pass: false, gap: v.gap };
|
|
217
525
|
return { pass: false, fault: v.verdict, gap: v.detail };
|
|
@@ -228,18 +536,35 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
228
536
|
* denied, the wallet as its budget), granted tools only (the menu IS the
|
|
229
537
|
* grant), per-attempt round bound via loop.stop() (F20), every round metered
|
|
230
538
|
* with a phase label (F12).
|
|
231
|
-
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean}} o
|
|
539
|
+
* @param {{granted: string[], phase: string, attemptRounds: number, attempts: number, writable: boolean, root?: ReturnType<typeof createRoot>|null, fence?: string[]|null, workerProvider?: any}} o
|
|
540
|
+
* `root` (Layer R): when present, the worker's write-class actions are teed
|
|
541
|
+
* for the within-run ratchet — staged before the gate decides, discarded on
|
|
542
|
+
* deny/halt, settled to landed-or-not after execution (the two axes, F43/F7).
|
|
543
|
+
* Wired ONLY on the Loop path: the native session exposes no onToolResult seam.
|
|
232
544
|
*/
|
|
233
|
-
async function mkWorker({ granted, phase, attemptRounds, attempts, writable }) {
|
|
545
|
+
async function mkWorker({ granted, phase, attemptRounds, attempts, writable, root = null, fence = null, workerProvider = null }) {
|
|
234
546
|
const gate = new Gate({
|
|
235
547
|
fs: {
|
|
236
|
-
|
|
548
|
+
// P: a step's `scope` narrows the fence — the per-step prefixes are always a
|
|
549
|
+
// SUBSET of the signed fence (validator: scope ∈ the same menu tree-changed
|
|
550
|
+
// uses), so this can only tighten, never widen
|
|
551
|
+
writeScope: writable ? (fence ?? fencePrefixes) : [],
|
|
237
552
|
readScope: [workdir],
|
|
238
553
|
deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')],
|
|
239
554
|
},
|
|
240
555
|
budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
|
|
241
556
|
limits: { maxTurns: attemptRounds * (attempts + 1) },
|
|
242
557
|
audit: { path: auditPath },
|
|
558
|
+
// The gate audit is the THIRD persistent, append-only channel (beside the
|
|
559
|
+
// spine and the ctx-verb events) and it lives IN THE TREE, so it gets the
|
|
560
|
+
// SAME ONE inventory. bareguard's default-on backstop covers only
|
|
561
|
+
// apiKey/authorization/`Bearer …`/`sk-…` — measured: a `ghp_` token landed
|
|
562
|
+
// in cleartext through every model-authored identifier the action carries
|
|
563
|
+
// (an isolate verb's `id`, a path the worker spelled), and masked under
|
|
564
|
+
// these patterns. Content is already unreachable here (`toolAction` reduces
|
|
565
|
+
// a write to `{bytes}`), but the identifiers are raw model text, and a log
|
|
566
|
+
// that captures a key captures it forever.
|
|
567
|
+
secrets: { patterns: SECRET_PATTERNS },
|
|
243
568
|
humanChannel: async () => ({ decision: 'terminate' }),
|
|
244
569
|
});
|
|
245
570
|
await gate.init();
|
|
@@ -259,7 +584,69 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
259
584
|
return [...paths];
|
|
260
585
|
} catch { return []; }
|
|
261
586
|
};
|
|
262
|
-
|
|
587
|
+
// Layer R tee (design record 2026-07-19; created per-step by executeStep).
|
|
588
|
+
// The translator STAGES write/edit content and snapshots the pre-write hash
|
|
589
|
+
// BEFORE the gate decides (Finding 6); the policy wrapper DISCARDS on a deny
|
|
590
|
+
// or a halt (the tool never runs); onToolOutcome SETTLES landed-vs-not after
|
|
591
|
+
// execution (Finding 7 — the gate's allow is intent, the file is outcome).
|
|
592
|
+
// All no-ops when root is null: every scout/drafter and every native worker.
|
|
593
|
+
const fileHash = (/** @type {string} */ p) => {
|
|
594
|
+
try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
|
|
595
|
+
};
|
|
596
|
+
/** @type {{path: string, content: string, type: string, before: string|null}|null} the write-class action awaiting its OUTCOME */
|
|
597
|
+
let pendingProbe = null;
|
|
598
|
+
const teeingTranslator = (/** @type {string} */ n, /** @type {any} */ a) => {
|
|
599
|
+
// classify by action TYPE, never a name list (the workerWrites filter uses
|
|
600
|
+
// the same write|edit test — a third enumeration would let a future
|
|
601
|
+
// write-class verb bypass the tee, the blind-instrument class)
|
|
602
|
+
const act = toolAction(n, a, workdir);
|
|
603
|
+
if (root && (act.type === 'write' || act.type === 'edit')) {
|
|
604
|
+
const path = /** @type {string} */ (act.path);
|
|
605
|
+
const content = String((act.type === 'edit' ? a?.newText : a?.content) ?? '');
|
|
606
|
+
root.stageWrite(path, content); // content read off RAW args, never onto the action (the audit stays bytes-only)
|
|
607
|
+
pendingProbe = { path, content, type: act.type, before: fileHash(path) };
|
|
608
|
+
}
|
|
609
|
+
return act;
|
|
610
|
+
};
|
|
611
|
+
const { policy: gatePolicy, onLlmResult } = wireGate(gate, { actionTranslator: teeingTranslator });
|
|
612
|
+
// Settle the stage on the verdict. A DENY/HALT means the tool never runs, so
|
|
613
|
+
// the outcome seam below never fires for it — discard here (an allow is NOT
|
|
614
|
+
// settled here: the bytes are not written yet; settling on the verdict is the
|
|
615
|
+
// exact Finding 7 defect). The catch RE-THROWS so cap-halt routing still reads
|
|
616
|
+
// the throw. Non-write actions stage nothing, so both settlements are no-ops.
|
|
617
|
+
const policy = root
|
|
618
|
+
? async (/** @type {string} */ n, /** @type {any} */ a, /** @type {any} */ c) => {
|
|
619
|
+
try {
|
|
620
|
+
const verdict = await gatePolicy(n, a, c);
|
|
621
|
+
if (verdict !== true) { root.discardWrite(); pendingProbe = null; }
|
|
622
|
+
return verdict;
|
|
623
|
+
} catch (e) { root.discardWrite(); pendingProbe = null; throw e; }
|
|
624
|
+
}
|
|
625
|
+
: gatePolicy;
|
|
626
|
+
/**
|
|
627
|
+
* Finding 7 — settle the staged write on what the tool ACTUALLY did. Fires
|
|
628
|
+
* after every tool.execute (bare-agent loop.js), success or error, and tool
|
|
629
|
+
* calls run strictly sequentially, so exactly one probe is ever in flight. A
|
|
630
|
+
* probe that never reaches here (a halt out of a tool body) leaves the stage
|
|
631
|
+
* unsettled — correct: nothing landed, and the attempt boundary drops it.
|
|
632
|
+
* @param {{result?: any}} [info] the tool's return value
|
|
633
|
+
*/
|
|
634
|
+
const onToolOutcome = async (info) => {
|
|
635
|
+
if (!root || !pendingProbe) return;
|
|
636
|
+
const { path: p, content, type, before } = pendingProbe;
|
|
637
|
+
pendingProbe = null;
|
|
638
|
+
let after = null;
|
|
639
|
+
try { after = readFileSync(p, 'utf8'); } catch { /* absent → after stays null */ }
|
|
640
|
+
let landed = after !== null && createHash('sha256').update(after, 'utf8').digest('hex') !== before;
|
|
641
|
+
if (!landed && after !== null) {
|
|
642
|
+
// no-byte-change: a write always writes (identical bytes ⇒ content IS the
|
|
643
|
+
// file, exact-equal never substring); a no-op edit is an idempotent apply
|
|
644
|
+
// OR a missed anchor — the tool RESULT is the only honest signal (F2)
|
|
645
|
+
if (type === 'write') landed = after === content;
|
|
646
|
+
else landed = typeof info?.result === 'string' && info.result.startsWith('edited ');
|
|
647
|
+
}
|
|
648
|
+
root.settleWrite(landed);
|
|
649
|
+
};
|
|
263
650
|
/** @type {number|string|undefined} */
|
|
264
651
|
let roundIteration;
|
|
265
652
|
let roundsThisAttempt = 0;
|
|
@@ -285,11 +672,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
285
672
|
}
|
|
286
673
|
}
|
|
287
674
|
const ctx = [...CTX_TOOLS].some((t) => grantedNames.has(t))
|
|
288
|
-
? createCtxTools(lc, workdir,
|
|
675
|
+
? createCtxTools(lc, workdir, emitCtx).filter((t) => grantedNames.has(t.name))
|
|
289
676
|
: [];
|
|
290
|
-
|
|
677
|
+
// LC-3 (litectx 0.31.0): cooperative yield — index() is async but its work is sync CPU,
|
|
678
|
+
// and the default pass holds THIS event loop (the fuse's timers, the wall clock, the lag
|
|
679
|
+
// sampler all live here) for its full duration: measured 4.4s at 4.5% liveness on a
|
|
680
|
+
// 155-file force pass. With yield:true the worst single block measured 189–252ms (n=4),
|
|
681
|
+
// below anything a second/minute-scale timer can perceive. Store output is byte-identical.
|
|
682
|
+
if (ctx.length) await lc.index({ yield: true });
|
|
291
683
|
const toolDefs = [...shell, ...ctx];
|
|
292
|
-
|
|
684
|
+
// F19 per VERB (P, corrected 2026-07-31): a strategy SENTENCE rides only with
|
|
685
|
+
// the verb it names. Gating per COMPONENT was close but over-reached — a
|
|
686
|
+
// worker granted only `impact` must not be steered to tools it lacks, and the
|
|
687
|
+
// isolate/retrieval paragraphs named other components' verbs outright. The
|
|
688
|
+
// assembly lives in tools.js beside the prose it composes, and is asserted
|
|
689
|
+
// byte-identical to the full-component paragraphs on a full grant.
|
|
690
|
+
const system = PERSONA_TOOLS + strategyFor(granted)
|
|
293
691
|
+ (native && grantedNames.has(TOOL_BY_VERB.read) ? NATIVE_READ_STRATEGY : '');
|
|
294
692
|
/** @param {any} u @returns {{inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheCreationTokens: number}} */
|
|
295
693
|
const usageOf = (u) => ({ inputTokens: u?.inputTokens ?? 0, outputTokens: u?.outputTokens ?? 0, cacheReadTokens: u?.cacheReadTokens ?? 0, cacheCreationTokens: u?.cacheCreationTokens ?? 0 });
|
|
@@ -323,14 +721,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
323
721
|
const provider2 = /** @type {any} */ (nativeProvider)({ policy, onTurn: nativeMetered, maxTurns: attemptRounds, hasTools: true });
|
|
324
722
|
const loop = new Loop({ provider: provider2, system }); // no Loop policy / no cacheMessages: the CLI owns the transcript
|
|
325
723
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
724
|
+
/** F59: unavailable on native — a toolless native session fires no onTurn and
|
|
725
|
+
* reports NO cost (F48/BA-16 live-verified), so a recovery round there would be
|
|
726
|
+
* unmetered spend. The caller treats a null return as "no recovery possible". */
|
|
727
|
+
const askFrom = async () => null;
|
|
326
728
|
const ask = async (prompt, defs = toolDefs) => {
|
|
327
729
|
let r;
|
|
328
730
|
try {
|
|
329
731
|
r = await loop.run([{ role: 'user', content: prompt }], defs, { maxTokens: 32000 });
|
|
330
732
|
} catch (e) {
|
|
331
|
-
|
|
332
|
-
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
333
|
-
throw err;
|
|
733
|
+
throw categorize(e).err;
|
|
334
734
|
}
|
|
335
735
|
// a maxTurns session is a BOUNDED attempt, not an escalation — the same
|
|
336
736
|
// role loop.stop() plays on the Loop path: judge the partial work and
|
|
@@ -352,7 +752,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
352
752
|
}
|
|
353
753
|
return r;
|
|
354
754
|
};
|
|
355
|
-
return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
|
|
755
|
+
return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
|
|
356
756
|
}
|
|
357
757
|
|
|
358
758
|
// ── LOOP path: the injected provider (anthropic-api and every other
|
|
@@ -364,35 +764,123 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
364
764
|
// never invisible (F6/F44). The gate policy is wired but idle (no tools).
|
|
365
765
|
const loopProvider = native
|
|
366
766
|
? /** @type {any} */ (nativeProvider)({ policy, maxTurns: attemptRounds * (attempts + 1), hasTools: false })
|
|
367
|
-
: provider;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
767
|
+
: (workerProvider ?? provider);
|
|
768
|
+
// F66 — the stall watchdog. bare-agent's `timeoutMs` bounds socket INACTIVITY,
|
|
769
|
+
// not call duration (provider-http.js `req.setTimeout` resets on every byte),
|
|
770
|
+
// so a connection that stays alive while producing nothing is invisible to it:
|
|
771
|
+
// U run ms3197n8 hung 274 minutes inside ONE call and died ECONNRESET with the
|
|
772
|
+
// bound correctly passed. The heartbeat here is a completed ROUND, which is the
|
|
773
|
+
// one signal that cannot be faked by a live socket. One watch PER WORKER, so
|
|
774
|
+
// the ceiling counts stalls across the step rather than resetting each call.
|
|
775
|
+
// L4 — the watch is handed the run's WALL. Self-heal is what a run does with
|
|
776
|
+
// time left: past the deadline a reissue is spend the run already declined to
|
|
777
|
+
// authorize, and this is the one path no other clock read covers (a stall
|
|
778
|
+
// completes no round and returns from no step, which are the only two places
|
|
779
|
+
// `clock.expired()` is otherwise consulted). Same clock object as every other
|
|
780
|
+
// reader — never a second time source.
|
|
781
|
+
const stallWatch = createStallWatch({
|
|
782
|
+
onStall: (n) => emit('stall', { phase, iteration: roundIteration, stall: n, stallMs: STALL_MS, maxStalls: MAX_STALLS }),
|
|
783
|
+
expired: () => clock.expired(),
|
|
784
|
+
});
|
|
785
|
+
/**
|
|
786
|
+
* ONE Loop PER ISSUED CALL, and every callback on it scoped to that call's
|
|
787
|
+
* generation. After a stall there are TWO calls alive: the abandoned one, whose
|
|
788
|
+
* socket keeps streaming rounds (ms3197n8's lived 274min past the point we moved
|
|
789
|
+
* on), and the reissue. Sharing one Loop between them shares `_stopped`, so a
|
|
790
|
+
* corpse's round bound would cut the LIVE call mid-flight; sharing one callback
|
|
791
|
+
* left the corpse beating the watch that was timing its replacement, which meant
|
|
792
|
+
* the replacement could hang forever without tripping — the fuse feeding itself
|
|
793
|
+
* from the thing it abandoned. A Loop is a plain object over the shared provider,
|
|
794
|
+
* holds no cross-run state (`run()` takes the transcript per call), so one per
|
|
795
|
+
* issue costs nothing.
|
|
796
|
+
*
|
|
797
|
+
* RESIDUAL, documented not fixed: both generations still execute tools through
|
|
798
|
+
* the SAME Gate (`policy`) and the same audit — deliberately, because the audit
|
|
799
|
+
* is what `workerWrites` reads and a per-generation gate would split the run's
|
|
800
|
+
* own books. Concurrent tool calls across a stall therefore interleave on one
|
|
801
|
+
* fence. The fence itself is decision-per-call and order-independent, so the
|
|
802
|
+
* denial semantics hold; what is not modelled is two calls writing the same path
|
|
803
|
+
* in the same window. Latent today (Layer R off, and a stall is rare).
|
|
804
|
+
* @param {number} gen the generation `stallWatch` handed this call
|
|
805
|
+
*/
|
|
806
|
+
const newLoop = (gen) => {
|
|
807
|
+
/** @type {any} */
|
|
808
|
+
let self = null;
|
|
809
|
+
/** @param {{costUsd?: number|null, pricing?: string|null, usage?: any, kind?: string}} arg */
|
|
810
|
+
const metered = async (arg) => {
|
|
811
|
+
// The meter fires for EVERY generation, live or abandoned. An orphaned round
|
|
812
|
+
// was really billed, and spend that no instrument sees is the one thing this
|
|
813
|
+
// repo never ships (F6/F44 — unknown is reported as unknown, never as zero;
|
|
814
|
+
// stall.js says it plainly: a reissue can pay twice, and the wallet is what
|
|
815
|
+
// caps that).
|
|
816
|
+
emit('worker-round', {
|
|
817
|
+
phase, iteration: roundIteration, kind: arg?.kind ?? 'turn',
|
|
818
|
+
costUsd: arg?.costUsd ?? null, pricing: arg?.pricing ?? null,
|
|
819
|
+
tokens: (arg?.usage?.inputTokens ?? 0) + (arg?.usage?.outputTokens ?? 0),
|
|
820
|
+
usage: usageOf(arg?.usage),
|
|
821
|
+
});
|
|
822
|
+
// Everything BELOW here belongs to the live call only. A superseded call must
|
|
823
|
+
// not beat the watch (that is the whole watchdog), must not spend the step's
|
|
824
|
+
// round bound (stall.js: "the step's round bound is untouched" by a reissue —
|
|
825
|
+
// the wallet is what bounds a reissue's spend), and must not announce a bound
|
|
826
|
+
// it cannot own.
|
|
827
|
+
if (!stallWatch.isCurrent(gen)) return onLlmResult(arg);
|
|
828
|
+
// F66 — the heartbeat is ANY result coming back from the model layer, not
|
|
829
|
+
// only a `turn`. The question the watchdog asks is "is the model still
|
|
830
|
+
// answering", and a non-turn result answers it just as well; gating the beat
|
|
831
|
+
// on `kind` would arm a false stall on any round shape we don't enumerate
|
|
832
|
+
// here, which is the blind-instrument class in miniature.
|
|
833
|
+
stallWatch.beat(gen);
|
|
834
|
+
if ((arg?.kind ?? 'turn') === 'turn') {
|
|
835
|
+
roundsThisAttempt += 1;
|
|
836
|
+
|
|
837
|
+
// The wall deadline rides the SAME seam as the round bound, because it is
|
|
838
|
+
// the only seam that exists: loop.stop() is read at the round boundary and
|
|
839
|
+
// cannot cut an in-flight call (F61/C1, measured — fired at 500ms, returned
|
|
840
|
+
// at 4,018ms). Stopping here judges the partial work and feeds the gap
|
|
841
|
+
// forward exactly as a round-bounded attempt does — this seam never decides a
|
|
842
|
+
// terminal. Three sites do, each reading the clock with nothing in flight: the
|
|
843
|
+
// step loop after a step returns (between steps), the head of a step's attempt
|
|
844
|
+
// (a step that would BEGIN past the deadline is never funded), and the head of
|
|
845
|
+
// a close-fix iteration (W-2 — the run stops on the verdict already minted).
|
|
846
|
+
if (clock.expired()) {
|
|
847
|
+
attemptBounded = roundIteration;
|
|
848
|
+
emit('wall-bounded', { phase, iteration: roundIteration, ...clock.report(closeTimeoutForReport) });
|
|
849
|
+
self.stop();
|
|
850
|
+
} else if (roundsThisAttempt >= attemptRounds) {
|
|
851
|
+
attemptBounded = roundIteration;
|
|
852
|
+
emit('attempt-bounded', { phase, iteration: roundIteration, rounds: roundsThisAttempt, cap: attemptRounds });
|
|
853
|
+
self.stop();
|
|
854
|
+
}
|
|
382
855
|
}
|
|
383
|
-
|
|
384
|
-
|
|
856
|
+
return onLlmResult(arg);
|
|
857
|
+
};
|
|
858
|
+
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
859
|
+
return self;
|
|
385
860
|
};
|
|
386
|
-
const loop = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered });
|
|
387
861
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
|
862
|
+
/**
|
|
863
|
+
* F59 — continue an EXISTING conversation for exactly one TOOLLESS round.
|
|
864
|
+
* `defs` is `[]`, so text is the only output the model can produce: the summary
|
|
865
|
+
* round is guaranteed by CONSTRUCTION, never requested in prose (F19/F37 — a
|
|
866
|
+
* pacing mandate in the persona was violated 6/6). Meters like any other round
|
|
867
|
+
* (F12); the bound has already fired, so this is a deliberate +1 paid once.
|
|
868
|
+
* @param {any[]} msgs the prior conversation (loop.run's `msgs`)
|
|
869
|
+
* @param {string} prompt
|
|
870
|
+
*/
|
|
871
|
+
const askFrom = async (msgs, prompt) => {
|
|
872
|
+
try {
|
|
873
|
+
return await stallWatch.watch((gen) => newLoop(gen).run([...msgs, { role: 'user', content: prompt }], [], { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
|
|
874
|
+
} catch (e) {
|
|
875
|
+
throw categorize(e).err;
|
|
876
|
+
}
|
|
877
|
+
};
|
|
388
878
|
const ask = async (prompt, defs = toolDefs) => {
|
|
389
879
|
let r;
|
|
390
880
|
try {
|
|
391
|
-
r = await
|
|
881
|
+
r = await stallWatch.watch((gen) => newLoop(gen).run([{ role: 'user', content: prompt }], defs, { cacheMessages: true, maxTokens: 32000, timeoutMs: clock.callTimeoutMs() }));
|
|
392
882
|
} catch (e) {
|
|
393
|
-
|
|
394
|
-
err.category = e instanceof HaltError ? 'cap-halt' : (err.category ?? 'provider-red');
|
|
395
|
-
throw err;
|
|
883
|
+
throw categorize(e).err;
|
|
396
884
|
}
|
|
397
885
|
// same error-return taxonomy as interpret's ask (one map, same doctrine):
|
|
398
886
|
// halt → cap-halt, denial streak → gate-red, API truncation → provider-red
|
|
@@ -407,29 +895,47 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
407
895
|
}
|
|
408
896
|
return r;
|
|
409
897
|
};
|
|
410
|
-
return { ask, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
|
|
898
|
+
return { ask, askFrom, workerWrites, setIteration: (/** @type {number|string} */ i) => { roundIteration = i; roundsThisAttempt = 0; }, wasBounded: () => attemptBounded };
|
|
411
899
|
}
|
|
412
900
|
|
|
413
|
-
/** relay a throw from OUTSIDE ralph (scout/plan drafting) as its honest
|
|
901
|
+
/** relay a throw from OUTSIDE ralph (scout/plan drafting/fix) as its honest
|
|
902
|
+
* category. Three terminals, never collapsed: the budget gate, the wall clock
|
|
903
|
+
* (F64 — the scout and the drafter run under the same derived call timeout the
|
|
904
|
+
* worker does, so this path needs the same split or it is the blinder route),
|
|
905
|
+
* and transport. */
|
|
414
906
|
const relay = (/** @type {any} */ e, /** @type {string} */ phase) => {
|
|
415
|
-
const category =
|
|
416
|
-
|
|
907
|
+
const { category } = categorize(e);
|
|
908
|
+
const detail = String(e?.message ?? e);
|
|
909
|
+
if (category === 'cap-halt') emit('cap-halt', { category, meaning: 'not under cap — not "can\'t"', detail });
|
|
910
|
+
if (category === 'wall-halt') emitWallHalt({ cutMidCall: true, phase });
|
|
911
|
+
const DECIDE = {
|
|
912
|
+
'cap-halt': [`The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.`, ['raise the job budget and rerun', 'abandon the run']],
|
|
913
|
+
'wall-halt': [`The run reached its wall-clock cap during ${phase}. Time ran out, not capability.`, WALL_OPTIONS],
|
|
914
|
+
// A stall OUTSIDE a step (scout, drafting, fix) has no step to replan, so it
|
|
915
|
+
// surfaces — but it is named, never laundered into provider-red. The socket
|
|
916
|
+
// did not fail here; the model stopped answering and reissuing did not help,
|
|
917
|
+
// and those are different diagnoses with different remedies (F45/F48: a
|
|
918
|
+
// casualty is never evidence, and a governance stop is never a casualty).
|
|
919
|
+
'step-stalled': [`The model stopped producing rounds during ${phase} and reissuing the call did not recover it.`, ['retry the run', 'check provider status', 'abandon the run']],
|
|
920
|
+
};
|
|
921
|
+
const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
|
|
922
|
+
?? [`The ${phase} call failed (${category}) — no result exists.`, ['retry the run', 'fix the provider binding', 'abandon the run']];
|
|
417
923
|
emit('escalation', {
|
|
418
|
-
category, decisionReady: true, phase,
|
|
419
|
-
decision: category === 'cap-halt' ? `The budget gate tripped during ${phase} — the wallet cannot fund the plan flow.` : `The ${phase} call failed (${category}) — no result exists.`,
|
|
420
|
-
options: category === 'cap-halt' ? ['raise the job budget and rerun', 'abandon the run'] : ['retry the run', 'fix the provider binding', 'abandon the run'],
|
|
421
|
-
detail: String(e?.message ?? e),
|
|
924
|
+
category, decisionReady: true, phase, decision, options, detail,
|
|
422
925
|
...(typeof e?.lib === 'string' ? { lib: e.lib } : {}),
|
|
423
926
|
});
|
|
424
|
-
|
|
927
|
+
// `step-stalled` rides out as ITSELF, not as provider-red. Naming the
|
|
928
|
+
// escalation while returning a casualty label would launder a governance stop
|
|
929
|
+
// into transport noise at exactly the layer the readout reads.
|
|
930
|
+
return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' ? category : 'provider-red';
|
|
425
931
|
};
|
|
426
932
|
|
|
427
933
|
// ── 1. SCOUT — read-only by construction: the write-class verbs are simply
|
|
428
934
|
// not in its menu (the menu is the grant), and its gate fences zero paths
|
|
429
935
|
let scoutBlob = '';
|
|
430
|
-
emit('scout-start', { rounds:
|
|
936
|
+
emit('scout-start', { rounds: scoutRounds });
|
|
431
937
|
try {
|
|
432
|
-
const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v)), phase: 'scout', attemptRounds:
|
|
938
|
+
const scout = await mkWorker({ granted: ceiling.filter((v) => !WRITE_VERBS.includes(v) && !STORE_VERBS.includes(v)), phase: 'scout', attemptRounds: scoutRounds, attempts: 1, writable: false });
|
|
433
939
|
scout.setIteration(1);
|
|
434
940
|
const r = await scout.ask([
|
|
435
941
|
'Survey this repository READ-ONLY for the goal below. Report: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Be concise — your notes brief a planner that cannot see the repository.',
|
|
@@ -438,10 +944,37 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
438
944
|
pre.gap && `The job's verification is currently failing. Its output on the tree as it stands:\n${pre.gap}`,
|
|
439
945
|
].filter(Boolean).join('\n\n'));
|
|
440
946
|
scoutBlob = scrub(r.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
947
|
+
// F59 — RESERVE the summary round. The round bound is enforced from the
|
|
948
|
+
// metering callback (loop.stop), so a scout still calling tools on its last
|
|
949
|
+
// round is halted mid-tool-use and `text` is empty: it spends the whole
|
|
950
|
+
// allowance exploring and never writes the survey that is its ONLY
|
|
951
|
+
// deliverable. Measured on 15 of 18 archived runs, which then drafted from
|
|
952
|
+
// `(no scout notes)` while still paying ~12% of the run (F55) for the walk.
|
|
953
|
+
// The recovery is one TOOLLESS round on the SAME conversation (`r.msgs`
|
|
954
|
+
// carries the exploration): with no tools offered, text is the only possible
|
|
955
|
+
// output — a mechanical guarantee, never a prose plea to summarise (F19/F37:
|
|
956
|
+
// a pacing mandate in the persona was violated 6/6).
|
|
957
|
+
// The trigger is BOUNDED-and-short, never short alone: a terse survey that
|
|
958
|
+
// finished on its own was not cut off and has nothing to recover. The archive
|
|
959
|
+
// separates cleanly on exactly this pair — every truncated scout was bounded
|
|
960
|
+
// with 0-86 bytes, and the two that finished naturally wrote 5991/8056.
|
|
961
|
+
if (scout.wasBounded() && Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES && Array.isArray(r.msgs) && r.msgs.length) {
|
|
962
|
+
emit('scout-truncated', { bytes: Buffer.byteLength(scoutBlob) });
|
|
963
|
+
const s2 = await scout.askFrom(r.msgs, 'You are out of exploration turns. Write your survey NOW, from what you have already read: the relevant layout, the key files and symbols, and your best hypothesis about what the work requires. Text only.');
|
|
964
|
+
const recovered = scrub(s2?.text ?? '').slice(0, SCOUT_BLOB_MAX);
|
|
965
|
+
if (Buffer.byteLength(recovered) > Buffer.byteLength(scoutBlob)) scoutBlob = recovered;
|
|
966
|
+
}
|
|
441
967
|
} catch (e) {
|
|
442
968
|
return relay(e, 'scout');
|
|
443
969
|
}
|
|
444
970
|
emit('scout-result', { bytes: Buffer.byteLength(scoutBlob) });
|
|
971
|
+
// F59 — the LOUD half. `scout-result {bytes: 0}` was emitted faithfully 18 times
|
|
972
|
+
// out of 18 and read by nobody: an instrument that works but has no consumer is a
|
|
973
|
+
// log line, not evidence. A survey that is still empty after its reserved round is
|
|
974
|
+
// now a NAMED condition. It is never a halt — F59's own evidence is that 3 of 5
|
|
975
|
+
// archived greens had an empty scout, so failing the run here would be a worse
|
|
976
|
+
// error than the one being fixed.
|
|
977
|
+
if (Buffer.byteLength(scoutBlob) < SCOUT_MIN_BYTES) emit('scout-empty', { bytes: Buffer.byteLength(scoutBlob), rounds: scoutRounds });
|
|
445
978
|
// F6 in-flight: an unpriced round means the cap cannot govern spend it cannot
|
|
446
979
|
// see — halt at the boundary rather than run the whole plan blind (the caller
|
|
447
980
|
// emits pricing-red; runPlan just stops burning tokens). Legacy halts per-step.
|
|
@@ -457,20 +990,41 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
457
990
|
* total run spend can never exceed the signed budget (advertised == enforced,
|
|
458
991
|
* the hard line) — a drafter built once at full budget would let the replan
|
|
459
992
|
* draft spend against money the steps had already consumed.
|
|
460
|
-
* @param {string} phase @param {string|null} failure
|
|
461
|
-
|
|
993
|
+
* @param {string} phase @param {string|null} failure
|
|
994
|
+
* @param {string} [progress] the meter's read, replan only (A's adaptation channel) */
|
|
995
|
+
const obtainPlan = async (phase, failure, progress) => {
|
|
462
996
|
const drafter = await mkWorker({ granted: [], phase: 'plan', attemptRounds: 2, attempts: 3, writable: false });
|
|
997
|
+
// The BALANCE, read live at each draft — never a snapshot and never a rate
|
|
998
|
+
// (addendum 2). At draft it is the whole budget (scale); at replan it is what
|
|
999
|
+
// is actually left, which is the adaptation channel.
|
|
1000
|
+
const materials = { balanceUsd: remainingUsd(), remainingMs: clock.bounded ? clock.remainingMs() : null, ...(progress ? { progress } : {}) };
|
|
1001
|
+
emit('materials', { phase, ...materials });
|
|
463
1002
|
const draftPlan = async (/** @type {any[]|null} */ reds) => {
|
|
464
1003
|
drafter.setIteration(reds ? 'redraft' : 'draft');
|
|
465
|
-
|
|
1004
|
+
// capRuns is the SAME number validatePlan bounds `attempts` against below —
|
|
1005
|
+
// one source, so the prompt and the validator cannot drift apart
|
|
1006
|
+
const r = await drafter.ask(planPrompt(job, scoutBlob, reds, maxStepRounds, failure, scopeMenu, materials, capRuns), []);
|
|
466
1007
|
return extractArtifact(r.text).code ?? '';
|
|
467
1008
|
};
|
|
1009
|
+
// Scrubbed HERE, once, before any consumer reads them — the judge() precedent
|
|
1010
|
+
// and the same V8-quoting class it was written for: `parse-error`'s detail is
|
|
1011
|
+
// JSON.parse's own message, and V8 quotes a window of the SOURCE inside it (a
|
|
1012
|
+
// body of 20 chars or fewer is quoted whole). That source is the DRAFT, which
|
|
1013
|
+
// the model wrote, so a red can carry model bytes onto `plan-validate`, onto
|
|
1014
|
+
// `plan-red`, and back into the redraft prompt — and the spine is append-only
|
|
1015
|
+
// forever (the hard line). The boundary is the right place and not the three
|
|
1016
|
+
// emit sites: scrubbing here makes the leak inexpressible, scrubbing each
|
|
1017
|
+
// consumer makes it something the next consumer has to remember.
|
|
1018
|
+
const validate = (/** @type {string} */ t) => {
|
|
1019
|
+
const v = validatePlan(t, { job, maxStepRounds, scopes: scopeMenu, capRuns });
|
|
1020
|
+
return { ...v, reds: v.reds.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r)) };
|
|
1021
|
+
};
|
|
468
1022
|
let text = await draftPlan(null);
|
|
469
|
-
let pv =
|
|
1023
|
+
let pv = validate(text);
|
|
470
1024
|
emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-1` });
|
|
471
1025
|
if (!pv.ok) {
|
|
472
1026
|
text = await draftPlan(pv.reds);
|
|
473
|
-
pv =
|
|
1027
|
+
pv = validate(text);
|
|
474
1028
|
emit('plan-validate', { ok: pv.ok, reds: pv.reds, phase: `${phase}-2` });
|
|
475
1029
|
}
|
|
476
1030
|
return pv;
|
|
@@ -497,6 +1051,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
497
1051
|
const stepOutcomes = [];
|
|
498
1052
|
let replanned = false;
|
|
499
1053
|
const planExecuted = () => emit('plan-executed', { steps: stepOutcomes, replanned });
|
|
1054
|
+
/** @type {object|null} W-2 at the STEP site — set when the WALL refused to open a
|
|
1055
|
+
* step's attempt. It carries that stop's own record fields, and its presence is what
|
|
1056
|
+
* lets the step loop's terminal tell this reading apart from F64's mid-call one
|
|
1057
|
+
* without consulting the clock a second time (two clock reads for one event is how
|
|
1058
|
+
* the two instruments come to disagree). Exactly the fix loop's `wallStop`, one
|
|
1059
|
+
* layer up. */
|
|
1060
|
+
let stepWallStop = null;
|
|
500
1061
|
|
|
501
1062
|
/** @param {any} step */
|
|
502
1063
|
const executeStep = async (step) => {
|
|
@@ -507,13 +1068,127 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
507
1068
|
for (const e of step.exit) {
|
|
508
1069
|
if (e.type === 'tree-changed') for (const [k, v] of await snapshotScope(workdir, e.scope)) snapshot.set(k, v);
|
|
509
1070
|
}
|
|
510
|
-
|
|
1071
|
+
// Layer R — one root per step's ralph loop (each micro-wheel is the Layer-1
|
|
1072
|
+
// atom, LAYERS.md). The red-set is the exit evaluator's OWN gap: fixation
|
|
1073
|
+
// here means "the worker rewrote the same file(s) AND the exit evaluator's
|
|
1074
|
+
// complaint is byte-identical" — so the whole normalized gap is the
|
|
1075
|
+
// comparable set (gapKeep `\S` = every non-blank line). That is more robust
|
|
1076
|
+
// than reusing a check's `^`-anchored gapKeep, which the exit wrapper
|
|
1077
|
+
// (`check "x" red: …`) would break. writesInformative is true (tool mode,
|
|
1078
|
+
// always) but never fires on write-overlap ALONE — a plan step rewrites its
|
|
1079
|
+
// one target every attempt, so only a KNOWN-unmoved red-set can distinguish
|
|
1080
|
+
// repetition from progress (Finding 3). Native is excluded (no onToolResult
|
|
1081
|
+
// seam → the tee cannot settle → same-path rewrites are blind).
|
|
1082
|
+
const root = layerRoot && !native
|
|
1083
|
+
? createRoot({ gapKeep: '\\S', redact: scrub, writesInformative: true })
|
|
1084
|
+
: null;
|
|
1085
|
+
// P: the three widened step fields, each wired or refused — a silently-ignored
|
|
1086
|
+
// optional field is the F50 blind-instrument class. `attempts` tightens the
|
|
1087
|
+
// shell cap (validator already bounds it); `scope` narrows the fence to a
|
|
1088
|
+
// menu value (a subset by construction); `model` swaps the worker's provider
|
|
1089
|
+
// via the runner's factory — absent factory it must STOP, not silently run
|
|
1090
|
+
// the default tier as if the choice had been honoured.
|
|
1091
|
+
const stepCap = step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns;
|
|
1092
|
+
if (step.model !== undefined && !providerFor) {
|
|
1093
|
+
const err = /** @type {any} */ (new Error(`step "${step.id}" selects model tier "${step.model}" but the runner supplied no providerFor factory — wiring gap, not a plan defect`));
|
|
1094
|
+
err.category = 'interpreter-red';
|
|
1095
|
+
throw err;
|
|
1096
|
+
}
|
|
1097
|
+
const w = await mkWorker({
|
|
1098
|
+
granted: step.tools, phase: `step:${step.id}`, attemptRounds: step.rounds, attempts: stepCap, writable: true, root,
|
|
1099
|
+
fence: step.scope !== undefined ? [resolve(workdir, globToPrefix(step.scope))] : null,
|
|
1100
|
+
workerProvider: step.model !== undefined && providerFor ? providerFor(step.model) : null,
|
|
1101
|
+
});
|
|
511
1102
|
let lastText = '';
|
|
512
1103
|
let iterationNow = 0;
|
|
1104
|
+
// The meter's baseline: what the RUN had left when this step began. Shares are
|
|
1105
|
+
// computed against this, never against the original budget — a late step is
|
|
1106
|
+
// measured against what actually remains for it, which is what "eating the run"
|
|
1107
|
+
// means. Read once, here, so the denominator cannot drift mid-step.
|
|
1108
|
+
const stepStartUsd = remainingUsd();
|
|
1109
|
+
const stepStartMs = clock.bounded ? clock.remainingMs() : Infinity;
|
|
513
1110
|
/** @param {number} iteration @param {string} [gap] */
|
|
514
1111
|
const middle = async (iteration, gap) => {
|
|
1112
|
+
// A — the variance check, at the HEAD of an attempt so no work is discarded
|
|
1113
|
+
// (attempts 1..n-1 accrued the spend; their writes are already on disk). Never
|
|
1114
|
+
// fires on attempt 1: the share is 0 there by construction. Throwing with a
|
|
1115
|
+
// category is the seam ralph already owns; it routes the category and the step
|
|
1116
|
+
// loop reads it as a REPLAN, not a stop.
|
|
1117
|
+
if (iteration > 1) {
|
|
1118
|
+
const moneyShare = stepStartUsd > 0 ? (stepStartUsd - remainingUsd()) / stepStartUsd : 0;
|
|
1119
|
+
const timeShare = clock.bounded && stepStartMs > 0 ? (stepStartMs - clock.remainingMs()) / stepStartMs : 0;
|
|
1120
|
+
if (moneyShare >= VARIANCE_THRESHOLD || timeShare >= VARIANCE_THRESHOLD) {
|
|
1121
|
+
emit('variance', {
|
|
1122
|
+
step: step.id, iteration, threshold: VARIANCE_THRESHOLD,
|
|
1123
|
+
moneyShare: Number(moneyShare.toFixed(3)),
|
|
1124
|
+
timeShare: clock.bounded ? Number(timeShare.toFixed(3)) : null,
|
|
1125
|
+
axis: moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time',
|
|
1126
|
+
});
|
|
1127
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
1128
|
+
`step "${step.id}" consumed ${Math.round(Math.max(moneyShare, timeShare) * 100)}% of the run's remaining `
|
|
1129
|
+
+ `${moneyShare >= VARIANCE_THRESHOLD ? 'money' : 'time'} across ${iteration - 1} attempt(s) with its exits still red`));
|
|
1130
|
+
err.category = 'step-variance';
|
|
1131
|
+
throw err;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// T/W-2 at the STEP site, and it sits BELOW the meter deliberately. The meter is
|
|
1135
|
+
// the instrument for a step that was ALREADY RUNNING when the deadline passed:
|
|
1136
|
+
// that step reads a time share of 1 and routes to the ONE replan exactly as it
|
|
1137
|
+
// always has. The case it is structurally blind to is a step that STARTS past the
|
|
1138
|
+
// deadline — its share is measured against what the run had left when it began,
|
|
1139
|
+
// which is zero, so the share is zero forever and the step burns every attempt it
|
|
1140
|
+
// is allowed. (Measured on the real flow: three attempts, three judged exits, all
|
|
1141
|
+
// past the deadline.) The two are exhaustive and do not overlap: past the deadline
|
|
1142
|
+
// a step that began with time left reads a share of exactly 1, so the meter always
|
|
1143
|
+
// fires first for it — which is why this check only ever reaches attempt 1, and why
|
|
1144
|
+
// the routing of a step that was already running is untouched.
|
|
1145
|
+
//
|
|
1146
|
+
// Every one of those attempts is worthless by construction: the metered clock
|
|
1147
|
+
// check stops the worker on its FIRST round, so it writes nothing, and the judge
|
|
1148
|
+
// still runs the step's exits — themselves close stages, at the full close timeout
|
|
1149
|
+
// each — to re-mint the red already on the record. hamr's ruling, the same one the
|
|
1150
|
+
// close-fix loop applies: when time is up, keep the grade we already have and
|
|
1151
|
+
// stop. Resume-to-cap extended from money to time — the stop IS the checkpoint.
|
|
1152
|
+
//
|
|
1153
|
+
// The placement IS the mechanism, and the in-flight case falls out of it with
|
|
1154
|
+
// nothing added: a deadline landing DURING an attempt still lets that attempt and
|
|
1155
|
+
// its judge run to completion, because the clock is not consulted again until the
|
|
1156
|
+
// next attempt is about to start. Grading real work always completes — a wall that
|
|
1157
|
+
// kills grading leaves the run unreadable after the money is spent (F45).
|
|
1158
|
+
if (clock.expired()) {
|
|
1159
|
+
stepWallStop = {
|
|
1160
|
+
cutMidCall: false,
|
|
1161
|
+
phase: `step:${step.id}`,
|
|
1162
|
+
stepsDone: idx,
|
|
1163
|
+
stepsPlanned: plan.steps.length,
|
|
1164
|
+
attemptsUsed: iteration - 1,
|
|
1165
|
+
// BOTH, honestly named. `capRuns` means the RUN's attempt cap on every
|
|
1166
|
+
// wall-halt record — the fix site writes exactly that — so this site must
|
|
1167
|
+
// not quietly put the step's TIGHTENED cap under the same key: two records
|
|
1168
|
+
// of one type disagreeing about what a number counts, with nothing on the
|
|
1169
|
+
// record saying which, is the two-instruments class. The tightening is real
|
|
1170
|
+
// information (it is what bounded THIS step) and keeps its own name.
|
|
1171
|
+
capRuns,
|
|
1172
|
+
stepAttemptCap: stepCap,
|
|
1173
|
+
};
|
|
1174
|
+
// No trend here, and none invented: `gapTrend` reads consecutive CLOSE output,
|
|
1175
|
+
// and a step that never ran has no grade of its own to compare. The lever the
|
|
1176
|
+
// human needs is on the run-level record either way.
|
|
1177
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
1178
|
+
`the wall-clock cap passed before step "${step.id}" attempt ${iteration} could start — `
|
|
1179
|
+
+ `the step was never funded to run. ${idx} of ${plan.steps.length} step(s) completed before it. `
|
|
1180
|
+
+ 'Nothing after the deadline is allowed to change the verdict the run already has.'));
|
|
1181
|
+
err.category = 'wall-halt';
|
|
1182
|
+
throw err;
|
|
1183
|
+
}
|
|
515
1184
|
w.setIteration(iteration);
|
|
516
1185
|
iterationNow = iteration;
|
|
1186
|
+
// Layer R observe: finalize the prior attempt from the books (workerWrites
|
|
1187
|
+
// audit + teed same-path rewrites), run the fixation detector, and inject
|
|
1188
|
+
// its escalating note into THIS attempt's prompt (null = inert). The event
|
|
1189
|
+
// carries counts and paths only — never content (the spine is forever).
|
|
1190
|
+
const rootInj = root ? root.observe({ iteration, gap, writes: w.workerWrites() }) : null;
|
|
1191
|
+
if (rootInj) emit('root-injected', { step: step.id, ...rootInj.event });
|
|
517
1192
|
const r = await w.ask([
|
|
518
1193
|
step.action,
|
|
519
1194
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root — a relative path resolves against a different directory and will be denied by the gate.`,
|
|
@@ -522,11 +1197,26 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
522
1197
|
gap && `Previous attempt failed this step's checks:\n${gap}`,
|
|
523
1198
|
w.wasBounded() === iteration - 1
|
|
524
1199
|
&& `Your previous attempt was CUT OFF after ${step.rounds} tool rounds. Reading is bounded; writing is not. Form a hypothesis EARLY and make the change.`,
|
|
1200
|
+
rootInj && rootInj.note,
|
|
525
1201
|
].filter(Boolean).join('\n\n'));
|
|
526
1202
|
lastText = scrub(r.text ?? '').slice(0, ARTIFACT_MAX);
|
|
527
1203
|
};
|
|
528
1204
|
const judge = async () => {
|
|
529
|
-
const { pass, results } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
1205
|
+
const { pass, results: raw } = await evalExits(step.exit, { dir: workdir, snapshot, runCheck });
|
|
1206
|
+
// Scrubbed HERE, once, before the results are read by anyone. An exit detail is
|
|
1207
|
+
// supposed to be names and counts, but `json-valid` embeds `JSON.parse`'s own
|
|
1208
|
+
// message and V8 quotes a window of the SOURCE inside it (a body of 20 chars or
|
|
1209
|
+
// fewer is quoted whole) — so the detail can carry FILE BYTES the worker chose,
|
|
1210
|
+
// and the spine is append-only forever (the hard line). ONE inventory
|
|
1211
|
+
// (SECRET_PATTERNS), the same `scrub` the close output and the ctx-verb channel
|
|
1212
|
+
// already ride through — never a second spelling of what a secret looks like.
|
|
1213
|
+
// The boundary is the right place and not the three call sites below it: these
|
|
1214
|
+
// same details become the `exit-eval` record, a fault's escalation `detail`
|
|
1215
|
+
// (via ralph's close-verdict + CLOSE_FAULTS emissions), and the worker gap
|
|
1216
|
+
// (which ralph also puts on the spine as close-verdict.gap). Scrubbing the
|
|
1217
|
+
// results makes the leak inexpressible; scrubbing each consumer makes it
|
|
1218
|
+
// something the next consumer has to remember.
|
|
1219
|
+
const results = raw.map((r) => (typeof r.detail === 'string' ? { ...r, detail: scrub(r.detail) } : r));
|
|
530
1220
|
emit('exit-eval', { step: step.id, iteration: iterationNow, results });
|
|
531
1221
|
// an instrument fault rides out by its runClose verdict NAME: ralph
|
|
532
1222
|
// escalates it through CLOSE_FAULTS (or F32-routes a crash after writes)
|
|
@@ -536,7 +1226,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
536
1226
|
// AND-only: the gap names EVERY failing wall (mechanical genre, F38)
|
|
537
1227
|
return { verdict: 'needs_revision', gap: results.filter((r) => !r.pass).map((r) => r.detail).join('\n') };
|
|
538
1228
|
};
|
|
539
|
-
const outcome = await ralph({ middle, judge, capRuns, emit: emitL, workerWrites: w.workerWrites });
|
|
1229
|
+
const outcome = await ralph({ middle, judge, capRuns: stepCap, emit: emitL, workerWrites: w.workerWrites });
|
|
540
1230
|
return { outcome, artifact: lastText };
|
|
541
1231
|
};
|
|
542
1232
|
|
|
@@ -576,6 +1266,23 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
576
1266
|
if (res.outcome === 'green') {
|
|
577
1267
|
artifacts.push({ id: step.id, text: res.artifact });
|
|
578
1268
|
idx += 1;
|
|
1269
|
+
// T's terminal: a run that cannot fit its time budget STOPS, and the stop is
|
|
1270
|
+
// the result (build-ladder discipline, v1.27 — unchanged). Checked after a
|
|
1271
|
+
// GREEN step too, not just a red one: the alternative silently starts a step
|
|
1272
|
+
// it cannot fund, which is the same class as the money cap binding mid-attempt
|
|
1273
|
+
// (F45). Resume-to-cap applies exactly as it does to money — the stop IS the
|
|
1274
|
+
// checkpoint, so the completed steps are not wasted.
|
|
1275
|
+
if (clock.expired() && idx < plan.steps.length) {
|
|
1276
|
+
emitWallHalt({ stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1277
|
+
emit('escalation', {
|
|
1278
|
+
category: 'wall-halt', decisionReady: true, phase: `step:${step.id}`,
|
|
1279
|
+
decision: `The run reached its wall-clock cap after ${idx} of ${plan.steps.length} steps. Time ran out, not capability.`,
|
|
1280
|
+
options: WALL_OPTIONS,
|
|
1281
|
+
detail: `requested ${clock.requestedMs}ms, elapsed ${clock.elapsedMs()}ms`,
|
|
1282
|
+
});
|
|
1283
|
+
planExecuted();
|
|
1284
|
+
return 'wall-halt';
|
|
1285
|
+
}
|
|
579
1286
|
continue;
|
|
580
1287
|
}
|
|
581
1288
|
// ONE replan, and only for EXHAUSTION with FUNDS LEFT (review #5): ralph
|
|
@@ -587,16 +1294,50 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
587
1294
|
// exhaustion leaves money on the table. An instrument/governance stop that
|
|
588
1295
|
// is not cap-halt never replans either. (An unpriced step already returned
|
|
589
1296
|
// pricing-red at the step-end guard above, so isUnpriced() is false here.)
|
|
1297
|
+
//
|
|
1298
|
+
// A (PRD v1.27) adds the SECOND trigger: `step-variance` — the meter stopped a
|
|
1299
|
+
// step that had eaten a declared share of the run with its exits unmoved. That
|
|
1300
|
+
// is the case exhaustion-only could never reach (F56: a replan has fired zero
|
|
1301
|
+
// times in the programme), and it is a genuine re-allocation rather than a stop.
|
|
1302
|
+
// Both triggers still need FUNDS LEFT, and both are still bounded by the ONE
|
|
1303
|
+
// replan ceiling — this changes the trigger, never the ceiling (v1.12).
|
|
590
1304
|
const cat = lastEscalation?.category;
|
|
591
|
-
|
|
1305
|
+
// F66 adds the THIRD trigger: `step-stalled` — the stall watchdog reissued the
|
|
1306
|
+
// model call MAX_STALLS times and never got a round back. hamr's ruling is that
|
|
1307
|
+
// a stall must self-heal rather than end the run ("killing and coming back is
|
|
1308
|
+
// not an option"), so the watchdog reissues silently; only once reissuing has
|
|
1309
|
+
// demonstrably stopped working does the run adapt instead — by replanning, not
|
|
1310
|
+
// by stopping. Same ONE-replan ceiling and same FUNDS-LEFT condition as the
|
|
1311
|
+
// other two: this changes the trigger, never the ceiling (v1.12).
|
|
1312
|
+
const replanTrigger = cat === 'cap-halt' ? 'step exhausted its attempts with exits still red'
|
|
1313
|
+
: cat === 'step-variance' ? 'the meter stopped a step that was consuming the run with its exits unmoved'
|
|
1314
|
+
: cat === 'step-stalled' ? 'the model stopped producing rounds and reissuing the call did not recover it'
|
|
1315
|
+
: null;
|
|
1316
|
+
if (!replanned && replanTrigger && remainingUsd() > MONEY_MIN) {
|
|
592
1317
|
replanned = true;
|
|
593
|
-
emit('replan', { step: step.id, reason:
|
|
594
|
-
|
|
1318
|
+
emit('replan', { step: step.id, reason: replanTrigger, trigger: cat });
|
|
1319
|
+
// The brief names the trigger HONESTLY, one branch per trigger: it is the
|
|
1320
|
+
// only channel the redrafting planner adapts to, so a wrong diagnosis here
|
|
1321
|
+
// is handed to the one component whose whole job is to respond to it. A
|
|
1322
|
+
// stall in particular never judged its exits at all — the exhaustion
|
|
1323
|
+
// sentence would invent a red the run never saw (F28's rule, replan side).
|
|
1324
|
+
const why = cat === 'step-variance'
|
|
1325
|
+
? 'It was stopped by the run\'s meter for consuming too large a share of what was left.'
|
|
1326
|
+
: cat === 'step-stalled'
|
|
1327
|
+
? 'It stalled: the model stopped producing rounds and reissuing the call did not recover it, so its exits were never judged.'
|
|
1328
|
+
: `It ran ${step.attempts !== undefined ? Math.min(step.attempts, capRuns) : capRuns} attempts and its exits were still red.`;
|
|
1329
|
+
const failure = `Step "${step.id}" (${step.action}) did not reach its exits. `
|
|
1330
|
+
+ `${why}\n`
|
|
595
1331
|
+ `Last exit state:\n${lastEscalation?.detail ?? '(none)'}\n`
|
|
596
1332
|
+ `Steps completed so far: ${artifacts.map((a) => a.id).join(', ') || 'none'}.`;
|
|
1333
|
+
// The progress line IS the adaptation channel (addendum 2): the balance rides
|
|
1334
|
+
// in via obtainPlan's live read, and this says where the run got to. Both are
|
|
1335
|
+
// the planner re-allocating what remains across what is left — never a rate.
|
|
1336
|
+
const progress = `step ${idx + 1} of ${plan.steps.length} ("${step.id}") did not finish; `
|
|
1337
|
+
+ `${artifacts.length} step(s) completed before it`;
|
|
597
1338
|
let pv;
|
|
598
1339
|
try {
|
|
599
|
-
pv = await obtainPlan('replan', failure);
|
|
1340
|
+
pv = await obtainPlan('replan', failure, progress);
|
|
600
1341
|
} catch (e) {
|
|
601
1342
|
planExecuted();
|
|
602
1343
|
return relay(e, 'replan');
|
|
@@ -615,6 +1356,22 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
615
1356
|
idx = 0;
|
|
616
1357
|
continue;
|
|
617
1358
|
}
|
|
1359
|
+
// F64 — the wall stopped this attempt from INSIDE a provider call (the derived
|
|
1360
|
+
// call timeout is the deadline in the provider's own coin). ralph already
|
|
1361
|
+
// emitted the escalation under this category, so the outcome and the record
|
|
1362
|
+
// agree (F11); what is still missing is the run-level TIME record, which the
|
|
1363
|
+
// between-steps site emits and this path must too. Never a replan trigger and
|
|
1364
|
+
// never a casualty: time ran out, and the stop IS the checkpoint.
|
|
1365
|
+
// W-2 adds the step loop's SECOND wall reading: the deadline seen at the head of
|
|
1366
|
+
// an attempt, with no call in flight. `stepWallStop` is that reading's own record
|
|
1367
|
+
// (attempts used, steps done); its absence means the deadline landed inside a call
|
|
1368
|
+
// instead, which is F64's cutMidCall stop — and run.js keys the job-end money floor
|
|
1369
|
+
// on exactly that field, so conflating the two would report an unknown as exact.
|
|
1370
|
+
if (cat === 'wall-halt') {
|
|
1371
|
+
emitWallHalt(stepWallStop ?? { cutMidCall: true, phase: `step:${step.id}`, stepsDone: idx, stepsPlanned: plan.steps.length });
|
|
1372
|
+
planExecuted();
|
|
1373
|
+
return 'wall-halt';
|
|
1374
|
+
}
|
|
618
1375
|
// A money-gate halt (wallet drained) is an honest cap-halt terminal, never a
|
|
619
1376
|
// step-red: the exits never ran because the money ran out, not because the
|
|
620
1377
|
// work failed. Attempt-exhaustion WITH funds after the one replan is spent
|
|
@@ -623,6 +1380,16 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
623
1380
|
planExecuted();
|
|
624
1381
|
return remainingUsd() <= MONEY_MIN ? 'cap-halt' : `step-red:${step.id}`;
|
|
625
1382
|
}
|
|
1383
|
+
// A second `step-variance` after the one replan is spent is a STOP, and the stop
|
|
1384
|
+
// is the result — but it rides out as `step-red:<id>`, NOT as its own top-level
|
|
1385
|
+
// outcome. The category is a replan TRIGGER, never a run verdict: leaking it
|
|
1386
|
+
// upward would mint an outcome run.js and the ledger's class table do not know,
|
|
1387
|
+
// and an unmapped category is counted as a library bug (ledger.js) when this is a
|
|
1388
|
+
// planning story. The escalation ralph already emitted carries the real detail.
|
|
1389
|
+
if (cat === 'step-variance') {
|
|
1390
|
+
planExecuted();
|
|
1391
|
+
return `step-red:${step.id}`;
|
|
1392
|
+
}
|
|
626
1393
|
// Any OTHER terminal escalation category is NOT a capability failure: a
|
|
627
1394
|
// provider-red is a transport CASUALTY, a gate-red/interpreter-red/close
|
|
628
1395
|
// fault is an instrument stop. Each rides out under its OWN name (F11: the
|
|
@@ -636,7 +1403,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
636
1403
|
// ── 4. THE CLOSE — the operator's signed command, the only truth. Red →
|
|
637
1404
|
// the gap feeds ONE bounded fix loop judged by the REAL close (v1.12 §4);
|
|
638
1405
|
// still red → the escalation ralph already emitted stands.
|
|
639
|
-
const post =
|
|
1406
|
+
const post = await judgeClose();
|
|
640
1407
|
emit('outer-close', { ...post });
|
|
641
1408
|
if (post.verdict === 'satisfied') {
|
|
642
1409
|
planExecuted();
|
|
@@ -650,28 +1417,141 @@ export async function runPlan(job, { workdir, provider, nativeProvider, emit, re
|
|
|
650
1417
|
}
|
|
651
1418
|
emit('fix-loop', { gapBytes: Buffer.byteLength(post.gap ?? '') });
|
|
652
1419
|
let fixOutcome;
|
|
1420
|
+
/** @type {object|null} W-2 — set when the WALL stopped the fix loop BETWEEN
|
|
1421
|
+
* iterations. It carries that stop's own record fields, and its presence is what
|
|
1422
|
+
* lets the post-loop branch tell this reading apart from F64's mid-call one
|
|
1423
|
+
* without consulting the clock a second time (two clock reads for one event is
|
|
1424
|
+
* how the two instruments come to disagree). */
|
|
1425
|
+
let wallStop = null;
|
|
653
1426
|
try {
|
|
654
|
-
|
|
1427
|
+
// Layer R for the close-fix loop — the plan flow's single ralph loop judged
|
|
1428
|
+
// by the REAL close (the plan flow's single ralph loop, and the
|
|
1429
|
+
// likeliest place fixation manifests: the fix worker has the full menu and
|
|
1430
|
+
// is judged by a command, not a form-only exit). The red-set comes from the
|
|
1431
|
+
// stage that ACTUALLY rendered the verdict this attempt, not from one pattern
|
|
1432
|
+
// fixed for the run: a staged close has N gapKeeps and the wall the worker hit
|
|
1433
|
+
// varies attempt to attempt (the gap here is the raw close output, so the
|
|
1434
|
+
// `^`-anchored pattern matches, unlike the exec steps' exit-eval gap). Same
|
|
1435
|
+
// native exclusion (no onToolResult seam ⇒ the tee cannot settle).
|
|
1436
|
+
const fixRoot = layerRoot && !native
|
|
1437
|
+
? createRoot({ redact: scrub, writesInformative: true })
|
|
1438
|
+
: null;
|
|
1439
|
+
const w = await mkWorker({ granted: ceiling, phase: 'fix', attemptRounds: maxStepRounds, attempts: capRuns, writable: true, root: fixRoot });
|
|
1440
|
+
// W-2's only input, and it is already in this loop's hand: the close gaps seen
|
|
1441
|
+
// so far, oldest first. `post.gap` is the grade the loop OPENED on; every later
|
|
1442
|
+
// entry is what ralph carried back from the close that judged the previous
|
|
1443
|
+
// attempt (or, after a worker-crash, F32's routed gap — still the feedback that
|
|
1444
|
+
// attempt actually received). Nothing is instrumented for this.
|
|
1445
|
+
const gaps = [post.gap ?? ''];
|
|
655
1446
|
/** @param {number} iteration @param {string} [gap] */
|
|
656
1447
|
const middle = async (iteration, gap) => {
|
|
1448
|
+
if (gap !== undefined) gaps.push(gap);
|
|
1449
|
+
// T/W-2 — the wall, read before a NEW fix attempt is started, and never
|
|
1450
|
+
// around the close itself. hamr's ruling: *"when time is up, keep the grade
|
|
1451
|
+
// we already have and stop … run tests (free) and when done if original time
|
|
1452
|
+
// is past due, pause and ask user to increase time or adjust prompt"*.
|
|
1453
|
+
//
|
|
1454
|
+
// Past the deadline this iteration is guaranteed to be worthless: the metered
|
|
1455
|
+
// round check stops the fix worker on its FIRST round, so it writes nothing,
|
|
1456
|
+
// and the iteration then re-runs the FULL staged close over an unchanged tree
|
|
1457
|
+
// (four stages × closeTimeoutMs = 60 minutes on the shipped spec) only to mint
|
|
1458
|
+
// the verdict already on the record — capRuns times over. So the run stops
|
|
1459
|
+
// here, ON that verdict. Resume-to-cap extended from money to time: the stop
|
|
1460
|
+
// IS the checkpoint, and the human tops up (v1.12).
|
|
1461
|
+
//
|
|
1462
|
+
// The placement IS the mechanism, and the in-flight case falls out of it with
|
|
1463
|
+
// nothing added: a deadline that lands DURING an attempt still lets that
|
|
1464
|
+
// attempt's close run to completion, because the clock is not consulted again
|
|
1465
|
+
// until the next iteration is about to start. The close is never bounded — a
|
|
1466
|
+
// wall that kills grading leaves the run unreadable after the money is spent
|
|
1467
|
+
// (the F45 class), so the one thing this must never do is stop a close.
|
|
1468
|
+
if (clock.expired()) {
|
|
1469
|
+
const t = gapTrend(gaps);
|
|
1470
|
+
wallStop = {
|
|
1471
|
+
cutMidCall: false,
|
|
1472
|
+
phase: 'fix',
|
|
1473
|
+
iterationsUsed: iteration - 1,
|
|
1474
|
+
capRuns,
|
|
1475
|
+
verdict: lastCloseVerdict?.verdict,
|
|
1476
|
+
...(lastCloseVerdict?.stage ? { stage: lastCloseVerdict.stage } : {}),
|
|
1477
|
+
trend: t.trend,
|
|
1478
|
+
};
|
|
1479
|
+
// The detail carries what the human needs to pick a lever and nothing else:
|
|
1480
|
+
// the verdict that stands, how much of the loop was actually spent, and the
|
|
1481
|
+
// trend that says which of the two spec edits fits. It never names a culprit
|
|
1482
|
+
// file (F28) — that is the worker's job, not the escalation's.
|
|
1483
|
+
const err = /** @type {CategorizedError} */ (new Error(
|
|
1484
|
+
`the wall-clock cap passed before fix attempt ${iteration} could start. `
|
|
1485
|
+
+ `The verdict stands as the last close rendered it: ${lastCloseVerdict?.verdict ?? 'unknown'}`
|
|
1486
|
+
+ `${lastCloseVerdict?.stage ? ` at stage "${lastCloseVerdict.stage}"` : ''}, `
|
|
1487
|
+
+ `after ${iteration - 1} of ${capRuns} fix iteration(s). `
|
|
1488
|
+
+ `Progress trend: ${t.trend} — ${t.reading}; ${t.lever}.`));
|
|
1489
|
+
err.category = 'wall-halt';
|
|
1490
|
+
throw err;
|
|
1491
|
+
}
|
|
657
1492
|
w.setIteration(iteration);
|
|
1493
|
+
// the red-set's source travels WITH the gap: `closeStage` is the stage the
|
|
1494
|
+
// judge stopped at for the attempt this gap came from, and its own gapKeep
|
|
1495
|
+
// designates that stage's failures
|
|
1496
|
+
const rootInj = fixRoot
|
|
1497
|
+
? fixRoot.observe({
|
|
1498
|
+
iteration, gap, writes: w.workerWrites(),
|
|
1499
|
+
redStage: closeStage,
|
|
1500
|
+
redKeep: stagedClose.find((s2) => s2.name === closeStage)?.gapKeep,
|
|
1501
|
+
})
|
|
1502
|
+
: null;
|
|
1503
|
+
if (rootInj) emit('root-injected', { phase: 'fix', ...rootInj.event });
|
|
658
1504
|
await w.ask([
|
|
659
1505
|
'The job\'s final verification is failing. Fix the repository so it passes.',
|
|
660
1506
|
`Repository root (absolute): ${workdir}\nEvery path you pass to a tool MUST be absolute and inside this root.`,
|
|
661
1507
|
artifacts.length > 0 && `Working context (read-only) — the plan's steps produced:\n${artifacts.map((a) => `[${a.id}] ${a.text}`).join('\n\n')}`,
|
|
662
1508
|
!gap && post.gap && `The verification's output on the tree as it stands (not an attempt of yours):\n${post.gap}`,
|
|
663
1509
|
gap && `Previous attempt failed the verification:\n${gap}`,
|
|
1510
|
+
rootInj && rootInj.note,
|
|
664
1511
|
].filter(Boolean).join('\n\n'));
|
|
665
1512
|
};
|
|
666
1513
|
fixOutcome = await ralph({
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1514
|
+
// the staged close rides in as the JUDGE seam (one verdict, every stage in
|
|
1515
|
+
// order) — ralph's single-command path stays exactly what it was
|
|
1516
|
+
middle, judge: async () => judgeClose(), capRuns, emit: emitL, redact: scrub,
|
|
1517
|
+
closeTimeoutMs, cwd: workdir, workerWrites: w.workerWrites,
|
|
670
1518
|
});
|
|
671
1519
|
} catch (e) {
|
|
672
1520
|
planExecuted();
|
|
673
1521
|
return relay(e, 'fix');
|
|
674
1522
|
}
|
|
675
1523
|
planExecuted();
|
|
1524
|
+
if (fixOutcome !== 'green' && lastEscalation?.category === 'wall-halt') {
|
|
1525
|
+
// F64 in the close-fix loop: the same governance stop, and it must not ride out
|
|
1526
|
+
// as a bare `escalated` either — that reads as "the fix failed" when the fix was
|
|
1527
|
+
// never given the time to run.
|
|
1528
|
+
// W-2 adds the loop's SECOND wall reading: the deadline seen between iterations,
|
|
1529
|
+
// where a real grade already exists and is kept. `wallStop` is that reading's own
|
|
1530
|
+
// record (verdict, iterations used, trend); its absence means the deadline landed
|
|
1531
|
+
// inside a call instead, which is F64's cutMidCall stop.
|
|
1532
|
+
emitWallHalt(wallStop ?? { cutMidCall: true, phase: 'fix' });
|
|
1533
|
+
return 'wall-halt';
|
|
1534
|
+
}
|
|
1535
|
+
if (fixOutcome !== 'green' && lastEscalation?.category === 'cap-halt') {
|
|
1536
|
+
// ralph spells BOTH of its terminals cap-halt — attempt-exhaustion AND a
|
|
1537
|
+
// money-gate halt thrown mid-attempt — so the category alone cannot tell them
|
|
1538
|
+
// apart, and the wallet is the only instrument that can. The step loop already
|
|
1539
|
+
// splits this exact pair on the same reading; the fix loop must too, because
|
|
1540
|
+
// the fix worker's gate is built with the wallet at its MOST drained (every
|
|
1541
|
+
// step's spend is behind it), which is precisely where a money cut masquerades
|
|
1542
|
+
// as "the fix failed". F45: a money cut is never a capability read — it rides
|
|
1543
|
+
// out as cap-halt, the resume-to-cap checkpoint (the stop IS the checkpoint).
|
|
1544
|
+
// Attempts spent with money still on the table stays the designed terminal
|
|
1545
|
+
// ("close still red") and keeps riding out as `escalated` below.
|
|
1546
|
+
// (MED-4, fixed with hamr's explicit go, 2026-07-30.)
|
|
1547
|
+
if (remainingUsd() <= MONEY_MIN) return 'cap-halt';
|
|
1548
|
+
} else if (fixOutcome !== 'green' && typeof lastEscalation?.category === 'string') {
|
|
1549
|
+
// The step loop's category restoration (F11), mirrored: ralph returns the flat
|
|
1550
|
+
// 'escalated' on a middle throw while its escalation carries the real name — a
|
|
1551
|
+
// provider-red here is a transport CASUALTY, and run.js keys the F44
|
|
1552
|
+
// spendComplete:false floor on the OUTCOME, so a laundered label would report
|
|
1553
|
+
// an exact-looking total for a call that never billed back.
|
|
1554
|
+
return lastEscalation.category;
|
|
1555
|
+
}
|
|
676
1556
|
return fixOutcome === 'green' ? 'green' : 'escalated';
|
|
677
1557
|
}
|