@eir-labs/coltrane 0.24.30 → 0.24.31
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/agents/change-verifier.json +4 -2
- package/agents/deploy-scout.json +2 -2
- package/agents/red-law-reviewer.json +54 -0
- package/agents/red-spec-attester.json +53 -0
- package/agents/red-spec-builder.json +56 -0
- package/agents/red-spec-drafter.json +1 -1
- package/agents/spec-reviewer.json +1 -1
- package/dist/src/chart.js +4 -0
- package/dist/src/chart.js.map +1 -1
- package/dist/src/chat_completions_port.d.ts +41 -0
- package/dist/src/chat_completions_port.js +221 -0
- package/dist/src/chat_completions_port.js.map +1 -0
- package/dist/src/claude_invoker.d.ts +18 -0
- package/dist/src/claude_invoker.js +771 -22
- package/dist/src/claude_invoker.js.map +1 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +49 -8
- package/dist/src/cli.js.map +1 -1
- package/dist/src/completions_invoker.d.ts +79 -0
- package/dist/src/completions_invoker.js +220 -0
- package/dist/src/completions_invoker.js.map +1 -0
- package/dist/src/composition.d.ts +13 -0
- package/dist/src/composition.js +16 -0
- package/dist/src/composition.js.map +1 -1
- package/dist/src/genome_schema.d.ts +200 -0
- package/dist/src/genome_schema.js +37 -0
- package/dist/src/genome_schema.js.map +1 -1
- package/dist/src/genome_store.d.ts +30 -2
- package/dist/src/genome_store.js +51 -8
- package/dist/src/genome_store.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +9 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/invoker_selection.d.ts +58 -0
- package/dist/src/invoker_selection.js +110 -0
- package/dist/src/invoker_selection.js.map +1 -0
- package/dist/src/ledger.d.ts +45 -2
- package/dist/src/ledger.js +23 -3
- package/dist/src/ledger.js.map +1 -1
- package/dist/src/mcp.js +2 -2
- package/dist/src/mcp.js.map +1 -1
- package/dist/src/outputs.d.ts +24 -0
- package/dist/src/outputs.js +1 -0
- package/dist/src/outputs.js.map +1 -1
- package/dist/src/registry.js +16 -0
- package/dist/src/registry.js.map +1 -1
- package/dist/src/repo_index.d.ts +13 -0
- package/dist/src/repo_index.js +90 -10
- package/dist/src/repo_index.js.map +1 -1
- package/dist/src/reside_backing.d.ts +3 -2
- package/dist/src/reside_backing.js +8 -2
- package/dist/src/reside_backing.js.map +1 -1
- package/dist/src/reuse.d.ts +8 -0
- package/dist/src/reuse.js.map +1 -1
- package/dist/src/run_deps.d.ts +10 -10
- package/dist/src/run_deps.js +25 -15
- package/dist/src/run_deps.js.map +1 -1
- package/dist/src/runtime.d.ts +205 -109
- package/dist/src/runtime.js +904 -145
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/server.js +148 -45
- package/dist/src/server.js.map +1 -1
- package/dist/src/server_relay.js +28 -0
- package/dist/src/server_relay.js.map +1 -1
- package/dist/src/skill_subprocess.d.ts +1 -0
- package/dist/src/skill_subprocess.js +5 -0
- package/dist/src/skill_subprocess.js.map +1 -1
- package/dist/src/transcript_store.d.ts +14 -0
- package/dist/src/transcript_store.js +39 -0
- package/dist/src/transcript_store.js.map +1 -0
- package/dist/src/turn_loop.d.ts +173 -0
- package/dist/src/turn_loop.js +283 -0
- package/dist/src/turn_loop.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/worker.js +6 -0
- package/dist/src/worker.js.map +1 -1
- package/dist/src/worker_env.d.ts +4 -2
- package/dist/src/worker_env.js +42 -0
- package/dist/src/worker_env.js.map +1 -1
- package/domain_types/change-set.json +33 -22
- package/domain_types/red-spec.json +32 -23
- package/domain_types/seat-primer.json +65 -0
- package/package.json +1 -1
- package/standards/build-from-red-spec-v0.json +93 -0
- package/standards/draft-red-laws-v0.json +68 -0
package/dist/src/runtime.js
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
// that carries model_version + (empty, v0) eval_scores — honestly un-tempered.
|
|
6
6
|
import { lineageAdoption } from "./lineage_adoption.js";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { performance } from "node:perf_hooks";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join as joinPath, relative as relPath, isAbsolute as isAbsPath, sep as pathSep } from "node:path";
|
|
8
12
|
import { PRIMITIVE_OUTPUT_TYPE, CORE_TYPES } from "./core_types.js";
|
|
9
13
|
import { executeSkillAsync } from "./skill_subprocess.js";
|
|
10
14
|
import { loadSkillPackage } from "./skills.js";
|
|
11
|
-
import { resolveModel } from "./claude_invoker.js";
|
|
15
|
+
import { resolveModel, sessionUuidFor } from "./claude_invoker.js";
|
|
12
16
|
import { resolveAgentGrants } from "./tool_providers.js";
|
|
13
17
|
import { resolveAndRealize } from "./venue_realize.js";
|
|
14
18
|
// core type → the process primitive that produces it (reverse of PRIMITIVE_OUTPUT_TYPE).
|
|
@@ -27,6 +31,34 @@ import { drainGigHeader } from "./output_mirror.js";
|
|
|
27
31
|
import { LEDGER_SCHEMA_VERSION } from "./ledger.js";
|
|
28
32
|
import { PlacementRefused } from "./placement.js";
|
|
29
33
|
import { COLTRANE_VERSION } from "./version.js";
|
|
34
|
+
// #seat-effort — the ONE place effort precedence resolves, mirroring how `depth` threads. The
|
|
35
|
+
// RESOLVED value is set on AgentInvocationContext.effort and reaches both invokers. Order: the
|
|
36
|
+
// dispatch effort wins, else the agent's declared effort, else the agent's tier default
|
|
37
|
+
// (economy→low, standard→medium, premium→high), else `medium` for an untiered agent — never the
|
|
38
|
+
// operator's ~/.claude/settings.json. Total by construction (always returns a level), which is what
|
|
39
|
+
// makes an undeclared, untiered seat still carry an explicit effort to the spawn (O3).
|
|
40
|
+
function resolveEffort(dispatchEffort, agent) {
|
|
41
|
+
if (dispatchEffort)
|
|
42
|
+
return dispatchEffort;
|
|
43
|
+
if (agent.effort)
|
|
44
|
+
return agent.effort;
|
|
45
|
+
switch (agent.model_tier) {
|
|
46
|
+
case "economy": return "low";
|
|
47
|
+
case "standard": return "medium";
|
|
48
|
+
case "premium": return "high";
|
|
49
|
+
default: return "medium";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// contract-seat-context-ceiling-v1 (O2) — the ONE place the context ceiling resolves, a sibling of
|
|
53
|
+
// resolveEffort but with NO floor: dispatch ▷ agent ▷ none. Absent everywhere ⇒ `undefined`, which the
|
|
54
|
+
// completions invoker reads as "no ceiling" and runs to done past any context (I1) — never a guessed
|
|
55
|
+
// default. Set on AgentInvocationContext.max_context_tokens at the invoke ctx site, the same seam
|
|
56
|
+
// `effort` threads through.
|
|
57
|
+
function resolveMaxContextTokens(dispatchCap, agent) {
|
|
58
|
+
if (dispatchCap !== undefined)
|
|
59
|
+
return dispatchCap;
|
|
60
|
+
return agent.max_context_tokens;
|
|
61
|
+
}
|
|
30
62
|
/**
|
|
31
63
|
* #236 — settled spend used to be discarded on every failed gig: `usage` was written only on
|
|
32
64
|
* the success path, and the async dispatcher's `.catch` set status/error and nothing else. A
|
|
@@ -131,51 +163,139 @@ export function abortReasonText(signal) {
|
|
|
131
163
|
return "cancelled";
|
|
132
164
|
}
|
|
133
165
|
/**
|
|
134
|
-
* Raised when a gig's
|
|
135
|
-
*
|
|
136
|
-
* the
|
|
137
|
-
*
|
|
166
|
+
* Raised when a gig's SETTLED dollar spend reaches its `max_usd` ceiling and the next batch may
|
|
167
|
+
* not start (O2/O5). Denominated in dollars: `spent_usd`, `max_usd`, `unit: "usd"`, and a message
|
|
168
|
+
* that names the amounts with `$` and `usd` — never append-units. The in-memory BudgetState is
|
|
169
|
+
* attached so a caller can render the full snapshot.
|
|
138
170
|
*/
|
|
139
171
|
export class BudgetExhausted extends Error {
|
|
140
172
|
agent_slug;
|
|
141
|
-
|
|
142
|
-
|
|
173
|
+
spent_usd;
|
|
174
|
+
max_usd;
|
|
175
|
+
unit = "usd";
|
|
143
176
|
state;
|
|
144
|
-
constructor(agent_slug,
|
|
145
|
-
super(`BudgetExhausted: agent "${agent_slug}"
|
|
177
|
+
constructor(agent_slug, spent_usd, max_usd, state) {
|
|
178
|
+
super(`BudgetExhausted: gig stopped before agent "${agent_slug}" — settled spend $${spent_usd} reached the $${max_usd} usd ceiling`);
|
|
146
179
|
this.name = "BudgetExhausted";
|
|
147
180
|
this.agent_slug = agent_slug;
|
|
148
|
-
this.
|
|
149
|
-
this.
|
|
181
|
+
this.spent_usd = spent_usd;
|
|
182
|
+
this.max_usd = max_usd;
|
|
150
183
|
this.state = state;
|
|
151
184
|
}
|
|
152
185
|
}
|
|
153
186
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
187
|
+
* Raised when a gig under a dollar ceiling cannot VERIFY its spend (F3): a settled invocation
|
|
188
|
+
* reported usage but no `total_cost_usd`, so the runtime cannot know whether the next batch is
|
|
189
|
+
* affordable. Fail-closed — no further batch starts. `reason` is the typed `budget_unverifiable`,
|
|
190
|
+
* and the message names the chairs whose dollar spend is unknown.
|
|
191
|
+
*/
|
|
192
|
+
export class BudgetUnverifiable extends Error {
|
|
193
|
+
reason = "budget_unverifiable";
|
|
194
|
+
chairs;
|
|
195
|
+
state;
|
|
196
|
+
constructor(chairs, state) {
|
|
197
|
+
super(`budget_unverifiable: cannot enforce the usd ceiling — chair(s) settled without reporting usd: ${chairs.join(", ")}`);
|
|
198
|
+
this.name = "BudgetUnverifiable";
|
|
199
|
+
this.chairs = chairs;
|
|
200
|
+
this.state = state;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The model that DID a chair's work: the model that WROTE the most output tokens across the chair's
|
|
205
|
+
* `modelUsage` breakdown — NOT whichever key the CLI listed first. Claude Code spends a small
|
|
206
|
+
* background call on a fast model before the real work, and that model is listed FIRST while writing
|
|
207
|
+
* almost nothing, so a first-key stamp names a model that did none of the chair's work. Output
|
|
208
|
+
* tokens are the honest signal of which model produced the answer. Ties break on the model id
|
|
209
|
+
* (lexicographic ascending), so the stamp is deterministic regardless of the breakdown's key order.
|
|
210
|
+
* An empty map (a transport that reported no per-model breakdown) yields undefined — nothing to
|
|
211
|
+
* stamp, exactly as before.
|
|
167
212
|
*/
|
|
168
|
-
export function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
213
|
+
export function workingModel(outputByModel) {
|
|
214
|
+
let best;
|
|
215
|
+
let bestTokens = -1;
|
|
216
|
+
for (const [model, tokens] of outputByModel) {
|
|
217
|
+
if (tokens > bestTokens || (tokens === bestTokens && best !== undefined && model < best)) {
|
|
218
|
+
best = model;
|
|
219
|
+
bestTokens = tokens;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return best;
|
|
223
|
+
}
|
|
224
|
+
function gitInTree(tree_root, args) {
|
|
225
|
+
return execFileSync("git", ["-C", tree_root, ...args]).toString();
|
|
226
|
+
}
|
|
227
|
+
// The it/test titles a law blob declares, in file order — the `tests` the reviewer is handed instead
|
|
228
|
+
// of a sentence about the law. Matches `it(...)`/`test(...)` (with any `.only`/`.skip`/… chain) taking
|
|
229
|
+
// a string literal as its first argument; the leading \b keeps `it`/`test` from matching inside a
|
|
230
|
+
// longer identifier (submit, audit, …). Escaped quotes/backslashes in the title are unescaped.
|
|
231
|
+
function testTitlesIn(body) {
|
|
232
|
+
const re = /\b(?:it|test)(?:\.\w+)*\s*\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
|
|
233
|
+
const titles = [];
|
|
234
|
+
for (let m = re.exec(body); m !== null; m = re.exec(body)) {
|
|
235
|
+
titles.push(m[2].replace(/\\(["'`\\])/g, "$1"));
|
|
236
|
+
}
|
|
237
|
+
return titles;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Stamp each law's `blob_sha` (`git rev-parse <commit>:<path>`) and `tests` (the it/test titles in
|
|
241
|
+
* that blob, in file order) from a supplied `{path, commit}`, reading git in `tree_root`.
|
|
242
|
+
* REFUSALS: no `tree_root` → `tree_root_unknown` (never a `process.cwd()` fallback); an address that
|
|
243
|
+
* does not resolve (unknown commit, or a path absent at that commit) → `law_record_unresolvable`
|
|
244
|
+
* naming `path@commit`; a seat-supplied `blob_sha`/`tests` that disagrees with git → `law_bytes_mismatch`
|
|
245
|
+
* naming the path and field.
|
|
246
|
+
*/
|
|
247
|
+
export function stampLawAddresses(laws, tree_root) {
|
|
248
|
+
if (tree_root === undefined) {
|
|
249
|
+
throw new RuntimeError("tree_root_unknown: a red-spec carrying `laws` cannot be stamped without a RunDeps.tree_root — the seal reads git objects from a named tree and never falls back to process.cwd().");
|
|
250
|
+
}
|
|
251
|
+
return laws.map((law) => {
|
|
252
|
+
let blob_sha;
|
|
253
|
+
try {
|
|
254
|
+
blob_sha = gitInTree(tree_root, ["rev-parse", `${law.commit}:${law.path}`]).trim();
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
throw new RuntimeError(`law_record_unresolvable: the law ${law.path}@${law.commit} does not resolve in tree_root — git holds no object for that <commit>:<path>. Nothing is sealed.`);
|
|
258
|
+
}
|
|
259
|
+
const body = gitInTree(tree_root, ["show", `${law.commit}:${law.path}`]);
|
|
260
|
+
const tests = testTitlesIn(body);
|
|
261
|
+
if (law.blob_sha !== undefined && law.blob_sha !== blob_sha) {
|
|
262
|
+
throw new RuntimeError(`law_bytes_mismatch: the seat-supplied blob_sha for law ${law.path} (${law.blob_sha}) disagrees with the engine (${blob_sha}). A seat's claim is refused, never overwritten. Nothing is sealed.`);
|
|
263
|
+
}
|
|
264
|
+
if (law.tests !== undefined && (law.tests.length !== tests.length || law.tests.some((t, i) => t !== tests[i]))) {
|
|
265
|
+
throw new RuntimeError(`law_bytes_mismatch: the seat-supplied tests for law ${law.path} disagree with the titles git holds in that blob. A seat's claim is refused, never overwritten. Nothing is sealed.`);
|
|
266
|
+
}
|
|
267
|
+
return { path: law.path, commit: law.commit, blob_sha, tests };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Stamp each change's `blob_sha` (`git hash-object` of the file in `tree_root`, or the literal
|
|
272
|
+
* `"deleted"` when the file is absent from the tree), `patch_sha256` (sha256 of `git diff <base> --
|
|
273
|
+
* <path>` in `tree_root`) and `bytes` (that diff's length) from a supplied `{path, base}`.
|
|
274
|
+
* REFUSALS: no `tree_root` → `tree_root_unknown`; a seat-supplied `blob_sha`/`patch_sha256`/`bytes`
|
|
275
|
+
* that disagrees with git → `law_bytes_mismatch` naming the path and field.
|
|
276
|
+
*/
|
|
277
|
+
export function stampChangeAddresses(changes, tree_root) {
|
|
278
|
+
if (tree_root === undefined) {
|
|
279
|
+
throw new RuntimeError("tree_root_unknown: a change-set carrying `changes` cannot be stamped without a RunDeps.tree_root — the seal reads git objects from a named tree and never falls back to process.cwd().");
|
|
280
|
+
}
|
|
281
|
+
return changes.map((change) => {
|
|
282
|
+
const blob_sha = existsSync(joinPath(tree_root, change.path))
|
|
283
|
+
? gitInTree(tree_root, ["hash-object", change.path]).trim()
|
|
284
|
+
: "deleted";
|
|
285
|
+
const diff = gitInTree(tree_root, ["diff", change.base, "--", change.path]);
|
|
286
|
+
const patch_sha256 = sha256Hex(diff);
|
|
287
|
+
const bytes = Buffer.byteLength(diff, "utf8");
|
|
288
|
+
if (change.blob_sha !== undefined && change.blob_sha !== blob_sha) {
|
|
289
|
+
throw new RuntimeError(`law_bytes_mismatch: the seat-supplied blob_sha for change ${change.path} (${change.blob_sha}) disagrees with the engine (${blob_sha}). A seat's claim is refused, never overwritten. Nothing is sealed.`);
|
|
290
|
+
}
|
|
291
|
+
if (change.patch_sha256 !== undefined && change.patch_sha256 !== patch_sha256) {
|
|
292
|
+
throw new RuntimeError(`law_bytes_mismatch: the seat-supplied patch_sha256 for change ${change.path} disagrees with the sha256 of the real diff. A seat's claim is refused, never overwritten. Nothing is sealed.`);
|
|
293
|
+
}
|
|
294
|
+
if (change.bytes !== undefined && change.bytes !== bytes) {
|
|
295
|
+
throw new RuntimeError(`law_bytes_mismatch: the seat-supplied bytes for change ${change.path} (${change.bytes}) disagrees with the engine (${bytes}). A seat's claim is refused, never overwritten. Nothing is sealed.`);
|
|
296
|
+
}
|
|
297
|
+
return { path: change.path, base: change.base, blob_sha, patch_sha256, bytes };
|
|
298
|
+
});
|
|
179
299
|
}
|
|
180
300
|
// Deterministic hash over the definitions a gig touches: the standard + its agents,
|
|
181
301
|
// in a canonical (sorted, JCS) form. This is the reproducibility key — same defs,
|
|
@@ -372,8 +492,32 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
372
492
|
// only granularity that means anything. Returns whether THIS chair ever reported usage.
|
|
373
493
|
const makeUsageSink = () => {
|
|
374
494
|
let saw = false;
|
|
495
|
+
let sawCost = false;
|
|
496
|
+
// contract-spend-survives-v1 (O1/I1) — this chair's own settled usage, accumulated ALONGSIDE the
|
|
497
|
+
// gig-wide `usage` fold below so the per-chair rows reconcile to the gig total by construction.
|
|
498
|
+
const chairOwnUsage = { input_tokens: 0, output_tokens: 0, total_cost_usd: 0, by_model: {} };
|
|
499
|
+
// Per-chair, alongside the gig-level fold. The gig's `by_model` cannot separate two chairs in
|
|
500
|
+
// one run, which is exactly the question per-chair routing asks. Output tokens per model id,
|
|
501
|
+
// accumulated across this chair's `result` events; `workingModel` picks the one that did the
|
|
502
|
+
// work (the argmax) at report time rather than trusting the CLI's first key.
|
|
503
|
+
const chairOutputByModel = new Map();
|
|
504
|
+
let chairCost = 0;
|
|
505
|
+
let chairTokens = 0;
|
|
506
|
+
let chairSaw = false;
|
|
375
507
|
return {
|
|
376
508
|
attributed: () => saw,
|
|
509
|
+
reportedCost: () => sawCost,
|
|
510
|
+
usage: () => (chairSaw ? chairOwnUsage : undefined),
|
|
511
|
+
reported: () => {
|
|
512
|
+
if (!chairSaw)
|
|
513
|
+
return {};
|
|
514
|
+
const model = workingModel(chairOutputByModel);
|
|
515
|
+
return {
|
|
516
|
+
...(model !== undefined ? { model } : {}),
|
|
517
|
+
cost_usd: chairCost,
|
|
518
|
+
tokens_used: chairTokens,
|
|
519
|
+
};
|
|
520
|
+
},
|
|
377
521
|
fold(ev) {
|
|
378
522
|
if (ev.type !== "result")
|
|
379
523
|
return;
|
|
@@ -393,17 +537,42 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
393
537
|
// number this engine could produce about money.
|
|
394
538
|
if (!hasCost && !hasTokens && !hasBreakdown)
|
|
395
539
|
return;
|
|
540
|
+
chairSaw = true;
|
|
541
|
+
if (hasCost)
|
|
542
|
+
sawCost = true;
|
|
543
|
+
chairTokens +=
|
|
544
|
+
(typeof inRaw === "number" ? inRaw : 0) + (typeof outRaw === "number" ? outRaw : 0);
|
|
545
|
+
chairCost += hasCost ? costRaw : 0;
|
|
396
546
|
usage.input_tokens += typeof inRaw === "number" ? inRaw : 0;
|
|
397
547
|
usage.output_tokens += typeof outRaw === "number" ? outRaw : 0;
|
|
398
548
|
usage.total_cost_usd += hasCost ? costRaw : 0;
|
|
549
|
+
// The SAME fold, kept per-chair for the durable chair_spend row (O1/I1). Summing every
|
|
550
|
+
// chair's own usage reconstructs the gig-wide `usage` above, which is what I1 pins.
|
|
551
|
+
chairOwnUsage.input_tokens += typeof inRaw === "number" ? inRaw : 0;
|
|
552
|
+
chairOwnUsage.output_tokens += typeof outRaw === "number" ? outRaw : 0;
|
|
553
|
+
chairOwnUsage.total_cost_usd += hasCost ? costRaw : 0;
|
|
399
554
|
// Per-model breakdown keyed by the ACTUAL model id that ran (not the configured tier).
|
|
400
555
|
if (hasBreakdown) {
|
|
401
556
|
for (const [model, m] of Object.entries(mu)) {
|
|
557
|
+
const outTok = typeof m["outputTokens"] === "number" ? m["outputTokens"] : 0;
|
|
402
558
|
const slot = usage.by_model[model] ?? { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
|
|
403
559
|
slot.input_tokens += typeof m["inputTokens"] === "number" ? m["inputTokens"] : 0;
|
|
404
|
-
slot.output_tokens +=
|
|
560
|
+
slot.output_tokens += outTok;
|
|
405
561
|
slot.cost_usd += typeof m["costUSD"] === "number" ? m["costUSD"] : 0;
|
|
406
562
|
usage.by_model[model] = slot;
|
|
563
|
+
// Per-chair by_model, keyed the same way, so the chair_spend row's own breakdown stands
|
|
564
|
+
// on its own rather than pointing back at the gig-wide total.
|
|
565
|
+
const cslot = chairOwnUsage.by_model[model] ?? { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
|
|
566
|
+
cslot.input_tokens += typeof m["inputTokens"] === "number" ? m["inputTokens"] : 0;
|
|
567
|
+
cslot.output_tokens += outTok;
|
|
568
|
+
cslot.cost_usd += typeof m["costUSD"] === "number" ? m["costUSD"] : 0;
|
|
569
|
+
chairOwnUsage.by_model[model] = cslot;
|
|
570
|
+
// The stamp is the model that WROTE this chair's output, decided at report time by
|
|
571
|
+
// `workingModel` (argmax over output tokens). The CLI lists a fast background call's
|
|
572
|
+
// model FIRST though it writes almost nothing, so the old first-key `??=` stamped a
|
|
573
|
+
// model that did none of the work. Accumulate per-model output; the gig-level `by_model`
|
|
574
|
+
// total above is untouched.
|
|
575
|
+
chairOutputByModel.set(model, (chairOutputByModel.get(model) ?? 0) + outTok);
|
|
407
576
|
}
|
|
408
577
|
}
|
|
409
578
|
else {
|
|
@@ -550,41 +719,38 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
550
719
|
// idempotent, which is what makes it safe on this path. No merge conflict; tsc caught it.
|
|
551
720
|
throw new GigAborted(gig_id, reason, finalizeUsage(), produced);
|
|
552
721
|
};
|
|
553
|
-
// Budget state.
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
const
|
|
722
|
+
// Budget state (budget-in-dollars). A DOLLAR ceiling is in play iff `deps.budget.max_usd` is set;
|
|
723
|
+
// a TURN POOL is in play iff `turn_pool` or `Standard.reserve_pool` names one. The snapshot exists
|
|
724
|
+
// whenever EITHER is present (O6): pool fields always, dollar fields only under a ceiling.
|
|
725
|
+
//
|
|
726
|
+
// O6 — the pool opens from RunDeps.turn_pool FIRST, then the standard default. `??` (not max/sum)
|
|
727
|
+
// so the dispatch declaration wins deterministically, and absence stays distinct from a declared 0.
|
|
728
|
+
const maxUsd = deps.budget?.max_usd;
|
|
729
|
+
const hasCeiling = typeof maxUsd === "number";
|
|
730
|
+
const poolSource = deps.turn_pool ?? standard.reserve_pool; // undefined when neither names one
|
|
731
|
+
const poolInPlay = poolSource !== undefined;
|
|
732
|
+
const poolOpening = poolSource ?? 0;
|
|
733
|
+
const budget = (hasCeiling || poolInPlay)
|
|
560
734
|
? {
|
|
561
|
-
opening: deps.budget.opening,
|
|
562
|
-
spent: 0,
|
|
563
|
-
credit: 0,
|
|
564
|
-
balance: deps.budget.opening,
|
|
565
735
|
agent_state: "active",
|
|
566
736
|
depleted_agent: null,
|
|
567
737
|
depleted_at: null,
|
|
568
|
-
base_cost: deps.budget.base_cost ?? 1,
|
|
569
|
-
k: deps.budget.k ?? 0.1,
|
|
570
|
-
unit: "append-units",
|
|
571
|
-
settled_usd: 0,
|
|
572
738
|
pool_remaining: poolOpening,
|
|
573
739
|
draws: [],
|
|
740
|
+
...(hasCeiling ? { max_usd: maxUsd, spent_usd: 0, unit: "usd" } : {}),
|
|
574
741
|
}
|
|
575
742
|
: null;
|
|
576
|
-
// #turn-budget — reserve turns HELD by prepared-but-not-yet-settled chairs
|
|
577
|
-
// the
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
743
|
+
// #turn-budget — reserve turns HELD by prepared-but-not-yet-settled chairs. `prepareChair` runs
|
|
744
|
+
// synchronously for the whole ready batch before any invoke, so an offer computed against
|
|
745
|
+
// `pool_remaining - poolReserved` cannot let two parallel chairs over-lend the same turns. A grant
|
|
746
|
+
// converts the hold to a real draw-down; a no-draw or denial releases it. Conservation therefore
|
|
747
|
+
// holds for every ordering, not by luck.
|
|
581
748
|
let poolReserved = 0;
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
let reserved = 0;
|
|
749
|
+
// F3 — under a ceiling, the agent slugs of chairs that SETTLED (reported usage) but reported NO
|
|
750
|
+
// usd. Their dollar spend is unknown, so the next batch cannot be verified affordable and must not
|
|
751
|
+
// start; this list is what BudgetUnverifiable names. A fully-stubbed chair that reports no usage at
|
|
752
|
+
// all is not here — that is every no-cost test fixture, and it completes as before.
|
|
753
|
+
const unverifiedChairs = [];
|
|
588
754
|
// Resolve agent-by-slug once.
|
|
589
755
|
const agentBySlug = new Map(standard.agents.map((a) => [a.slug, a]));
|
|
590
756
|
// ── dispatch preflight: the UNIFIED t=0 dead-reference sweep ────────────────
|
|
@@ -1036,7 +1202,11 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1036
1202
|
const core = deps.outputs.coreTypeOf(domain_type) ?? domain_type;
|
|
1037
1203
|
const primitive = CORE_TO_PRIMITIVE[core] ?? "JUDGE";
|
|
1038
1204
|
const approvalInputs = hc.depends_on.flatMap((d) => producedByRole.get(d) ?? []);
|
|
1039
|
-
|
|
1205
|
+
// contract-seat-time-monotonic-v1 (O2) — seat time is measured on the monotonic clock
|
|
1206
|
+
// (performance.now), never Date.now: a wall-clock jump during the seat (lid sleep, NTP step)
|
|
1207
|
+
// is machine time, not seat time. Rounded to whole ms at the difference, since performance.now
|
|
1208
|
+
// is fractional.
|
|
1209
|
+
const t0 = performance.now();
|
|
1040
1210
|
emit({ type: "chair_start", phase: phase.name, role: hc.role, producer: deps.approved_by ?? "human" });
|
|
1041
1211
|
const rec = deps.outputs.write({
|
|
1042
1212
|
core_type: core,
|
|
@@ -1057,7 +1227,9 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1057
1227
|
produced.push(rec);
|
|
1058
1228
|
emit({
|
|
1059
1229
|
type: "chair_complete", phase: phase.name, role: hc.role, producer: deps.approved_by ?? "human",
|
|
1060
|
-
output_types: [domain_type], duration_ms:
|
|
1230
|
+
output_types: [domain_type], duration_ms: Math.round(performance.now() - t0),
|
|
1231
|
+
// A human seat forwards no agent write events, so there is nothing to measure: null, not 0.
|
|
1232
|
+
first_write_ms: null, context_tokens_at_first_write: null,
|
|
1061
1233
|
});
|
|
1062
1234
|
// A sealed lineage-verdict either grounds an institution or does not. Decide it here,
|
|
1063
1235
|
// where the verdict and the record it approved are both in hand, and report the answer
|
|
@@ -1126,6 +1298,27 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1126
1298
|
const chosenRoles = new Set(chosen.map((c) => c.role));
|
|
1127
1299
|
ready = ready.filter((c) => chosenRoles.has(c.role));
|
|
1128
1300
|
}
|
|
1301
|
+
// ── BUDGET BATCH-BOUNDARY GATE (O2/F3) ──────────────────────────────────────────────────
|
|
1302
|
+
// The dollar ceiling is checked HERE, before this ready batch starts, against SETTLED spend
|
|
1303
|
+
// (reconciled at each prior batch boundary). The batch already running is never interrupted;
|
|
1304
|
+
// it is the NEXT batch that does not start. Two fail-closed conditions, both naming the chair
|
|
1305
|
+
// that would have run next:
|
|
1306
|
+
// F3 — a prior settled invocation reported no usd, so affordability is UNVERIFIABLE; and
|
|
1307
|
+
// O2 — settled spend has reached max_usd.
|
|
1308
|
+
if (hasCeiling && budget) {
|
|
1309
|
+
if (unverifiedChairs.length > 0) {
|
|
1310
|
+
budget.agent_state = "depleted";
|
|
1311
|
+
budget.depleted_agent = ready[0]?.agent_slug ?? null;
|
|
1312
|
+
budget.depleted_at = new Date().toISOString();
|
|
1313
|
+
throw new BudgetUnverifiable([...unverifiedChairs], budget);
|
|
1314
|
+
}
|
|
1315
|
+
if ((budget.spent_usd ?? 0) >= maxUsd) {
|
|
1316
|
+
budget.agent_state = "depleted";
|
|
1317
|
+
budget.depleted_agent = ready[0]?.agent_slug ?? null;
|
|
1318
|
+
budget.depleted_at = new Date().toISOString();
|
|
1319
|
+
throw new BudgetExhausted(ready[0]?.agent_slug ?? "?", budget.spent_usd ?? 0, maxUsd, budget);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1129
1322
|
// Per-chair work happens in two stages so non-invocation failures
|
|
1130
1323
|
// (BudgetExhausted, contract violations, programming-level errors like
|
|
1131
1324
|
// TypeError from a circular gig_input) propagate UNWRAPPED through the
|
|
@@ -1151,11 +1344,11 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1151
1344
|
noteCheckpointRole(ch.role, phase.name, r.value);
|
|
1152
1345
|
}
|
|
1153
1346
|
}
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
if (budget)
|
|
1158
|
-
budget.
|
|
1347
|
+
// O2 — BATCH BOUNDARY reconcile: the settled dollars this batch added are folded into the
|
|
1348
|
+
// snapshot here, so the NEXT batch's gate (top of the while loop) sees them. prepareChair ran
|
|
1349
|
+
// for every chair in this batch before any was invoked, so no chair saw its siblings' cost.
|
|
1350
|
+
if (budget && hasCeiling)
|
|
1351
|
+
budget.spent_usd = usage.total_cost_usd;
|
|
1159
1352
|
// Bank progress BEFORE the failure throw below. A batch whose siblings succeeded has
|
|
1160
1353
|
// durable outputs either way; the checkpoint is what makes them reachable next time,
|
|
1161
1354
|
// and writing it only on the happy path would forfeit exactly the runs that need it.
|
|
@@ -1186,7 +1379,14 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1186
1379
|
if (examineRounds > 0) {
|
|
1187
1380
|
const allChairs = standard.phases.flatMap((p) => p.chairs);
|
|
1188
1381
|
const phaseNameOf = (role) => standard.phases.find((p) => p.chairs.some((c) => c.role === role))?.name ?? phase.name;
|
|
1189
|
-
|
|
1382
|
+
// O3 — a verify SEAT is a chair whose output resolves to core type Verdict, whatever produces
|
|
1383
|
+
// it (agent or skill chair). Keying on the agent's VERIFY primitive (as this did) meant a
|
|
1384
|
+
// skill-backed verify chair sealing `pass: false` never triggered an amend: a skill chair
|
|
1385
|
+
// binds no agent, so it has no primitive to match.
|
|
1386
|
+
const producesVerdict = (c) => {
|
|
1387
|
+
const out = c.output_contract[0];
|
|
1388
|
+
return !!out && (deps.outputs.coreTypeOf(out) ?? "") === "Verdict";
|
|
1389
|
+
};
|
|
1190
1390
|
const dropFromProduced = (recs) => {
|
|
1191
1391
|
for (const r of recs) {
|
|
1192
1392
|
const idx = produced.indexOf(r);
|
|
@@ -1197,7 +1397,7 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1197
1397
|
const failingVerdict = (role) => (producedByRole.get(role) ?? []).find((r) => (deps.outputs.coreTypeOf(r.domain_type) ?? "") === "Verdict" &&
|
|
1198
1398
|
r.data.pass === false);
|
|
1199
1399
|
for (const vch of phase.chairs) {
|
|
1200
|
-
if (!
|
|
1400
|
+
if (!producesVerdict(vch))
|
|
1201
1401
|
continue;
|
|
1202
1402
|
let verdict = failingVerdict(vch.role);
|
|
1203
1403
|
if (!verdict)
|
|
@@ -1210,11 +1410,35 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1210
1410
|
const out = c.output_contract[0];
|
|
1211
1411
|
return !!out && (deps.outputs.coreTypeOf(out) ?? "") === "Artifact";
|
|
1212
1412
|
};
|
|
1213
|
-
const
|
|
1413
|
+
const makerSet = vch.depends_on
|
|
1214
1414
|
.map((role) => allChairs.find((c) => c.role === role))
|
|
1215
1415
|
.filter((c) => !!c && producesArtifact(c));
|
|
1216
|
-
if (
|
|
1416
|
+
if (makerSet.length === 0)
|
|
1217
1417
|
continue; // nothing to re-run — a verify with no maker to amend
|
|
1418
|
+
// contract-amend-nearest-makers-v1 (O1/I1/F1) — an amend round spends a seat ONLY where the
|
|
1419
|
+
// verdict's fix can land. Of today's maker set, re-invoke only the makers that no OTHER maker in
|
|
1420
|
+
// the set depends on, directly or transitively through depends_on. An upstream maker's inputs do
|
|
1421
|
+
// not change between rounds, so re-running it re-does settled work AND replaces the exact record
|
|
1422
|
+
// the downstream fix was built on (its sealed record must instead be carried unchanged — O2:
|
|
1423
|
+
// producedByRole still holds it, and the amend loop below never touches a skipped maker's role,
|
|
1424
|
+
// so prepareChair gathers it as-is). Independent makers keep no dependant in the set, so they all
|
|
1425
|
+
// re-run as today (I1). A composed standard's depends_on graph is acyclic, so the maker set always
|
|
1426
|
+
// has at least one sink — the narrowed selection is never empty and never the whole set (F1).
|
|
1427
|
+
const chairByRole = new Map(allChairs.map((c) => [c.role, c]));
|
|
1428
|
+
const transitiveDepsOf = (role) => {
|
|
1429
|
+
const seen = new Set();
|
|
1430
|
+
const stack = [...(chairByRole.get(role)?.depends_on ?? [])];
|
|
1431
|
+
while (stack.length > 0) {
|
|
1432
|
+
const r = stack.pop();
|
|
1433
|
+
if (seen.has(r))
|
|
1434
|
+
continue;
|
|
1435
|
+
seen.add(r);
|
|
1436
|
+
for (const d of chairByRole.get(r)?.depends_on ?? [])
|
|
1437
|
+
stack.push(d);
|
|
1438
|
+
}
|
|
1439
|
+
return seen;
|
|
1440
|
+
};
|
|
1441
|
+
const makers = makerSet.filter((m) => !makerSet.some((other) => other.role !== m.role && transitiveDepsOf(other.role).has(m.role)));
|
|
1218
1442
|
for (let round = 1; round <= examineRounds && verdict; round++) {
|
|
1219
1443
|
checkpoint();
|
|
1220
1444
|
emit({ type: "phase_start", phase: `${phase.name}:amend#${round}`, roles: [...makers.map((m) => m.role), vch.role] });
|
|
@@ -1222,24 +1446,42 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1222
1446
|
// AMEND: each maker re-runs with the failing verdict fed in as an extra input, so
|
|
1223
1447
|
// the seat that built the change fixes the exact thing the verify caught.
|
|
1224
1448
|
for (const mk of makers) {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1449
|
+
// O1/I3 — carry the maker's OWN work from the round just judged, plus the failing
|
|
1450
|
+
// verdict, INTO prepareChair, so both are among `inputs` when lookupReuse computes the
|
|
1451
|
+
// key. Pushing the verdict in AFTER prep (as this did) left the amend key identical to
|
|
1452
|
+
// round 1's, so a reuse-wired amend was served round 1's failing artifact from the
|
|
1453
|
+
// cache. producedByRole holds ONLY the round just judged, so the amend carries exactly
|
|
1454
|
+
// one prior artifact and one verdict, both the latest — never a pile of drafts.
|
|
1455
|
+
const priorWork = producedByRole.get(mk.role) ?? [];
|
|
1456
|
+
// contract-chair-session-continuity-v1 (O3/O5) — an amend RESUMES the maker's own session
|
|
1457
|
+
// (the invoker gets ctx.resume) and is never served from the reuse cache.
|
|
1458
|
+
// O1 — the initial invocation is round 1 (the default), so the amend loop's iteration
|
|
1459
|
+
// `round` (1-based) stamps round `round + 1`: each re-run of a seat gets a distinct,
|
|
1460
|
+
// monotonic round and no two chair_spend rows for one role collide.
|
|
1461
|
+
const prep = prepareChair(mk, phaseNameOf(mk.role), [...priorWork, feedback], { resume: true, round: round + 1 });
|
|
1228
1462
|
const recs = await invokeAndWriteChair(prep);
|
|
1229
1463
|
dropFromProduced(producedByRole.get(mk.role) ?? []);
|
|
1230
1464
|
producedByRole.set(mk.role, recs);
|
|
1231
1465
|
produced.push(...recs);
|
|
1232
1466
|
noteCheckpointRole(mk.role, phaseNameOf(mk.role), recs);
|
|
1233
1467
|
}
|
|
1234
|
-
// RE-VERIFY the amended artifact.
|
|
1235
|
-
|
|
1468
|
+
// RE-VERIFY the amended artifact. contract-resumed-gig-session-v1 (O1/I1) — the re-verify
|
|
1469
|
+
// RESUMES the verifier's own round-one session (`resume: true`), never re-opens `--session-id`
|
|
1470
|
+
// for a live id: the id is deterministic in (gig_id, role), so a second `--session-id` open
|
|
1471
|
+
// collides ("already in use"). Resuming carries `--resume <uuid>` instead. `keep_prompt` keeps
|
|
1472
|
+
// the FULL prompt rather than the maker's trimmed amend continuation: unlike a maker (whose
|
|
1473
|
+
// failing verdict IS the one new thing to send), a re-verify re-reads the amended tree from its
|
|
1474
|
+
// own identity, and a stateless door (chat-completions) that holds no conversation must still
|
|
1475
|
+
// carry the verify seat's identity — the trimmed continuation would strip it. buildInvokerArgs
|
|
1476
|
+
// still emits `--resume` (it keys on `resume`, not the prompt), so O1's arg law holds.
|
|
1477
|
+
const vprep = prepareChair(vch, phase.name, [], { resume: true, keep_prompt: true, round: round + 1 });
|
|
1236
1478
|
const vrecs = await invokeAndWriteChair(vprep);
|
|
1237
1479
|
dropFromProduced(producedByRole.get(vch.role) ?? []);
|
|
1238
1480
|
producedByRole.set(vch.role, vrecs);
|
|
1239
1481
|
produced.push(...vrecs);
|
|
1240
1482
|
noteCheckpointRole(vch.role, phase.name, vrecs);
|
|
1241
|
-
if (budget)
|
|
1242
|
-
budget.
|
|
1483
|
+
if (budget && hasCeiling)
|
|
1484
|
+
budget.spent_usd = usage.total_cost_usd;
|
|
1243
1485
|
saveCheckpoint();
|
|
1244
1486
|
verdict = failingVerdict(vch.role); // undefined once it passes → loop ends
|
|
1245
1487
|
}
|
|
@@ -1350,6 +1592,28 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1350
1592
|
}
|
|
1351
1593
|
return { key, hit: { cache_key: key, source_gig_id: entry.source_gig_id, outputs: entry.outputs } };
|
|
1352
1594
|
}
|
|
1595
|
+
/**
|
|
1596
|
+
* contract-seat-primer-v1 (O2/I3) — the MOST RECENT `seat-primer` this agent sealed for `area`,
|
|
1597
|
+
* across gigs. Agent-specific by construction: a chair never forks a primer sealed by a DIFFERENT
|
|
1598
|
+
* agent, even for the same area (I3), because the (agent_slug, area) filter admits only its own.
|
|
1599
|
+
* "Most recent" so re-priming an area updates every later fork — staleness is decided per blob at
|
|
1600
|
+
* fork time, not pinned to one primer id. Returns undefined when the agent has never primed the area.
|
|
1601
|
+
*/
|
|
1602
|
+
function mostRecentSeatPrimer(agentSlug, area) {
|
|
1603
|
+
let best;
|
|
1604
|
+
for (const r of deps.outputs.all()) {
|
|
1605
|
+
if (r.domain_type !== "seat-primer")
|
|
1606
|
+
continue;
|
|
1607
|
+
const d = r.data;
|
|
1608
|
+
if (d["agent_slug"] !== agentSlug || d["area"] !== area)
|
|
1609
|
+
continue;
|
|
1610
|
+
// `>=` prefers the later-iterated record on an equal timestamp; all() is insertion-ordered, so
|
|
1611
|
+
// the newest primer wins even when two sealed in the same millisecond.
|
|
1612
|
+
if (!best || r.created_at >= best.created_at)
|
|
1613
|
+
best = r;
|
|
1614
|
+
}
|
|
1615
|
+
return best;
|
|
1616
|
+
}
|
|
1353
1617
|
/**
|
|
1354
1618
|
* Offer an ENTRY chair the seeds a chart edge carried in.
|
|
1355
1619
|
*
|
|
@@ -1369,7 +1633,7 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1369
1633
|
seedsConsumed.set(s.id, s);
|
|
1370
1634
|
}
|
|
1371
1635
|
}
|
|
1372
|
-
function prepareChair(chair, phaseName) {
|
|
1636
|
+
function prepareChair(chair, phaseName, extraInputs = [], opts = {}) {
|
|
1373
1637
|
// A skill-backed chair runs the skill's deterministic code half — no agent, no model.
|
|
1374
1638
|
if (chair.skill_slug && (chair.agent_slug ?? "") === "") {
|
|
1375
1639
|
const dir = deps.skill_dirs?.get(chair.skill_slug);
|
|
@@ -1388,6 +1652,11 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1388
1652
|
inputs.push(...recs);
|
|
1389
1653
|
}
|
|
1390
1654
|
pullSeeds(chair, inputs, chair.input_contract);
|
|
1655
|
+
// Amend carriage: extra inputs (the maker's own prior work + the failing verdict) join the
|
|
1656
|
+
// frontier so they enter the reuse key — see the EXAMINE⇄AMEND block. Empty otherwise.
|
|
1657
|
+
for (const ex of extraInputs)
|
|
1658
|
+
if (!inputs.includes(ex))
|
|
1659
|
+
inputs.push(ex);
|
|
1391
1660
|
if (chair.input_contract.length > 0) {
|
|
1392
1661
|
for (const need of chair.input_contract) {
|
|
1393
1662
|
// #156: a type satisfied by an upstream record OR by the gig payload (entry-chair seed).
|
|
@@ -1425,6 +1694,7 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1425
1694
|
producer_slug: chair.skill_slug, domain: standard.domain,
|
|
1426
1695
|
...(skillReuse ? { reuse_key: skillReuse.key } : {}),
|
|
1427
1696
|
...(skillReuse?.hit ? { reuse_hit: skillReuse.hit } : {}),
|
|
1697
|
+
...(opts.round !== undefined ? { round: opts.round } : {}),
|
|
1428
1698
|
};
|
|
1429
1699
|
}
|
|
1430
1700
|
const agent = standard.agents.find((a) => a.slug === chair.agent_slug);
|
|
@@ -1477,6 +1747,14 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1477
1747
|
// upstream record — by type, as records, so provenance survives the movement boundary.
|
|
1478
1748
|
pullSeeds(chair, inputs, [...chair.input_contract, ...agent.input_types]);
|
|
1479
1749
|
}
|
|
1750
|
+
// Amend carriage (O1/I3): the maker's own round-just-judged artifact and the failing verdict are
|
|
1751
|
+
// threaded in as extra inputs so they enter `inputs` BEFORE lookupReuse — the amend key then
|
|
1752
|
+
// describes what the maker actually receives, and a reuse-wired amend is no longer served round
|
|
1753
|
+
// 1's failing artifact from the cache. Empty on every non-amend prep, so round-1 keys stay
|
|
1754
|
+
// byte-identical (the I1 control).
|
|
1755
|
+
for (const ex of extraInputs)
|
|
1756
|
+
if (!inputs.includes(ex))
|
|
1757
|
+
inputs.push(ex);
|
|
1480
1758
|
// Runtime input_contract check: every type the chair declares it expects
|
|
1481
1759
|
// on input must be satisfied by its actual upstream inputs. Subtype-aware
|
|
1482
1760
|
// (docs/genome-extension.md): a core-type requirement is met by any domain
|
|
@@ -1563,36 +1841,88 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1563
1841
|
// context, so charging it (or worse, refusing it for lack of allowance) would be the budget
|
|
1564
1842
|
// enforcing a cost that is not going to be incurred.
|
|
1565
1843
|
const lookup = lookupReuse({ chair, phaseName, inputs, output_specs, agent, skills, producer_slug: agent.slug, domain });
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1844
|
+
// contract-seat-primer-v1 (O2/O3/O4/F1) — FORK WIRING. A chair with fork_from looks up the most
|
|
1845
|
+
// recent seat-primer for (its agent, area) across gigs and warm-starts from it. Resolved here so
|
|
1846
|
+
// the reuse gate below can withhold the hit (O5) and so the invocation carries the primer session
|
|
1847
|
+
// and the blob-stale paths. A fork with NO primer (F1) records `primer_missing` and runs cold.
|
|
1848
|
+
let fork;
|
|
1849
|
+
let fork_fallback;
|
|
1850
|
+
if (chair.fork_from) {
|
|
1851
|
+
const area = chair.fork_from.primer;
|
|
1852
|
+
const primer = mostRecentSeatPrimer(agent.slug, area);
|
|
1853
|
+
if (!primer) {
|
|
1854
|
+
fork_fallback = "primer_missing";
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
const pd = primer.data;
|
|
1858
|
+
// contract-rolling-seat-primer-v1 (O4/I1) — a `max_context_tokens` ceiling REPLACES a bloated
|
|
1859
|
+
// primer instead of forking it: when the primer's recorded context size exceeds the ceiling, the
|
|
1860
|
+
// chair does NOT fork — it runs COLD (a fresh `--session-id`, its full prompt) and records
|
|
1861
|
+
// `primer_too_large`. Absent ceiling (I1) → the fork happens whatever the size, so size gates the
|
|
1862
|
+
// fork ONLY through this ceiling.
|
|
1863
|
+
const ceiling = chair.fork_from.max_context_tokens;
|
|
1864
|
+
const primer_context = typeof pd["context_tokens"] === "number" ? pd["context_tokens"] : 0;
|
|
1865
|
+
if (ceiling !== undefined && primer_context > ceiling) {
|
|
1866
|
+
fork_fallback = "primer_too_large";
|
|
1867
|
+
}
|
|
1868
|
+
else {
|
|
1869
|
+
// O4 — staleness is decided by BLOBS against the working tree, through the SAME gitInTree seam
|
|
1870
|
+
// the law/change stampers use. A file git can no longer hash (removed since priming) is stale.
|
|
1871
|
+
const files = Array.isArray(pd["files"]) ? pd["files"] : [];
|
|
1872
|
+
const stale_paths = files
|
|
1873
|
+
.filter((f) => {
|
|
1874
|
+
if (deps.tree_root === undefined)
|
|
1875
|
+
return false;
|
|
1876
|
+
let current;
|
|
1877
|
+
try {
|
|
1878
|
+
current = gitInTree(deps.tree_root, ["hash-object", f.path]).trim();
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
return current !== f.blob_sha;
|
|
1884
|
+
})
|
|
1885
|
+
.map((f) => f.path);
|
|
1886
|
+
fork = {
|
|
1887
|
+
primer_session_id: String(pd["session_id"] ?? ""),
|
|
1888
|
+
primer_id: primer.id,
|
|
1889
|
+
primer_commit: String(pd["commit"] ?? ""),
|
|
1890
|
+
stale_paths,
|
|
1891
|
+
// contract-rolling-seat-primer-v1 (O2) — carry the forked primer's own files so a prime+fork
|
|
1892
|
+
// chair can seal a fresher primer unioning them with this seat's reads.
|
|
1893
|
+
primer_files: files,
|
|
1894
|
+
// contract-primer-reading-frontier-v1 (O2/F2) — the primer's recorded reading frontier, so the
|
|
1895
|
+
// fork's first spawn cuts the resumed conversation there. Absent ⇒ the whole session resumes.
|
|
1896
|
+
...(typeof pd["frontier"] === "string" ? { frontier: pd["frontier"] } : {}),
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1585
1899
|
}
|
|
1586
|
-
reserved += cost;
|
|
1587
|
-
reservedCost = cost;
|
|
1588
1900
|
}
|
|
1901
|
+
// contract-chair-session-continuity-v1 (O5) — a RESUMED (amend-round) invocation is NEVER served
|
|
1902
|
+
// from the reuse cache: the seat resumes its own conversation, which carries what the failing
|
|
1903
|
+
// verdict prompted it to reconsider, and none of that is in the reuse key. Serving the cached
|
|
1904
|
+
// artifact would replay the very work the amend exists to redo. The key is still kept below (the
|
|
1905
|
+
// amend's own output is cacheable on the round-one terms), only the HIT is withheld.
|
|
1906
|
+
// contract-seat-primer-v1 (O5) — the SAME withholding for a FORK: a warm-started conversation the
|
|
1907
|
+
// reuse key cannot describe would replay the very reading the fork exists to reuse. The hit is
|
|
1908
|
+
// withheld here AND recorded (below, in executeChair) so the cache-was-live signal survives — a
|
|
1909
|
+
// fork always invokes, even on a key that would otherwise hit.
|
|
1910
|
+
const effectiveHit = (opts.resume || fork) ? undefined : lookup?.hit;
|
|
1911
|
+
// O5 — record the withheld fork hit so executeChair marks the cache LIVE without serving it.
|
|
1912
|
+
const fork_reuse_withheld = fork && lookup?.hit
|
|
1913
|
+
? { cache_key: lookup.hit.cache_key, source_gig_id: lookup.hit.source_gig_id, output_types: lookup.hit.outputs.map((o) => o.domain_type) }
|
|
1914
|
+
: undefined;
|
|
1915
|
+
// BUDGET GATE — the append-unit pre-invocation gate is GONE (O3). Payload size / base_cost / k
|
|
1916
|
+
// no longer decide whether a chair runs. The dollar ceiling is enforced against SETTLED spend at
|
|
1917
|
+
// BATCH BOUNDARIES (O2, in the dispatch loop), not per-chair here. This block now only computes
|
|
1918
|
+
// the reserve OFFER against the gig pool.
|
|
1589
1919
|
// #turn-budget — RESERVE OFFER. Only a chair that DECLARED a `turn_reserve` reaches for the pool;
|
|
1590
1920
|
// one that declared none threads no ctx.turn_reserve, so the invoker's own opts-level reserve is
|
|
1591
|
-
// undisturbed (the #329 continuation path). When a
|
|
1592
|
-
//
|
|
1593
|
-
// budget
|
|
1921
|
+
// undisturbed (the #329 continuation path). When a pool is in play the offer is capped to what it
|
|
1922
|
+
// can still lend (min(own reserve, pool_remaining - poolReserved)) and HELD; with no pool at all
|
|
1923
|
+
// (`budget` null) the declared reserve threads through directly.
|
|
1594
1924
|
let reserveOffer;
|
|
1595
|
-
if (chair.turn_reserve !== undefined && !
|
|
1925
|
+
if (chair.turn_reserve !== undefined && !effectiveHit) {
|
|
1596
1926
|
if (budget) {
|
|
1597
1927
|
const poolAvailable = Math.max(0, budget.pool_remaining - poolReserved);
|
|
1598
1928
|
reserveOffer = Math.min(chair.turn_reserve, poolAvailable);
|
|
@@ -1605,42 +1935,23 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1605
1935
|
return {
|
|
1606
1936
|
chair, phaseName, agent, primitive, domain_type, output_specs, inputs, skills,
|
|
1607
1937
|
missing_skills: missing, producer_slug: agent.slug, domain,
|
|
1608
|
-
...(reservedCost !== undefined ? { cost: reservedCost } : {}),
|
|
1609
1938
|
...(reserveOffer !== undefined ? { reserve_offer: reserveOffer } : {}),
|
|
1610
1939
|
...(lookup ? { reuse_key: lookup.key } : {}),
|
|
1611
|
-
...(
|
|
1940
|
+
...(effectiveHit ? { reuse_hit: effectiveHit } : {}),
|
|
1941
|
+
...(opts.resume ? { resume: true } : {}),
|
|
1942
|
+
...(opts.keep_prompt ? { resume_keep_prompt: true } : {}),
|
|
1943
|
+
...(opts.round !== undefined ? { round: opts.round } : {}),
|
|
1944
|
+
...(fork ? { fork } : {}),
|
|
1945
|
+
...(fork_fallback !== undefined ? { fork_fallback } : {}),
|
|
1946
|
+
...(fork_reuse_withheld ? { fork_reuse_withheld } : {}),
|
|
1612
1947
|
};
|
|
1613
1948
|
}
|
|
1614
|
-
// #232 — convert a chair's reservation into settled spend, or release it. `spent` moves ONLY
|
|
1615
|
-
// for a chair whose invocation actually returned, which is what the budget contract always
|
|
1616
|
-
// claimed. A chair that was prepared and then never invoked (its batch sibling tripped the
|
|
1617
|
-
// gate) never reaches here at all — so it is never charged, which is the point.
|
|
1618
|
-
function settleChairCost(p, succeeded) {
|
|
1619
|
-
if (!budget || p.cost === undefined)
|
|
1620
|
-
return;
|
|
1621
|
-
reserved -= p.cost;
|
|
1622
|
-
if (!succeeded)
|
|
1623
|
-
return;
|
|
1624
|
-
budget.spent += p.cost;
|
|
1625
|
-
budget.balance = budget.opening - budget.spent + budget.credit;
|
|
1626
|
-
}
|
|
1627
1949
|
// Stage 2 — actual invocation + post-invocation output_contract check + write.
|
|
1628
1950
|
// Errors here ARE aggregated by Promise.allSettled and surfaced as a phase-
|
|
1629
|
-
// level RuntimeError naming every failing chair role.
|
|
1630
|
-
//
|
|
1631
|
-
// The thin wrapper is where a chair's budget RESERVATION settles (#232): a hold becomes
|
|
1632
|
-
// `spent` on success and is released on failure. Both paths must run, so the accounting
|
|
1633
|
-
// cannot drift no matter how the chair ends.
|
|
1951
|
+
// level RuntimeError naming every failing chair role. There is no per-chair budget
|
|
1952
|
+
// reservation to settle any more — the ceiling is a batch-boundary check on settled USD.
|
|
1634
1953
|
async function invokeAndWriteChair(p) {
|
|
1635
|
-
|
|
1636
|
-
const written = await executeChair(p);
|
|
1637
|
-
settleChairCost(p, true);
|
|
1638
|
-
return written;
|
|
1639
|
-
}
|
|
1640
|
-
catch (e) {
|
|
1641
|
-
settleChairCost(p, false);
|
|
1642
|
-
throw e;
|
|
1643
|
-
}
|
|
1954
|
+
return executeChair(p);
|
|
1644
1955
|
// THE ROOM IS NOT TORN DOWN HERE. It used to be, in this chair-level `finally`, described as
|
|
1645
1956
|
// "idempotent and a no-op when no venue was named". Idempotent yes; a no-op no —
|
|
1646
1957
|
// `src/venue_realize.ts` sets `torn = true`, and `canReach()` is `!torn && egress.includes(...)`.
|
|
@@ -1655,8 +1966,55 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1655
1966
|
// on both the success and failure paths — see the `finally` on runGig's outer try.
|
|
1656
1967
|
}
|
|
1657
1968
|
async function executeChair(p) {
|
|
1969
|
+
// What the transport SAID about this chair, hoisted out of the invocation block so the seal
|
|
1970
|
+
// can prefer a measurement over the tier table's guess. Empty for a skill-backed chair.
|
|
1971
|
+
let chairReport = {};
|
|
1658
1972
|
const { chair, phaseName, inputs, skills, output_specs, producer_slug, domain } = p;
|
|
1659
|
-
|
|
1973
|
+
// contract-seat-time-monotonic-v1 (O1) — the chair's timings (first_write_ms, duration_ms below)
|
|
1974
|
+
// are monotonic differences (performance.now), never Date.now: a wall-clock jump mid-chair is
|
|
1975
|
+
// machine time, not seat time. Rounded to whole ms at each difference, since performance.now is
|
|
1976
|
+
// fractional.
|
|
1977
|
+
const t0 = performance.now();
|
|
1978
|
+
// #seat-metrics — the seat's FIRST write and the context it carried then, measured from the
|
|
1979
|
+
// chair's forwarded agent events (below). Null until a write happens; a chair that never writes
|
|
1980
|
+
// (or forwards no events, like a skill chair) leaves both null.
|
|
1981
|
+
let firstWriteMs = null;
|
|
1982
|
+
let contextAtFirstWrite = null;
|
|
1983
|
+
let lastAssistantContext = null;
|
|
1984
|
+
// #seat-effort (O5) — the effort the model chair resolved to, captured at the invoke ctx site
|
|
1985
|
+
// (below) so the chair_complete emit can record what the seat ran at. Stays undefined for a skill
|
|
1986
|
+
// chair, which runs no model at an effort.
|
|
1987
|
+
let resolvedEffort;
|
|
1988
|
+
// contract-amend-resume-prompt-v1 (F1) — set when the invoker reports a resume whose session was
|
|
1989
|
+
// gone and fell back cold (the `resume_fallback` stream event). Recorded on chair_complete so the
|
|
1990
|
+
// fallback is observable rather than a resume the record falsely claims happened.
|
|
1991
|
+
let resumeFellBack = false;
|
|
1992
|
+
// contract-resumed-gig-session-v1 (O3) — set when the invoker RESUMED a collided --session-id open
|
|
1993
|
+
// (the `resume_on_collision` stream event): a first spawn whose deterministic session id was already
|
|
1994
|
+
// in use continued the conversation instead of failing. `p.resume` is false on such a spawn (it is a
|
|
1995
|
+
// first open, not an amend), so this is what makes chair_complete record the continuation truthfully.
|
|
1996
|
+
let resumedOnCollision = false;
|
|
1997
|
+
// contract-seat-primer-v1 (O1/I2) — the files a PRIME seat Read, captured from the invoker's
|
|
1998
|
+
// forwarded `seat_reads` event, so the seat-primer is sealed from exactly what the seat read.
|
|
1999
|
+
let primerReads;
|
|
2000
|
+
// contract-rolling-seat-primer-v1 (O3) — the context size (input+cache_read+cache_creation) of the
|
|
2001
|
+
// LAST usage the seat reported, captured from the invoker's forwarded `seat_context` event. Under an
|
|
2002
|
+
// injected `run` seam the streamed assistant usages never reach onEvent, so the invoker parses the
|
|
2003
|
+
// last one from the returned stdout and forwards it, the same way it forwards `seat_reads`.
|
|
2004
|
+
let primerContextTokens;
|
|
2005
|
+
// contract-primer-reading-frontier-v1 (O1) — the reading frontier the invoker derived from the seat's
|
|
2006
|
+
// stream (the last user line before its first write), forwarded on the `seat_reads` event and sealed
|
|
2007
|
+
// onto the seat-primer. Absent when the seat called no write tool (I3) or had no user line before it (F1).
|
|
2008
|
+
let primerFrontier;
|
|
2009
|
+
// contract-primer-reading-frontier-v1 (O4/I1) — a PRIME chair's tree snapshot at chair START (git
|
|
2010
|
+
// stash create, or HEAD when clean), so a file Read before the frontier is sealed at the blob it had
|
|
2011
|
+
// when the chair began — not the post-edit blob the seat may have written after reading it. Undefined
|
|
2012
|
+
// when there is no tree_root (no git resolution) or the snapshot could not be taken.
|
|
2013
|
+
let primerStartSnapshot;
|
|
2014
|
+
// contract-seat-primer-v1 (F2) — set when the invoker reported the primer's session could not be
|
|
2015
|
+
// resumed and fell back cold (the `fork_fallback` stream event). Recorded on chair_complete.
|
|
2016
|
+
let forkFellBackReason;
|
|
2017
|
+
const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
1660
2018
|
// ── REUSE HIT ────────────────────────────────────────────────────────────────────────
|
|
1661
2019
|
// Everything that could refuse this was decided at prep, before a byte was written. What
|
|
1662
2020
|
// is left is a normal seal: the record is written through the SAME `deps.outputs.write`
|
|
@@ -1673,6 +2031,12 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1673
2031
|
const rec = deps.outputs.write({
|
|
1674
2032
|
core_type: spec.core_type,
|
|
1675
2033
|
domain_type: o.domain_type,
|
|
2034
|
+
// O4 — a recall keeps the version it was SEALED at. Omitting this let outputs.write default
|
|
2035
|
+
// to 1, so a record sealed against a v2 type recalled as v1 — a DIFFERENT content_sha than
|
|
2036
|
+
// the record it recalls. A pre-migration entry carries no version; on a HIT the current
|
|
2037
|
+
// version equals the sealed one (else lookupReuse's re-hash would already have refused the
|
|
2038
|
+
// entry), so the fallback re-hashes identically.
|
|
2039
|
+
domain_type_version: o.domain_type_version ?? deps.outputs.typeVersionOf(o.domain_type),
|
|
1676
2040
|
domain,
|
|
1677
2041
|
gig_id,
|
|
1678
2042
|
agent_slug: producer_slug,
|
|
@@ -1713,6 +2077,16 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1713
2077
|
}
|
|
1714
2078
|
const producerHint = chair.skill_slug || p.agent?.slug || chair.agent_slug || chair.role;
|
|
1715
2079
|
emit({ type: "chair_start", phase: phaseName, role: chair.role, producer: producerHint });
|
|
2080
|
+
// contract-seat-primer-v1 (O5) — a fork whose reuse key WOULD hit: the cache is LIVE (record the
|
|
2081
|
+
// hit so the signal survives) but the fork is NEVER served — it invokes below, carrying warm
|
|
2082
|
+
// conversation the reuse key cannot describe.
|
|
2083
|
+
if (p.fork_reuse_withheld) {
|
|
2084
|
+
reuseReport.hits.push({
|
|
2085
|
+
phase: phaseName, role: chair.role,
|
|
2086
|
+
cache_key: p.fork_reuse_withheld.cache_key, source_gig_id: p.fork_reuse_withheld.source_gig_id,
|
|
2087
|
+
output_types: p.fork_reuse_withheld.output_types,
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
1716
2090
|
let data;
|
|
1717
2091
|
// Skill-backed chairs record which skill (version + verified code_hash + tier) sealed the
|
|
1718
2092
|
// output, so the ledger entry traces back to the exact SkillChainEvent. Undefined for agents.
|
|
@@ -1729,7 +2103,13 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1729
2103
|
// even be DELIVERED. `gig_abort` during a skill chair was a promise the engine could
|
|
1730
2104
|
// not keep — #249's shape again, but a missing opportunity to kill rather than a
|
|
1731
2105
|
// missing kill.
|
|
1732
|
-
|
|
2106
|
+
// contract-skill-chair-runs-in-tree-v1 (O1/I1) — a skill chair's code half runs in the GIG'S
|
|
2107
|
+
// tree: forward RunDeps.tree_root as the child's working directory when the run has one. A run
|
|
2108
|
+
// with no tree_root passes no cwd, so the child inherits the engine process's directory (I2).
|
|
2109
|
+
const r = await executeSkillAsync(p.skill_dir, skillInput, 120_000, {
|
|
2110
|
+
signal: deps.signal,
|
|
2111
|
+
...(deps.tree_root !== undefined ? { cwd: deps.tree_root } : {}),
|
|
2112
|
+
});
|
|
1733
2113
|
if (!r.ok)
|
|
1734
2114
|
throw new RuntimeError(`skill chair "${chair.role}" ("${chair.skill_slug}") failed: ${r.error}`);
|
|
1735
2115
|
data = (r.output && typeof r.output === "object" ? r.output : {});
|
|
@@ -1793,8 +2173,30 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1793
2173
|
}
|
|
1794
2174
|
placedHydration = decision.hydration;
|
|
1795
2175
|
}
|
|
2176
|
+
// #seat-effort (O2) — resolve precedence ONCE here and set the RESOLVED value on the ctx, the
|
|
2177
|
+
// same seam `depth` threads through. Captured so chair_complete records what the seat ran at.
|
|
2178
|
+
resolvedEffort = resolveEffort(deps.effort, agent);
|
|
2179
|
+
// contract-seat-context-ceiling-v1 (O2) — resolve the context ceiling ONCE here (dispatch ▷
|
|
2180
|
+
// agent ▷ none) and set the RESOLVED value on the ctx only when one exists, so an undeclared
|
|
2181
|
+
// seat carries no ceiling and the completions invoker runs it uncapped (I1).
|
|
2182
|
+
const resolvedMaxContext = resolveMaxContextTokens(deps.max_context_tokens, agent);
|
|
2183
|
+
// contract-primer-reading-frontier-v1 (O4/I1) — snapshot the tree the moment BEFORE a PRIME seat
|
|
2184
|
+
// runs (git stash create captures the working tree without touching it; empty output ⇒ a clean
|
|
2185
|
+
// tree, so HEAD is the snapshot). A file the seat reads then edits is sealed at this pre-edit blob
|
|
2186
|
+
// (git rev-parse <snapshot>:<path>), and a carried file the seat never re-reads keeps its forked
|
|
2187
|
+
// blob — so a fork remembers exactly what the primer READ, not what it then DID. Best-effort: a
|
|
2188
|
+
// tree that cannot be snapshotted leaves this undefined and the seal falls back to hash-object.
|
|
2189
|
+
if (chair.prime && deps.tree_root !== undefined) {
|
|
2190
|
+
try {
|
|
2191
|
+
const created = gitInTree(deps.tree_root, ["stash", "create"]).trim();
|
|
2192
|
+
primerStartSnapshot = created.length > 0 ? created : gitInTree(deps.tree_root, ["rev-parse", "HEAD"]).trim();
|
|
2193
|
+
}
|
|
2194
|
+
catch {
|
|
2195
|
+
primerStartSnapshot = undefined;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
1796
2198
|
data = await deps.invoke({
|
|
1797
|
-
agent, phase: phaseName, gig_id, inputs, gig_input: gigInput, skills,
|
|
2199
|
+
agent, phase: phaseName, role: chair.role, gig_id, inputs, gig_input: gigInput, skills,
|
|
1798
2200
|
missing_skills: p.missing_skills, // #241 — what did NOT resolve, so the prompt can't assert it
|
|
1799
2201
|
// THE SEAT IS WHERE THE INSTITUTION'S DATA ENTERS. Validated at compose time (the dead-slot
|
|
1800
2202
|
// refusal) and, until now, dropped on the floor immediately afterwards.
|
|
@@ -1809,12 +2211,31 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1809
2211
|
// itself, so an invoker can kill its child and shape what it asks the model for.
|
|
1810
2212
|
...(deps.signal ? { signal: deps.signal } : {}),
|
|
1811
2213
|
...(deps.depth ? { depth: deps.depth } : {}),
|
|
2214
|
+
// #seat-effort (O2) — the RESOLVED effort reaches the invoker on the ctx, always present
|
|
2215
|
+
// (resolveEffort floors to medium), so both invokers carry it without re-deriving.
|
|
2216
|
+
effort: resolvedEffort,
|
|
2217
|
+
// contract-seat-context-ceiling-v1 (O2/O3) — the RESOLVED ceiling reaches the invoker on the
|
|
2218
|
+
// ctx, present ONLY when declared (no floor), so an undeclared seat stays uncapped (I1).
|
|
2219
|
+
...(resolvedMaxContext !== undefined ? { max_context_tokens: resolvedMaxContext } : {}),
|
|
1812
2220
|
// #turn-budget — the chair's own turn budget threads through exactly as `depth` does; the
|
|
1813
2221
|
// reserve is the pool-capped OFFER, not the raw declaration, and is present only when the
|
|
1814
2222
|
// chair declared a reserve (so a reserve-less chair leaves the invoker's opts-level default
|
|
1815
2223
|
// untouched — the #329 continuation path stays byte-identical).
|
|
1816
2224
|
...(p.chair.turn_budget !== undefined ? { turn_budget: p.chair.turn_budget } : {}),
|
|
1817
2225
|
...(p.reserve_offer !== undefined ? { turn_reserve: p.reserve_offer } : {}),
|
|
2226
|
+
// contract-chair-session-continuity-v1 (O3) — an amend re-invocation resumes the maker's
|
|
2227
|
+
// own prior-round session; the invoker reads this to pass --resume instead of --session-id.
|
|
2228
|
+
...(p.resume ? { resume: true } : {}),
|
|
2229
|
+
// contract-resumed-gig-session-v1 (O1) — a re-verify resumes its session but keeps the full
|
|
2230
|
+
// prompt; thread it so buildPrompt skips the maker's trimmed continuation for this seat.
|
|
2231
|
+
...(p.resume_keep_prompt ? { resume_keep_prompt: true } : {}),
|
|
2232
|
+
// contract-seat-primer-v1 (O1/I2) — a PRIME chair: the invoker parses its Read events and
|
|
2233
|
+
// emits them so the runtime seals the seat-primer from exactly what this seat read.
|
|
2234
|
+
...(chair.prime ? { prime: chair.prime } : {}),
|
|
2235
|
+
// contract-seat-primer-v1 (O2/O4) — a FORK chair with a primer: the invoker warm-starts from
|
|
2236
|
+
// the primer's session and names the stale paths in the prompt. Absent on a plain chair or a
|
|
2237
|
+
// fork whose primer is missing (F1), so both spawn a fresh session and never --fork-session.
|
|
2238
|
+
...(p.fork ? { fork: { primer_session_id: p.fork.primer_session_id, stale_paths: p.fork.stale_paths, ...(p.fork.frontier !== undefined ? { frontier: p.fork.frontier } : {}) } } : {}),
|
|
1818
2239
|
// The venue → dispatch wire: thread the realized room onto the chair's ctx ONLY when a
|
|
1819
2240
|
// venue resolved, so the invoker narrows the spawn by construction; both fields stay
|
|
1820
2241
|
// absent otherwise (the venue-less path is unchanged).
|
|
@@ -1831,6 +2252,53 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1831
2252
|
onEvent: (ev) => {
|
|
1832
2253
|
sink.fold(ev);
|
|
1833
2254
|
emit({ type: "agent_event", phase: phaseName, role: chair.role, event: ev });
|
|
2255
|
+
// contract-amend-resume-prompt-v1 (F1) — the invoker's cold-fallback signal. Captured here
|
|
2256
|
+
// (the one seam every chair event flows through) so chair_complete can record it.
|
|
2257
|
+
if (ev.type === "resume_fallback")
|
|
2258
|
+
resumeFellBack = true;
|
|
2259
|
+
// contract-resumed-gig-session-v1 (O3) — the invoker's collision-resume signal, captured on
|
|
2260
|
+
// the same seam so chair_complete can record the chair CONTINUED its session.
|
|
2261
|
+
if (ev.type === "resume_on_collision")
|
|
2262
|
+
resumedOnCollision = true;
|
|
2263
|
+
// contract-seat-primer-v1 (O1/I2) — a PRIME seat's forwarded reads, so the seat-primer is
|
|
2264
|
+
// sealed (below) from exactly the files it Read.
|
|
2265
|
+
if (ev.type === "seat_reads") {
|
|
2266
|
+
const r = ev.raw?.reads;
|
|
2267
|
+
primerReads = Array.isArray(r) ? r.filter((x) => typeof x === "string") : [];
|
|
2268
|
+
// contract-primer-reading-frontier-v1 (O1) — the frontier travels on the same event.
|
|
2269
|
+
const f = ev.raw?.frontier;
|
|
2270
|
+
primerFrontier = typeof f === "string" ? f : undefined;
|
|
2271
|
+
}
|
|
2272
|
+
// contract-rolling-seat-primer-v1 (O3) — the seat's context size at seal, forwarded by the
|
|
2273
|
+
// invoker (the injected `run` seam bypasses the streamed usages onEvent would otherwise fold).
|
|
2274
|
+
if (ev.type === "seat_context") {
|
|
2275
|
+
const c = ev.raw?.context_tokens;
|
|
2276
|
+
if (typeof c === "number")
|
|
2277
|
+
primerContextTokens = c;
|
|
2278
|
+
}
|
|
2279
|
+
// contract-seat-primer-v1 (F2) — the fork's primer session could not be resumed and it fell
|
|
2280
|
+
// back cold. Captured so chair_complete records the fallback (naming the session), not a
|
|
2281
|
+
// fork that never happened.
|
|
2282
|
+
if (ev.type === "fork_fallback") {
|
|
2283
|
+
const reason = ev.raw?.reason;
|
|
2284
|
+
forkFellBackReason = typeof reason === "string" ? reason : "the primer session could not be resumed";
|
|
2285
|
+
}
|
|
2286
|
+
// #seat-metrics — track the last assistant context and the FIRST write, BEFORE the
|
|
2287
|
+
// budget early-return below (which fires whenever no budget is wired). An assistant
|
|
2288
|
+
// event's raw.message.usage carries the context; the first Write/Edit/MultiEdit/
|
|
2289
|
+
// NotebookEdit tool_use marks the first write and snapshots the context as of the last
|
|
2290
|
+
// assistant usage before it.
|
|
2291
|
+
if (ev.type === "assistant") {
|
|
2292
|
+
const u = ev.raw?.message?.usage;
|
|
2293
|
+
if (u) {
|
|
2294
|
+
lastAssistantContext =
|
|
2295
|
+
(u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
else if (firstWriteMs === null && ev.type === "tool_use" && ev.tool !== undefined && WRITE_TOOLS.has(ev.tool)) {
|
|
2299
|
+
firstWriteMs = Math.round(performance.now() - t0);
|
|
2300
|
+
contextAtFirstWrite = lastAssistantContext;
|
|
2301
|
+
}
|
|
1834
2302
|
if (!budget)
|
|
1835
2303
|
return;
|
|
1836
2304
|
// A chair crossed its budget into a granted reserve. Set `yielding` (D1: one condition
|
|
@@ -1871,6 +2339,12 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1871
2339
|
finally {
|
|
1872
2340
|
if (sink.attributed())
|
|
1873
2341
|
attributedInvocations++;
|
|
2342
|
+
// F3 — under a ceiling, a chair that reported usage but NO settled usd leaves the next
|
|
2343
|
+
// batch unverifiable: the runtime cannot know whether it is affordable. Record it (never
|
|
2344
|
+
// silent). A chair that reported nothing at all (a plain stub) is not here.
|
|
2345
|
+
if (hasCeiling && sink.attributed() && !sink.reportedCost())
|
|
2346
|
+
unverifiedChairs.push(agent.slug);
|
|
2347
|
+
chairReport = sink.reported();
|
|
1874
2348
|
}
|
|
1875
2349
|
// The drawing chair LANDED within its reserve → clear yielding back to `active` (O12/INV16).
|
|
1876
2350
|
// The gig-end success path then settles it; an idle chair that held but never drew releases here.
|
|
@@ -1879,6 +2353,29 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1879
2353
|
emit({ type: "budget_state", phase: phaseName, role: chair.role, agent_state: "active", pool_remaining: budget.pool_remaining });
|
|
1880
2354
|
}
|
|
1881
2355
|
releaseHold();
|
|
2356
|
+
// ── contract-spend-survives-v1 (O1/I2/O2/F1) — the DURABLE chair_spend row ─────────────────
|
|
2357
|
+
// Appended the moment THIS chair's invocation settled — before the next chair is invoked, and
|
|
2358
|
+
// before the output_contract check below (which can throw) — so a kill or a failure on a later
|
|
2359
|
+
// chair leaves this one's captured spend behind. An attributed chair carries its own usage; an
|
|
2360
|
+
// unattributed one seals captured:false with NO cost, never a $0 row (#235). The gig-wide fold
|
|
2361
|
+
// still writes the single gig row on success; these rows are the per-chair record that survives
|
|
2362
|
+
// when that row is never sealed. Appended only for a model invocation — a skill chair runs no
|
|
2363
|
+
// model and settles no spend — and never for a reuse hit (which returns before reaching here).
|
|
2364
|
+
const chairSettledUsage = sink.usage();
|
|
2365
|
+
deps.ledger.append({
|
|
2366
|
+
kind: "chair_spend",
|
|
2367
|
+
schema_version: LEDGER_SCHEMA_VERSION,
|
|
2368
|
+
entry_id: `chair_spend:${gig_id}:${chair.role}:${p.round ?? 1}:${randomUUID()}`,
|
|
2369
|
+
gig_id,
|
|
2370
|
+
role: chair.role,
|
|
2371
|
+
phase: phaseName,
|
|
2372
|
+
round: p.round ?? 1,
|
|
2373
|
+
captured: sink.attributed(),
|
|
2374
|
+
output_hashes: [],
|
|
2375
|
+
started_at: new Date(t0).toISOString(),
|
|
2376
|
+
finished_at: new Date().toISOString(),
|
|
2377
|
+
...(chairSettledUsage ? { usage: chairSettledUsage } : {}),
|
|
2378
|
+
});
|
|
1882
2379
|
// Runtime output_contract check: every type the chair promised must be covered by the
|
|
1883
2380
|
// bound agent's declared output_types (compose-time mirror; a hand-rolled literal could
|
|
1884
2381
|
// still ship a mismatch).
|
|
@@ -1990,6 +2487,25 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
1990
2487
|
// #243 — DECIDE BEFORE SEALING. Every check that can throw now runs against resolved
|
|
1991
2488
|
// slices while nothing has been written yet.
|
|
1992
2489
|
//
|
|
2490
|
+
// AN INVOKER'S TYPED REFUSAL IS A REASON, NOT A MALFORMED OUTPUT. An invoker that declines —
|
|
2491
|
+
// a chair granting a tool that port does not carry, a tier the deployment never mapped, an
|
|
2492
|
+
// upstream that could not be reached — returns `{ok:false, refusal, message}` rather than
|
|
2493
|
+
// throwing, so the refusal is a value the engine can act on. Without this it fell straight
|
|
2494
|
+
// through to the seal path and the operator was told
|
|
2495
|
+
//
|
|
2496
|
+
// cannot seal "p": ... additionalProperties: must NOT have additional properties 'refusal'
|
|
2497
|
+
//
|
|
2498
|
+
// which is the shape complaint of the very object carrying the answer. The reason was present
|
|
2499
|
+
// and nothing read it — a refusal typed and then discarded is the same defect as a mechanism
|
|
2500
|
+
// nothing reaches, one seam over.
|
|
2501
|
+
//
|
|
2502
|
+
// Narrow on purpose: `ok === false` with BOTH a string refusal and a string message. A domain
|
|
2503
|
+
// type is free to have an `ok` field; it is not plausibly carrying all three.
|
|
2504
|
+
const refusal = data["refusal"];
|
|
2505
|
+
const refusalWhy = data["message"];
|
|
2506
|
+
if (data["ok"] === false && typeof refusal === "string" && typeof refusalWhy === "string") {
|
|
2507
|
+
throw new RuntimeError(`chair "${chair.role}" refused: ${refusal} — ${refusalWhy}`);
|
|
2508
|
+
}
|
|
1993
2509
|
// The floor check used to sit AFTER the write loop, which created a failure class that did
|
|
1994
2510
|
// not previously exist: a chair delivering part of its contract sealed those records,
|
|
1995
2511
|
// append-flushed them to `outputs/<gig_id>.jsonl`, and only THEN threw — so the gig failed
|
|
@@ -2000,7 +2516,37 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2000
2516
|
const resolved = [];
|
|
2001
2517
|
for (const spec of output_specs) {
|
|
2002
2518
|
const keyed = data[spec.domain_type];
|
|
2003
|
-
|
|
2519
|
+
// WRAPPER vs FIELD — THE TYPE'S OWN SCHEMA DECIDES, never the data's shape. A chair may return
|
|
2520
|
+
// its record bare, or keyed under its type slug (`{ <type>: <record> }`). But a record whose
|
|
2521
|
+
// OWN schema declares a field named like its type (a type `line` with a `line` field) puts a
|
|
2522
|
+
// value under `data[<type>]` that is a FIELD, not a wrapper — and the shape alone cannot tell
|
|
2523
|
+
// the two apart (law 2: an OBJECT field looks exactly like a wrapped record). The schema can:
|
|
2524
|
+
// the keyed value is a wrapper only if it VALIDATES as a record of the type while the whole
|
|
2525
|
+
// `data` does not. So when `data[<type>]` cannot itself be a record of this type but the whole
|
|
2526
|
+
// `data` can, `data[<type>]` is a field and the whole record seals; otherwise the keyed wrapper
|
|
2527
|
+
// is honoured (control law 3), and multi-output (`single` false) is untouched — its blob is
|
|
2528
|
+
// keyed by construction. An array under the key is the multi-record seal list; honour it as-is.
|
|
2529
|
+
let raw;
|
|
2530
|
+
if (keyed === undefined || keyed === null) {
|
|
2531
|
+
raw = single ? data : undefined;
|
|
2532
|
+
}
|
|
2533
|
+
else if (single && !Array.isArray(keyed)) {
|
|
2534
|
+
const keyedIsRecord = typeof keyed === "object" &&
|
|
2535
|
+
deps.outputs.validateWrite({
|
|
2536
|
+
core_type: spec.core_type,
|
|
2537
|
+
domain_type: spec.domain_type,
|
|
2538
|
+
data: keyed,
|
|
2539
|
+
}).valid;
|
|
2540
|
+
const wholeIsRecord = deps.outputs.validateWrite({
|
|
2541
|
+
core_type: spec.core_type,
|
|
2542
|
+
domain_type: spec.domain_type,
|
|
2543
|
+
data,
|
|
2544
|
+
}).valid;
|
|
2545
|
+
raw = !keyedIsRecord && wholeIsRecord ? data : keyed;
|
|
2546
|
+
}
|
|
2547
|
+
else {
|
|
2548
|
+
raw = keyed;
|
|
2549
|
+
}
|
|
2004
2550
|
if (raw === undefined || raw === null)
|
|
2005
2551
|
continue;
|
|
2006
2552
|
// MULTI-RECORD SEAL. captureOutputWrites hands the runtime a LIST of records per declared type
|
|
@@ -2020,6 +2566,21 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2020
2566
|
// that throw also lands before the first write, rather than midway through them.
|
|
2021
2567
|
for (const { slice } of resolved)
|
|
2022
2568
|
backfillShas(slice);
|
|
2569
|
+
// RECORDS BY ADDRESS — the seal stamps law/change addresses from git, beside the *_sha backfill
|
|
2570
|
+
// and before any validateWrite or write, so a stamping refusal fails the chair with its typed
|
|
2571
|
+
// reason and nothing is sealed. A sealed `red-spec` carrying `laws` (or a `change-set` carrying
|
|
2572
|
+
// `changes`) has those entries REPLACED with the engine-stamped ones; a record carrying only
|
|
2573
|
+
// `diffs` and no `laws`/`changes` is left exactly as today (the pre-migration shape this gig's
|
|
2574
|
+
// own attester and builder still seal). tree_root is required only once a record actually carries
|
|
2575
|
+
// an address — a stamp with no tree_root refuses (`tree_root_unknown`), never reads process.cwd().
|
|
2576
|
+
for (const { spec, slice } of resolved) {
|
|
2577
|
+
if (spec.domain_type === "red-spec" && Array.isArray(slice["laws"])) {
|
|
2578
|
+
slice["laws"] = stampLawAddresses(slice["laws"], deps.tree_root);
|
|
2579
|
+
}
|
|
2580
|
+
else if (spec.domain_type === "change-set" && Array.isArray(slice["changes"])) {
|
|
2581
|
+
slice["changes"] = stampChangeAddresses(slice["changes"], deps.tree_root);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2023
2584
|
// The output_contract is a FLOOR, not merely a selector. `written.length === 0` alone let a
|
|
2024
2585
|
// chair that promised two types and sealed one complete silently. The old in-code
|
|
2025
2586
|
// justification — a keyed type may be conditional, and a downstream input_contract check
|
|
@@ -2078,6 +2639,15 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2078
2639
|
// Only now does anything become durable.
|
|
2079
2640
|
const written = [];
|
|
2080
2641
|
for (const { spec, slice } of resolved) {
|
|
2642
|
+
// contract-chair-cost-once-v1 — one chair invocation is settled ONCE (its `result` event
|
|
2643
|
+
// reports a single total_cost_usd), so its spend is attributed to exactly ONE sealed record.
|
|
2644
|
+
// The FIRST record this invocation seals carries `chairReport.cost_usd` / `tokens_used`; every
|
|
2645
|
+
// later record omits both and carries `cost_on`: the first record's id. Stamping the whole
|
|
2646
|
+
// chairReport on every record made a chair of width N report its spend N times, so summing
|
|
2647
|
+
// `cost_usd` over a gig double-counted every multi-output chair (coltrane-ui#242). `written` is
|
|
2648
|
+
// this invocation's records in seal order, so it is empty exactly at the first record and its
|
|
2649
|
+
// head is the cost carrier for the rest.
|
|
2650
|
+
const costCarrier = written[0];
|
|
2081
2651
|
const rec = deps.outputs.write({
|
|
2082
2652
|
core_type: spec.core_type,
|
|
2083
2653
|
domain_type: spec.domain_type,
|
|
@@ -2097,10 +2667,28 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2097
2667
|
// WHICH model produced this, resolved through the invoker's own function so the stamp
|
|
2098
2668
|
// and the spawn cannot disagree. Absent for a skill-backed chair — no model ran, and
|
|
2099
2669
|
// absent must mean unknown rather than "the default".
|
|
2670
|
+
// WHICH model produced this. The transport's own word wins: `resolveModel` is one
|
|
2671
|
+
// invoker's tier table, and the runtime calls it for EVERY invoker — so a chair served by
|
|
2672
|
+
// any other port used to seal a stamp naming a model it never touched. The fallback stays
|
|
2673
|
+
// (a transport may report nothing) but it no longer masquerades as a measurement:
|
|
2674
|
+
// `model_reported` says which of the two this is. Absent for skill-backed chairs — no
|
|
2675
|
+
// model ran, and absent must mean unknown rather than "the default".
|
|
2100
2676
|
...(p.agent
|
|
2101
2677
|
? {
|
|
2102
|
-
model: resolveModel(p.agent.model_tier, deps.model_version),
|
|
2678
|
+
model: chairReport.model ?? resolveModel(p.agent.model_tier, deps.model_version),
|
|
2679
|
+
...(chairReport.model !== undefined ? { model_reported: true } : {}),
|
|
2103
2680
|
...(p.agent.model_tier ? { model_tier: p.agent.model_tier } : {}),
|
|
2681
|
+
// Per-chair spend, declared in the record's own schema since it was written and
|
|
2682
|
+
// populated by nothing. The gig total cannot separate two chairs on two tiers,
|
|
2683
|
+
// which is the only question per-chair routing asks. Attributed ONCE per invocation
|
|
2684
|
+
// (contract-chair-cost-once-v1): the first record carries the settled cost + tokens;
|
|
2685
|
+
// every later record carries `cost_on` at the carrier and no cost of its own.
|
|
2686
|
+
...(costCarrier === undefined
|
|
2687
|
+
? {
|
|
2688
|
+
...(chairReport.cost_usd !== undefined ? { cost_usd: chairReport.cost_usd } : {}),
|
|
2689
|
+
...(chairReport.tokens_used !== undefined ? { tokens_used: chairReport.tokens_used } : {}),
|
|
2690
|
+
}
|
|
2691
|
+
: { cost_on: costCarrier.id }),
|
|
2104
2692
|
}
|
|
2105
2693
|
: {}),
|
|
2106
2694
|
skill_provenance,
|
|
@@ -2131,6 +2719,8 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2131
2719
|
core_type: w.core_type, domain_type: w.domain_type, domain: w.domain,
|
|
2132
2720
|
primitive: w.primitive, agent_slug: w.agent_slug, phase: phaseName,
|
|
2133
2721
|
data: w.data, content_sha: w.content_sha, type_fingerprint: fp, source_output_id: w.id,
|
|
2722
|
+
// O4 — carry the sealed version so a recall re-stamps it and hashes as the record it recalls.
|
|
2723
|
+
domain_type_version: w.domain_type_version,
|
|
2134
2724
|
...(w.skill_provenance ? { skill_provenance: w.skill_provenance } : {}),
|
|
2135
2725
|
});
|
|
2136
2726
|
}
|
|
@@ -2148,16 +2738,180 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2148
2738
|
}
|
|
2149
2739
|
}
|
|
2150
2740
|
}
|
|
2741
|
+
// contract-seat-primer-v1 (O1/I2) — a PRIME chair seals a `seat-primer` record DERIVED by the
|
|
2742
|
+
// engine (never typed by the model): its own (gig, role) session, HEAD at seal, and the files its
|
|
2743
|
+
// seat Read, each with the `git hash-object` blob in the tree at seal. The reads arrive through the
|
|
2744
|
+
// invoker's `seat_reads` event (captured above); the record is sealed HERE, through the same write
|
|
2745
|
+
// boundary a derived output crosses, so it enters this gig's store and any later fork can find it.
|
|
2746
|
+
if (chair.prime) {
|
|
2747
|
+
const area = chair.prime.area;
|
|
2748
|
+
const session_id = sessionUuidFor(gig_id, chair.role);
|
|
2749
|
+
const reads = primerReads ?? [];
|
|
2750
|
+
const commit = deps.tree_root ? gitInTree(deps.tree_root, ["rev-parse", "HEAD"]).trim() : "";
|
|
2751
|
+
// contract-rolling-seat-primer-v1 (O2) — a chair that ALSO forked a primer (p.fork resolved) seals
|
|
2752
|
+
// a FRESHER primer: the forked primer's files UNIONed with this seat's own reads, de-duped by path
|
|
2753
|
+
// (forked first, then own reads), each RE-BLOBBED against the tree at seal. A chair that fell back
|
|
2754
|
+
// cold (primer_too_large / primer_missing → no p.fork) unions nothing and seals only its own reads,
|
|
2755
|
+
// which is the fresh, small primer the ceiling/first-primer path calls for. The `session_id` stays
|
|
2756
|
+
// this build's own (gig, role) uuid — the fork branch the next build resumes.
|
|
2757
|
+
const forkedFiles = p.fork?.primer_files ?? [];
|
|
2758
|
+
// contract-seat-primer-paths-v1 (O1/I1/F1) — a seat-primer must name its files the way the
|
|
2759
|
+
// repository does, so ANY checkout of this commit can use it. A seat's Read event carries an
|
|
2760
|
+
// ABSOLUTE checkout path, which ties the primer to one machine's location. Normalize each read to
|
|
2761
|
+
// its tree_root-relative POSIX path (an already-relative read is resolved against tree_root first,
|
|
2762
|
+
// so both spellings of the same file collapse to one key — I1), and DROP any path that resolves
|
|
2763
|
+
// OUTSIDE tree_root — it is not part of the area and must never be stored, above all not as an
|
|
2764
|
+
// absolute path escaping it (F1). With paths stored relative, the fork-time staleness hash
|
|
2765
|
+
// (gitInTree hash-object) resolves them in the FORKING run's own checkout, so a primer sealed
|
|
2766
|
+
// under one checkout is fresh in another of the same content (O2). Without a tree_root there is no
|
|
2767
|
+
// anchor (and no git resolution downstream), so the raw path is kept unchanged.
|
|
2768
|
+
const toTreeRelative = (raw) => {
|
|
2769
|
+
if (deps.tree_root === undefined)
|
|
2770
|
+
return raw;
|
|
2771
|
+
const abs = isAbsPath(raw) ? raw : joinPath(deps.tree_root, raw);
|
|
2772
|
+
const rel = relPath(deps.tree_root, abs);
|
|
2773
|
+
if (rel === "" || rel.startsWith("..") || isAbsPath(rel))
|
|
2774
|
+
return undefined; // outside tree_root
|
|
2775
|
+
return rel.split(pathSep).join("/");
|
|
2776
|
+
};
|
|
2777
|
+
// The seat's OWN reads — already CUT to those before the reading frontier by the invoker
|
|
2778
|
+
// (contract-primer-reading-frontier-v1 O4) — normalized to tree-relative and de-duped.
|
|
2779
|
+
const readRel = [];
|
|
2780
|
+
const readSet = new Set();
|
|
2781
|
+
for (const raw of reads) {
|
|
2782
|
+
const rel = toTreeRelative(raw);
|
|
2783
|
+
if (rel !== undefined && !readSet.has(rel)) {
|
|
2784
|
+
readSet.add(rel);
|
|
2785
|
+
readRel.push(rel);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
// contract-carried-primer-paths-v1 (O1/I1/F1) — a CARRIED file obeys the SAME path rules as a
|
|
2789
|
+
// read: normalize each forked path through the SAME toTreeRelative (absolute-under-tree_root →
|
|
2790
|
+
// tree_root-relative POSIX; a path resolving OUTSIDE tree_root is dropped, never stored — F1)
|
|
2791
|
+
// BEFORE de-duping, so a carried absolute spelling and a relative read of the same file collapse
|
|
2792
|
+
// to one tree-relative key (I1). The blob map is keyed by the NORMALIZED path (first carried
|
|
2793
|
+
// entry wins), so a carried file the seat did NOT re-read keeps the blob the forked primer
|
|
2794
|
+
// recorded — never re-blobbed against the tree (O1; contract-primer-reading-frontier-v1 I1).
|
|
2795
|
+
const carriedRel = [];
|
|
2796
|
+
const forkedBlob = new Map();
|
|
2797
|
+
for (const f of forkedFiles) {
|
|
2798
|
+
const rel = toTreeRelative(f.path);
|
|
2799
|
+
if (rel === undefined)
|
|
2800
|
+
continue; // outside tree_root — never stored (F1)
|
|
2801
|
+
if (!forkedBlob.has(rel))
|
|
2802
|
+
forkedBlob.set(rel, f.blob_sha);
|
|
2803
|
+
carriedRel.push(rel);
|
|
2804
|
+
}
|
|
2805
|
+
// Union: carried (forked) first, then the seat's own reads, de-duped by NORMALIZED path.
|
|
2806
|
+
const orderedPaths = [];
|
|
2807
|
+
const seenPaths = new Set();
|
|
2808
|
+
for (const path of [...carriedRel, ...readRel]) {
|
|
2809
|
+
if (!seenPaths.has(path)) {
|
|
2810
|
+
seenPaths.add(path);
|
|
2811
|
+
orderedPaths.push(path);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
// contract-primer-reading-frontier-v1 (O4/I1) — a file the seat READ before its frontier is sealed
|
|
2815
|
+
// at its CHAIR-START blob: `git rev-parse <snapshot>:<path>` against the pre-invoke snapshot, so a
|
|
2816
|
+
// file read-then-edited keeps its pre-edit blob (what the fork remembers), and a path the snapshot
|
|
2817
|
+
// does not hold (untracked at chair start) is hashed at seal. A carried file the seat did NOT
|
|
2818
|
+
// re-read keeps the blob the forked primer recorded — the reseal never launders a stale file fresh.
|
|
2819
|
+
const blobFor = (path) => {
|
|
2820
|
+
if (readSet.has(path)) {
|
|
2821
|
+
if (deps.tree_root === undefined)
|
|
2822
|
+
return "";
|
|
2823
|
+
if (primerStartSnapshot !== undefined) {
|
|
2824
|
+
try {
|
|
2825
|
+
return gitInTree(deps.tree_root, ["rev-parse", `${primerStartSnapshot}:${path}`]).trim();
|
|
2826
|
+
}
|
|
2827
|
+
catch { /* untracked at chair start — hash at seal below */ }
|
|
2828
|
+
}
|
|
2829
|
+
try {
|
|
2830
|
+
return gitInTree(deps.tree_root, ["hash-object", path]).trim();
|
|
2831
|
+
}
|
|
2832
|
+
catch {
|
|
2833
|
+
return "";
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
return forkedBlob.get(path) ?? "";
|
|
2837
|
+
};
|
|
2838
|
+
const files = orderedPaths.map((path) => ({ path, blob_sha: blobFor(path) }));
|
|
2839
|
+
// contract-rolling-seat-primer-v1 (O3) — the context size of the LAST usage the seat reported:
|
|
2840
|
+
// the invoker-forwarded value under an injected `run` seam, else the streamed fold. Recorded ONLY
|
|
2841
|
+
// when a usage was actually reported — a seat that reported none has no "last usage" to stamp, so
|
|
2842
|
+
// the field is omitted (the same conditional discipline as `session_id`), which also keeps a seat-
|
|
2843
|
+
// primer type that never declared context_tokens sealing cleanly.
|
|
2844
|
+
const contextTokens = primerContextTokens ?? (lastAssistantContext ?? undefined);
|
|
2845
|
+
deps.outputs.write({
|
|
2846
|
+
core_type: "Signal",
|
|
2847
|
+
domain_type: "seat-primer",
|
|
2848
|
+
domain,
|
|
2849
|
+
gig_id,
|
|
2850
|
+
agent_slug: producer_slug,
|
|
2851
|
+
from_role: chair.role,
|
|
2852
|
+
phase: phaseName,
|
|
2853
|
+
primitive: CORE_TO_PRIMITIVE["Signal"] ?? "SENSE",
|
|
2854
|
+
// `source` is the Signal core's substance floor; the rest is the derived primer.
|
|
2855
|
+
data: {
|
|
2856
|
+
agent_slug: producer_slug, area,
|
|
2857
|
+
...(session_id !== undefined ? { session_id } : {}),
|
|
2858
|
+
commit, files,
|
|
2859
|
+
// contract-rolling-seat-primer-v1 (O3) — the context size the seat carried at seal
|
|
2860
|
+
// (input+cache_read+cache_creation of its LAST usage): what the next build's ceiling weighs.
|
|
2861
|
+
...(contextTokens !== undefined ? { context_tokens: contextTokens } : {}),
|
|
2862
|
+
// contract-primer-reading-frontier-v1 (O1) — seal the reading frontier ONLY when the seat wrote
|
|
2863
|
+
// (the invoker derived one); a read-only or user-less-write seat records none (I3/F1), so a
|
|
2864
|
+
// frontier is never guessed.
|
|
2865
|
+
...(primerFrontier !== undefined ? { frontier: primerFrontier } : {}),
|
|
2866
|
+
source: `seat-primer://${producer_slug}/${area}`,
|
|
2867
|
+
},
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2151
2870
|
// A DECLARED-optional absence is still a fact about this run. Legitimising a shortfall is
|
|
2152
2871
|
// not the same as hiding it, so it keeps its row in the manifest.
|
|
2153
2872
|
if (missing.length > 0)
|
|
2154
2873
|
unfulfilledOutputs.push({ role: chair.role, phase: phaseName, missing });
|
|
2874
|
+
// contract-seat-primer-v1 (O3/O4/F1/F2) — a fork that WARM-STARTED records where it forked from and
|
|
2875
|
+
// the paths stale since priming; a fork that FELL BACK cold (no primer, or an unresumable session)
|
|
2876
|
+
// records the fallback instead. `forkFellBackReason` is set by the invoker's cold-fallback event
|
|
2877
|
+
// (F2); `p.fork_fallback` is the prep-time "primer_missing" (F1). A cold fallback did NOT fork, so
|
|
2878
|
+
// it records neither forked_from nor primer_stale_paths.
|
|
2879
|
+
const forkFellBack = forkFellBackReason !== undefined;
|
|
2155
2880
|
emit({
|
|
2156
2881
|
type: "chair_complete", phase: phaseName, role: chair.role, producer: producer_slug,
|
|
2157
|
-
output_types: written.map((w) => w.domain_type), duration_ms:
|
|
2882
|
+
output_types: written.map((w) => w.domain_type), duration_ms: Math.round(performance.now() - t0),
|
|
2883
|
+
first_write_ms: firstWriteMs,
|
|
2884
|
+
context_tokens_at_first_write: contextAtFirstWrite,
|
|
2158
2885
|
promised_output_types: output_specs.map((s) => s.domain_type),
|
|
2159
2886
|
missing_output_types: missing,
|
|
2160
2887
|
...(unresolvedShaFields.length > 0 ? { unresolved_sha_fields: unresolvedShaFields } : {}),
|
|
2888
|
+
// #seat-effort (O5) — record the effort the seat ran at (a model chair only; a skill chair
|
|
2889
|
+
// leaves it undefined and the field stays absent).
|
|
2890
|
+
...(resolvedEffort !== undefined ? { effort: resolvedEffort } : {}),
|
|
2891
|
+
// contract-chair-session-continuity-v1 (O4) — record the seat's session id (the uuid derived
|
|
2892
|
+
// from (gig_id, role)) and whether THIS invocation resumed it. A model chair only; a skill
|
|
2893
|
+
// chair (no p.agent) runs no session, so both fields stay absent. Computed here from the same
|
|
2894
|
+
// (gig_id, role) the invoker derives the spawn's --session-id from, so the record and the
|
|
2895
|
+
// spawn name one session.
|
|
2896
|
+
// contract-resumed-gig-session-v1 (O3) — `resumed` is true when THIS invocation continued its
|
|
2897
|
+
// session: either an amend round (`p.resume`) OR a first open whose deterministic id collided and
|
|
2898
|
+
// was resumed on the spot (`resumedOnCollision`). The second case is a first spawn, so `p.resume`
|
|
2899
|
+
// is false and only the collision signal tells the truth of it.
|
|
2900
|
+
...(p.agent && sessionUuidFor(gig_id, chair.role) !== undefined
|
|
2901
|
+
? { session_id: sessionUuidFor(gig_id, chair.role), resumed: p.resume === true || resumedOnCollision }
|
|
2902
|
+
: {}),
|
|
2903
|
+
// contract-amend-resume-prompt-v1 (F1) — a resume that fell back cold is recorded (never silent),
|
|
2904
|
+
// and only then, so a normal spawn's chair_complete is byte-identical to before.
|
|
2905
|
+
...(resumeFellBack ? { resume_fallback: true } : {}),
|
|
2906
|
+
// contract-seat-primer-v1 (O3/O4) — a fork that warm-started names its primer and the stale paths.
|
|
2907
|
+
...(p.fork && !forkFellBack
|
|
2908
|
+
? {
|
|
2909
|
+
forked_from: { id: p.fork.primer_id, session_id: p.fork.primer_session_id, commit: p.fork.primer_commit },
|
|
2910
|
+
primer_stale_paths: p.fork.stale_paths,
|
|
2911
|
+
}
|
|
2912
|
+
: {}),
|
|
2913
|
+
// contract-seat-primer-v1 (F1/F2) — a fork that could not warm-start records why (never silent).
|
|
2914
|
+
...(forkFellBack ? { fork_fallback: forkFellBackReason } : p.fork_fallback !== undefined ? { fork_fallback: p.fork_fallback } : {}),
|
|
2161
2915
|
});
|
|
2162
2916
|
return written;
|
|
2163
2917
|
}
|
|
@@ -2219,6 +2973,10 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2219
2973
|
// invokers, or a run whose every invocation reported no usage payload). #235: an absent
|
|
2220
2974
|
// usage block means "not captured", never "$0.00".
|
|
2221
2975
|
...(settledUsage ? { usage: settledUsage } : {}),
|
|
2976
|
+
// contract-spend-survives-v1 (O4) — a RESUMED gig's row carries what the killed attempt spent,
|
|
2977
|
+
// read from the checkpoint (resumedFrom.prior_usage) and kept BESIDE this run's own `usage`,
|
|
2978
|
+
// never folded in (#235/#236). Absent on a cold run.
|
|
2979
|
+
...(resumedFrom?.prior_usage !== undefined ? { prior_usage: resumedFrom.prior_usage } : {}),
|
|
2222
2980
|
});
|
|
2223
2981
|
// Drain the gig HEADER to the sink (fire-and-forget, like every output before it) — the
|
|
2224
2982
|
// stub row the drain service fabricated for FK integrity is replaced by the run's own record.
|
|
@@ -2236,12 +2994,13 @@ export async function runGig(standard, gigInput, deps) {
|
|
|
2236
2994
|
if (process.env["COLTRANE_DRAIN_DEBUG"])
|
|
2237
2995
|
console.error(`[drain] gig header ${gig_id}: ${String(e)}`);
|
|
2238
2996
|
});
|
|
2239
|
-
// Cycle complete — when a
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2997
|
+
// Cycle complete — when a snapshot exists (a ceiling OR a pool), mark it `settled` and surface
|
|
2998
|
+
// the final state in the manifest. Under a ceiling, the settled dollars are reconciled one last
|
|
2999
|
+
// time; a pool-only snapshot carries no dollar figure.
|
|
2242
3000
|
if (budget) {
|
|
2243
3001
|
budget.agent_state = "settled";
|
|
2244
|
-
|
|
3002
|
+
if (hasCeiling)
|
|
3003
|
+
budget.spent_usd = usage.total_cost_usd;
|
|
2245
3004
|
}
|
|
2246
3005
|
// The gig finished, so there is nothing left to resume — drop its checkpoint. Without this
|
|
2247
3006
|
// every gig a deployment ever runs leaves a file behind forever. Only the SUCCESS path clears
|