@renzrollon/interlock 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- package/.claude-plugin/marketplace.json +0 -19
package/lib/run.mjs
CHANGED
|
@@ -22,15 +22,26 @@
|
|
|
22
22
|
// through `ctx.deps` rather than being written a second time here.
|
|
23
23
|
|
|
24
24
|
import { createHash } from 'node:crypto'
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
26
26
|
import { dirname, join, resolve } from 'node:path'
|
|
27
27
|
|
|
28
|
-
import { LIMITS, EFFORT } from './limits.mjs'
|
|
28
|
+
import { LIMITS, EFFORT, RUNTIME } from './limits.mjs'
|
|
29
29
|
import { spawnPrefix } from './host.mjs'
|
|
30
30
|
import { PLUGIN_ROOT } from './doctor.mjs'
|
|
31
31
|
import { RED_SECTION_MARKER, inspectChange, planCoverage, tickTasks } from './artifacts.mjs'
|
|
32
32
|
import { FINGERPRINT_PATH, computeFingerprint, resolvePlanReuse, writeFingerprint } from './plan-fingerprint.mjs'
|
|
33
33
|
import { clearStage } from './ship-stage.mjs'
|
|
34
|
+
import { readModelRouting, runtimeSlotsOf, WORKFLOW_MAX_CONCURRENT_RANGE } from './host/claude-env.mjs'
|
|
35
|
+
import { modelMatches } from './host/model-map.mjs'
|
|
36
|
+
import { readAgentUsage } from './agent-usage.mjs'
|
|
37
|
+
import { resolveStateHome } from './state-home.mjs'
|
|
38
|
+
import {
|
|
39
|
+
formatInterruptedBanner,
|
|
40
|
+
formatUnreadableNote,
|
|
41
|
+
isUnspoken,
|
|
42
|
+
markSpoken,
|
|
43
|
+
readInterruptedNotes
|
|
44
|
+
} from './interrupted.mjs'
|
|
34
45
|
import { appendRunLogEvent, checkRunLog } from './run-log.mjs'
|
|
35
46
|
import { appendOutcome, observedFromReceipt } from './outcomes.mjs'
|
|
36
47
|
import { readPredictedPaths, readTouchedPaths } from './run-paths.mjs'
|
|
@@ -45,7 +56,7 @@ import {
|
|
|
45
56
|
import { detectUnarchived } from './drift.mjs'
|
|
46
57
|
import { projectSlug } from './project-slug.mjs'
|
|
47
58
|
import { composeCloseMessage, postNtfy, readNotifyConfig } from './notify.mjs'
|
|
48
|
-
import { SKIP_REASONS, planVerification, judgeVerification } from './verify.mjs'
|
|
59
|
+
import { BANNERS as VERIFY_BANNERS, SKIP_REASONS, foldCheckTimings, planVerification, judgeVerification } from './verify.mjs'
|
|
49
60
|
import { resolveReview, readReviewPolicy } from './review-core.mjs'
|
|
50
61
|
import { writeReviewMetrics } from './metrics.mjs'
|
|
51
62
|
import { planRemediation } from './remediate.mjs'
|
|
@@ -59,6 +70,7 @@ import {
|
|
|
59
70
|
createRunState,
|
|
60
71
|
laneEffort,
|
|
61
72
|
laneLabel,
|
|
73
|
+
laneTitle,
|
|
62
74
|
laneModel,
|
|
63
75
|
nextStep,
|
|
64
76
|
planWaves,
|
|
@@ -112,6 +124,15 @@ export const REPLAN_PATH = join(WORK_DIR, 'replan.json')
|
|
|
112
124
|
// the agent reports about itself (design D2).
|
|
113
125
|
export const FINDINGS_PATH = join(WORK_DIR, 'findings.json')
|
|
114
126
|
export const VERDICTS_PATH = join(WORK_DIR, 'verdicts.json')
|
|
127
|
+
// The exact bytes the last `run` subcommand printed. The Workflow host's relay
|
|
128
|
+
// is a model retyping stdout, and when its copy does not parse, this is the
|
|
129
|
+
// only record of what the CLI actually said — the halt names it rather than
|
|
130
|
+
// leaving the reader to dig the relay out of a transcript.
|
|
131
|
+
export const LAST_STEP_PATH = join(WORK_DIR, 'last-step.json')
|
|
132
|
+
// One line per `interlock verify exec`: the planned check it ran, its exit code
|
|
133
|
+
// and how long it took, measured by the CLI around the command itself. `run
|
|
134
|
+
// judge` folds these into the inter-wave verify budget and clears the file.
|
|
135
|
+
export const VERIFY_TIMINGS_PATH = join(WORK_DIR, 'verify-timings.jsonl')
|
|
115
136
|
|
|
116
137
|
/** The first line of every briefing file: what it is, whose it is, and its hash. */
|
|
117
138
|
export const BRIEFING_HEADER = (label, sha) =>
|
|
@@ -169,6 +190,12 @@ export const ASSUMED_CAPABILITIES = Object.freeze({
|
|
|
169
190
|
// manifest written before this key existed reads `false` here, which is the
|
|
170
191
|
// honest answer — that run measured no cache figures either.
|
|
171
192
|
cacheAccounting: false,
|
|
193
|
+
// Merge default only. A Workflow run does not keep it: `interlock run start
|
|
194
|
+
// --host workflow` replaces it with the `claude --help` probe before the
|
|
195
|
+
// manifest is written. A runner manifest written before the key existed also
|
|
196
|
+
// reads `flag` here, although that run applied no effort at all. Nothing
|
|
197
|
+
// reads the key from an old manifest — a receipt without the field is null.
|
|
198
|
+
effort: 'flag',
|
|
172
199
|
billing: 'claude-subscription-programmatic'
|
|
173
200
|
})
|
|
174
201
|
|
|
@@ -257,9 +284,9 @@ export function newManifest({ change, flags, host, hostCapabilities }) {
|
|
|
257
284
|
// so a driver that skips or repeats a step is told which call was expected
|
|
258
285
|
// rather than silently advancing the state machine twice.
|
|
259
286
|
lastThen: null,
|
|
260
|
-
// The shared-tree
|
|
261
|
-
// across the driver round trip so
|
|
262
|
-
// lanes actually forked from. The emitted step carries it for the driver;
|
|
287
|
+
// The shared-tree snapshot captured when an isolated batch was dispatched
|
|
288
|
+
// (HEAD itself on a clean tree), held across the driver round trip so
|
|
289
|
+
// `record-batch` folds against the base the lanes actually forked from. The emitted step carries it for the driver;
|
|
263
290
|
// this is where the CLI reads it back, because the driver returns only
|
|
264
291
|
// results and the wave state has no such field.
|
|
265
292
|
mergeBase: null,
|
|
@@ -299,7 +326,14 @@ export function newManifest({ change, flags, host, hostCapabilities }) {
|
|
|
299
326
|
handoff: null,
|
|
300
327
|
// The autonomy-ladder record `run close` wrote, from the CLI's own
|
|
301
328
|
// surviving-blocker count (design D6). Storage only; nothing prints a level.
|
|
302
|
-
autonomy: null
|
|
329
|
+
autonomy: null,
|
|
330
|
+
// Every briefed spawn this run dispatched once it had an id — wave, label,
|
|
331
|
+
// kind, routed model and briefing sha — which is the table `run close` joins
|
|
332
|
+
// the recorder's sidecar against (ship/agent-results).
|
|
333
|
+
dispatched: [],
|
|
334
|
+
// The runner lanes whose host recorded a session, for the resume card's
|
|
335
|
+
// `claude --resume` lines. Empty on the Workflow host, which has none.
|
|
336
|
+
laneSessions: []
|
|
303
337
|
}
|
|
304
338
|
}
|
|
305
339
|
|
|
@@ -377,6 +411,62 @@ export function hostId(manifest) {
|
|
|
377
411
|
return typeof host === 'string' && host ? host : null
|
|
378
412
|
}
|
|
379
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The fields of a step the Workflow host's interpreter reads, and no others.
|
|
416
|
+
*
|
|
417
|
+
* On that host a step reaches `workflows/ship.js` through a relay — a model that
|
|
418
|
+
* runs the command and RETYPES its stdout into a result field. A 23 KB step was
|
|
419
|
+
* retyped with a duplicated key run and two dropped brackets, did not parse, and
|
|
420
|
+
* halted a healthy run at wave 6. Nearly all of those bytes were things the
|
|
421
|
+
* interpreter never reads: each spawn's inline `prompt` (the worker is handed
|
|
422
|
+
* `promptPath` and `promptSha256` instead), and the carried wave state —
|
|
423
|
+
* `remainingBatches`, `previousHandoffs`, `changed` — which the next `run`
|
|
424
|
+
* subcommand reads back from `state.json`, never from the step.
|
|
425
|
+
*
|
|
426
|
+
* A whitelist rather than a list of what to drop, so a field added to the step
|
|
427
|
+
* later cannot quietly grow the relay again. The other direction — the
|
|
428
|
+
* interpreter starting to read a field this drops — is pinned by a scan of
|
|
429
|
+
* ship.js in test/workflows.test.mjs.
|
|
430
|
+
*/
|
|
431
|
+
export const RELAY_STEP_FIELDS = Object.freeze([
|
|
432
|
+
'schema',
|
|
433
|
+
'action',
|
|
434
|
+
'then',
|
|
435
|
+
'spawns',
|
|
436
|
+
'banners',
|
|
437
|
+
'change',
|
|
438
|
+
'wave',
|
|
439
|
+
'reason',
|
|
440
|
+
// The control-plane ping model `run start` decided (design D3). The driver
|
|
441
|
+
// sets its relay pings' model from this field and from nothing else.
|
|
442
|
+
'pingModel'
|
|
443
|
+
])
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The step as the run's host should receive it.
|
|
447
|
+
*
|
|
448
|
+
* Branches on the host id for the reason `hostSessionId` does: "stdout is
|
|
449
|
+
* copied by a model" is a property of the Workflow runtime alone — the runner
|
|
450
|
+
* refuses that id and reads stdout as a process. Every other host, and a run
|
|
451
|
+
* that named none, gets the whole step: `bin/interlock-run` sends `prompt`
|
|
452
|
+
* inline and needs it.
|
|
453
|
+
*/
|
|
454
|
+
export function relayStep(manifest, step) {
|
|
455
|
+
if (hostId(manifest) !== WORKFLOW_HOST || !step || typeof step !== 'object') return step
|
|
456
|
+
const out = {}
|
|
457
|
+
for (const field of RELAY_STEP_FIELDS) {
|
|
458
|
+
if (step[field] !== undefined) out[field] = step[field]
|
|
459
|
+
}
|
|
460
|
+
if (Array.isArray(step.spawns)) {
|
|
461
|
+
out.spawns = step.spawns.map(s => {
|
|
462
|
+
if (!s || typeof s !== 'object') return s
|
|
463
|
+
const { prompt, ...rest } = s
|
|
464
|
+
return rest
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
return out
|
|
468
|
+
}
|
|
469
|
+
|
|
380
470
|
// --- the step budget (design D9) --------------------------------------------
|
|
381
471
|
|
|
382
472
|
/**
|
|
@@ -583,6 +673,313 @@ export function summarizeUsage(manifest) {
|
|
|
583
673
|
}
|
|
584
674
|
}
|
|
585
675
|
|
|
676
|
+
// --- what the host observed about each agent (ship/agent-results) -----------
|
|
677
|
+
//
|
|
678
|
+
// Two hosts, two channels, one set of verdicts. On a runner the adapter reads
|
|
679
|
+
// each lane's `claude -p` envelope and its session transcript and forwards a
|
|
680
|
+
// host record beside the result (`--host-records`); on the Workflow host the
|
|
681
|
+
// recorder hooks leave one sidecar file per agent, which the close joins to the
|
|
682
|
+
// spawns this program dispatched. Either way the CLI decides here, in the words
|
|
683
|
+
// below, and a driver decides none of it.
|
|
684
|
+
|
|
685
|
+
/** The one wording both hosts print for a lane a different model ran (design D6). */
|
|
686
|
+
export function modelSubstitutedBanner(label, routed, ran) {
|
|
687
|
+
return `MODEL SUBSTITUTED: ${label} routed ${routed}, ran ${ran.join(', ')}`
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** A lane the host ended with an error or a non-zero exit, named by what it reported. */
|
|
691
|
+
export function laneStoppedBanner(label, how) {
|
|
692
|
+
return `LANE STOPPED BY HOST: ${label} ${how}`
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** A success with no structured result: the open vendor bug this adapter's configuration triggers. */
|
|
696
|
+
export function schemaResultMissingBanner(label) {
|
|
697
|
+
return (
|
|
698
|
+
`SCHEMA RESULT MISSING (claude): ${label} — success without structured_output ` +
|
|
699
|
+
`(anthropics/claude-code#82258)`
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The host denied tool calls inside a lane. A count the reader interprets, never a verdict. */
|
|
704
|
+
export function toolsDeniedBanner(label, count, tools) {
|
|
705
|
+
return `TOOLS DENIED IN LANE: ${label} ${count}${tools.length ? ` (${tools.join(', ')})` : ''}`
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Whether a spawn ran on a model other than the one it was routed to (design D6).
|
|
710
|
+
*
|
|
711
|
+
* The matcher compares; what it is fed decides what a mismatch MEANS, and the
|
|
712
|
+
* two observations do not mean the same thing. `servedModels` is turn-scoped —
|
|
713
|
+
* the model of every assistant turn the agent itself took, read from its own
|
|
714
|
+
* transcript — so ANY unmatched id is a model the plan did not choose, a
|
|
715
|
+
* fallback that served one turn included. `sessionModels` is session-scoped —
|
|
716
|
+
* the `claude -p` envelope's per-model breakdown, which also lists the host's
|
|
717
|
+
* internal calls (every captured envelope carried a haiku entry the session was
|
|
718
|
+
* not routed to) — so there only a session the routed model NEVER served is
|
|
719
|
+
* established. The strongest observation present decides; neither, or no
|
|
720
|
+
* routed model, decides nothing.
|
|
721
|
+
*
|
|
722
|
+
* The only caller of `modelMatches`; called from the runner's record path and
|
|
723
|
+
* the Workflow close, and nowhere else (`test/spine/host-adapters.test.mjs`).
|
|
724
|
+
*
|
|
725
|
+
* @param {string|null|undefined} routed
|
|
726
|
+
* @param {{servedModels?: string[]|null, sessionModels?: string[]|null}} [observed]
|
|
727
|
+
* @returns {{substituted: boolean|null, scope: 'turns'|'session'|null, ran: string[]}}
|
|
728
|
+
*/
|
|
729
|
+
export function modelSubstitution(routed, { servedModels, sessionModels } = {}) {
|
|
730
|
+
const want = typeof routed === 'string' ? routed.trim() : ''
|
|
731
|
+
const ids = value => (Array.isArray(value) ? value.filter(m => typeof m === 'string' && m.trim()) : [])
|
|
732
|
+
const served = ids(servedModels)
|
|
733
|
+
const session = ids(sessionModels)
|
|
734
|
+
if (!want) return { substituted: null, scope: null, ran: [] }
|
|
735
|
+
if (served.length) {
|
|
736
|
+
return { substituted: served.some(m => modelMatches(want, m) === false), scope: 'turns', ran: served }
|
|
737
|
+
}
|
|
738
|
+
if (session.length) {
|
|
739
|
+
return { substituted: !session.some(m => modelMatches(want, m) === true), scope: 'session', ran: session }
|
|
740
|
+
}
|
|
741
|
+
return { substituted: null, scope: null, ran: [] }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** The most recent spawn this run dispatched under a label, or null. */
|
|
745
|
+
function dispatchedSpawn(manifest, label) {
|
|
746
|
+
const list = Array.isArray(manifest.dispatched) ? manifest.dispatched : []
|
|
747
|
+
for (let i = list.length - 1; i >= 0; i--) if (list[i] && list[i].label === label) return list[i]
|
|
748
|
+
return null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The receipt's runner counts, created on the first host record and never before. */
|
|
752
|
+
function agentCounts(manifest) {
|
|
753
|
+
if (!manifest.agentCounts || typeof manifest.agentCounts !== 'object') {
|
|
754
|
+
manifest.agentCounts = { lanesStoppedByHost: 0, schemaResultsMissing: 0, toolsDeniedInLanes: 0, modelSubstitutions: 0 }
|
|
755
|
+
}
|
|
756
|
+
return manifest.agentCounts
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Every record path's bookkeeping for the spawns it received: the usage the
|
|
761
|
+
* results carry, as before, and — on a runner — what the host observed about
|
|
762
|
+
* each one (design D9).
|
|
763
|
+
*
|
|
764
|
+
* For each host record it raises the lane banners, stores the lane's session for
|
|
765
|
+
* the resume card, increments the receipt's counts, and appends one
|
|
766
|
+
* `agent-result` to the trajectory. That append is the trajectory's fatal class:
|
|
767
|
+
* the failure is returned for the caller to halt on BEFORE anything is ticked.
|
|
768
|
+
* A step with no host records — a Workflow run, a host that sent none — raises
|
|
769
|
+
* nothing and counts nothing: absence stays unknown.
|
|
770
|
+
*
|
|
771
|
+
* @returns {{failure: string|null}}
|
|
772
|
+
*/
|
|
773
|
+
function recordSpawnObservations(ctx, manifest, { results, hostRecords, wave = null } = {}) {
|
|
774
|
+
recordUsage(manifest, results, wave)
|
|
775
|
+
const records = Array.isArray(hostRecords) ? hostRecords.filter(r => r && typeof r === 'object') : []
|
|
776
|
+
if (!records.length) return { failure: null }
|
|
777
|
+
const counts = agentCounts(manifest)
|
|
778
|
+
if (!Array.isArray(manifest.laneSessions)) manifest.laneSessions = []
|
|
779
|
+
if (!Array.isArray(manifest.banners)) manifest.banners = []
|
|
780
|
+
let failure = null
|
|
781
|
+
|
|
782
|
+
for (const record of records) {
|
|
783
|
+
const label = typeof record.label === 'string' && record.label ? record.label : 'agent'
|
|
784
|
+
const subtype = typeof record.subtype === 'string' && record.subtype ? record.subtype : null
|
|
785
|
+
const exitCode = Number.isInteger(record.exitCode) ? record.exitCode : null
|
|
786
|
+
const stopped = record.isError === true || record.timedOut === true || (exitCode !== null && exitCode !== 0)
|
|
787
|
+
|
|
788
|
+
let outcome = 'returned'
|
|
789
|
+
if (stopped) {
|
|
790
|
+
const how =
|
|
791
|
+
subtype && subtype !== 'success'
|
|
792
|
+
? subtype
|
|
793
|
+
: record.timedOut === true
|
|
794
|
+
? 'timed out'
|
|
795
|
+
: exitCode !== null && exitCode !== 0
|
|
796
|
+
? `exit ${exitCode}`
|
|
797
|
+
: subtype || 'error'
|
|
798
|
+
manifest.banners.push(laneStoppedBanner(label, how))
|
|
799
|
+
counts.lanesStoppedByHost++
|
|
800
|
+
outcome = `stopped by the host (${how})`
|
|
801
|
+
}
|
|
802
|
+
if (record.resultMissing === true) {
|
|
803
|
+
manifest.banners.push(schemaResultMissingBanner(label))
|
|
804
|
+
counts.schemaResultsMissing++
|
|
805
|
+
if (!stopped) outcome = 'returned no structured result'
|
|
806
|
+
}
|
|
807
|
+
const denials = record.permissionDenials && typeof record.permissionDenials === 'object' ? record.permissionDenials : null
|
|
808
|
+
const denied = denials && Number.isInteger(denials.count) ? denials.count : null
|
|
809
|
+
if (denied !== null && denied > 0) {
|
|
810
|
+
const tools = [...new Set((Array.isArray(denials.tools) ? denials.tools : []).filter(t => typeof t === 'string' && t))]
|
|
811
|
+
manifest.banners.push(toolsDeniedBanner(label, denied, tools))
|
|
812
|
+
counts.toolsDeniedInLanes++
|
|
813
|
+
}
|
|
814
|
+
const verdict = modelSubstitution(record.modelRouted, record)
|
|
815
|
+
if (verdict.substituted === true) {
|
|
816
|
+
manifest.banners.push(modelSubstitutedBanner(label, record.modelRouted.trim(), verdict.ran))
|
|
817
|
+
counts.modelSubstitutions++
|
|
818
|
+
}
|
|
819
|
+
if (typeof record.sessionId === 'string' && record.sessionId) {
|
|
820
|
+
manifest.laneSessions.push({ label, sessionId: record.sessionId, outcome })
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (!manifest.runId) continue
|
|
824
|
+
const spawned = dispatchedSpawn(manifest, label)
|
|
825
|
+
const written = appendRunLogEvent(stateHomeOf(ctx, manifest), {
|
|
826
|
+
runId: manifest.runId,
|
|
827
|
+
change: manifest.change,
|
|
828
|
+
type: 'agent-result',
|
|
829
|
+
label,
|
|
830
|
+
kind: spawned ? spawned.kind : undefined,
|
|
831
|
+
source: 'envelope',
|
|
832
|
+
subtype: record.subtype,
|
|
833
|
+
isError: record.isError,
|
|
834
|
+
terminalReason: record.terminalReason,
|
|
835
|
+
errors: record.errors,
|
|
836
|
+
permissionDenials: record.permissionDenials,
|
|
837
|
+
sessionId: record.sessionId,
|
|
838
|
+
numTurns: record.numTurns,
|
|
839
|
+
modelRouted: record.modelRouted,
|
|
840
|
+
servedModels: record.servedModels,
|
|
841
|
+
sessionModels: record.sessionModels,
|
|
842
|
+
modelScope: verdict.scope,
|
|
843
|
+
substituted: verdict.substituted,
|
|
844
|
+
resultMissing: record.resultMissing,
|
|
845
|
+
usage: record.usage,
|
|
846
|
+
hostCostUsd: record.hostCostUsd
|
|
847
|
+
})
|
|
848
|
+
if (!written.written) {
|
|
849
|
+
ctx.warn(`agent-result not recorded: ${written.reason}`)
|
|
850
|
+
if (!failure) failure = `${label}: ${written.reason}`
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return { failure }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** One wave's or the run's cache figures, summed over joined agents, unknown-contagious. */
|
|
857
|
+
function sumJoinedCache(agents) {
|
|
858
|
+
let read = 0
|
|
859
|
+
let tiers = { ephemeral_5m: 0, ephemeral_1h: 0 }
|
|
860
|
+
let total = 0
|
|
861
|
+
for (const agent of agents) {
|
|
862
|
+
const usage = agent.usage
|
|
863
|
+
read = read === null || !Number.isFinite(usage.cacheReadInputTokens) ? null : read + usage.cacheReadInputTokens
|
|
864
|
+
const creation = usage.cacheCreationInputTokens
|
|
865
|
+
const split =
|
|
866
|
+
creation && Number.isFinite(creation.ephemeral_5m) && Number.isFinite(creation.ephemeral_1h)
|
|
867
|
+
? creation
|
|
868
|
+
: null
|
|
869
|
+
const known = split ? split.ephemeral_5m + split.ephemeral_1h : creation && Number.isFinite(creation.total) ? creation.total : null
|
|
870
|
+
total = total === null || known === null ? null : total + known
|
|
871
|
+
// One agent recorded only a total: the wave's split is unknown, its total is not.
|
|
872
|
+
tiers = tiers === null || !split ? null : { ephemeral_5m: tiers.ephemeral_5m + split.ephemeral_5m, ephemeral_1h: tiers.ephemeral_1h + split.ephemeral_1h }
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
cacheReadInputTokens: read,
|
|
876
|
+
cacheCreationInputTokens: tiers !== null ? tiers : total !== null ? { total } : null
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Join what the recorder hooks wrote for this run to the spawns it dispatched
|
|
882
|
+
* (design D5), by the briefing sha256 every bootstrap carries.
|
|
883
|
+
*
|
|
884
|
+
* The denominator is the briefed spawns dispatched once the run had an id: the
|
|
885
|
+
* recorder writes nothing without one, so a spawn dispatched before it (the
|
|
886
|
+
* planner) could never join, and counting it would make every run partial. A
|
|
887
|
+
* spawn that did not join, or joined a transcript with no usage, makes its
|
|
888
|
+
* wave's figures unknown. A recorded agent nobody dispatched — a relay ping,
|
|
889
|
+
* which carries no briefing, or any other workflow the matcher admitted — is
|
|
890
|
+
* counted apart and attributed nowhere.
|
|
891
|
+
*/
|
|
892
|
+
function joinAgentUsage(root, manifest) {
|
|
893
|
+
const read = manifest.runId
|
|
894
|
+
? readAgentUsage(root, manifest.runId)
|
|
895
|
+
: { present: false, agents: [], permissions: [], unreadable: [] }
|
|
896
|
+
const briefed = (Array.isArray(manifest.dispatched) ? manifest.dispatched : []).filter(
|
|
897
|
+
d => d && typeof d.sha === 'string' && d.sha
|
|
898
|
+
)
|
|
899
|
+
const pool = new Map()
|
|
900
|
+
for (const agent of read.agents) {
|
|
901
|
+
const key = typeof agent.briefingSha === 'string' && agent.briefingSha ? agent.briefingSha : null
|
|
902
|
+
if (!key) continue
|
|
903
|
+
if (!pool.has(key)) pool.set(key, [])
|
|
904
|
+
pool.get(key).push(agent)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const joined = []
|
|
908
|
+
const waves = new Map()
|
|
909
|
+
let unrecorded = 0
|
|
910
|
+
for (const spawned of briefed) {
|
|
911
|
+
const candidates = pool.get(spawned.sha) || []
|
|
912
|
+
const agent = candidates.shift() || null
|
|
913
|
+
const usable = Boolean(agent && agent.usage && typeof agent.usage === 'object')
|
|
914
|
+
if (!usable) unrecorded++
|
|
915
|
+
if (agent) joined.push({ spawned, agent })
|
|
916
|
+
const wave = Number.isInteger(spawned.wave) ? spawned.wave : null
|
|
917
|
+
if (!waves.has(wave)) waves.set(wave, { agents: [], complete: true })
|
|
918
|
+
const entry = waves.get(wave)
|
|
919
|
+
if (usable) entry.agents.push(agent)
|
|
920
|
+
else entry.complete = false
|
|
921
|
+
}
|
|
922
|
+
const leftover = [...pool.values()].flat().length
|
|
923
|
+
const noKey = read.agents.filter(a => !(typeof a.briefingSha === 'string' && a.briefingSha)).length
|
|
924
|
+
|
|
925
|
+
const cacheByWave = new Map()
|
|
926
|
+
for (const [wave, entry] of waves) {
|
|
927
|
+
cacheByWave.set(
|
|
928
|
+
wave,
|
|
929
|
+
entry.complete ? sumJoinedCache(entry.agents) : { cacheReadInputTokens: null, cacheCreationInputTokens: null }
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
const complete = read.present && briefed.length > 0 && unrecorded === 0
|
|
933
|
+
return {
|
|
934
|
+
present: read.present,
|
|
935
|
+
// Read before the close refines it: only a host that declared it cannot
|
|
936
|
+
// account for cache has its figures supplied by the hooks.
|
|
937
|
+
cacheFromHooks: hostCapability(manifest, 'cacheAccounting') === false,
|
|
938
|
+
briefed: briefed.length,
|
|
939
|
+
unrecorded,
|
|
940
|
+
complete,
|
|
941
|
+
joined,
|
|
942
|
+
cacheByWave,
|
|
943
|
+
runCache: complete
|
|
944
|
+
? sumJoinedCache(joined.map(j => j.agent))
|
|
945
|
+
: { cacheReadInputTokens: null, cacheCreationInputTokens: null },
|
|
946
|
+
unjoined: { noKey, unknownKey: leftover },
|
|
947
|
+
permissions: read.permissions,
|
|
948
|
+
unreadable: read.unreadable
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// --- the state home (ship/state-home) ----------------------------------------
|
|
953
|
+
//
|
|
954
|
+
// Corpora — the trajectory, the outcome line, the metrics, the resume card, the
|
|
955
|
+
// interrupted notes, the autonomy ledger — go to the state home, which is the
|
|
956
|
+
// main checkout when the run is in a linked worktree; per-run working state —
|
|
957
|
+
// this manifest, the wave state, briefings, spill, the marker, the sidecar —
|
|
958
|
+
// stays in `ctx.root`. `run start` resolves the home once and records it; every
|
|
959
|
+
// later subcommand reads it back from here and never resolves again.
|
|
960
|
+
|
|
961
|
+
/** Where this run's corpora are written: the recorded home, or the root for a manifest without one. */
|
|
962
|
+
export function stateHomeOf(ctx, manifest) {
|
|
963
|
+
return manifest && typeof manifest.stateHome === 'string' && manifest.stateHome ? manifest.stateHome : ctx.root
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** The test profile and the graph, located root-first then home (design D16). */
|
|
967
|
+
export const TEST_PROFILE_PATH = join('.claude', 'testing', 'profile.json')
|
|
968
|
+
export const GRAPH_FILE_PATH = join('.claude', 'graph', 'graph.json')
|
|
969
|
+
|
|
970
|
+
/** The graph line raised at start when neither the root nor the home has one. */
|
|
971
|
+
export const GRAPH_UNAVAILABLE_BANNER =
|
|
972
|
+
'GRAPH UNAVAILABLE: never built — implementer and reviewer agents fall back to grep and will be slower'
|
|
973
|
+
|
|
974
|
+
/** Root first, then the home, never written: the inputs a worktree run reads through. */
|
|
975
|
+
function locateInput(root, home, relative) {
|
|
976
|
+
const atRoot = resolve(root, relative)
|
|
977
|
+
if (existsSync(atRoot)) return { path: atRoot, source: 'root' }
|
|
978
|
+
const atHome = resolve(home, relative)
|
|
979
|
+
if (resolve(home) !== resolve(root) && existsSync(atHome)) return { path: atHome, source: 'state-home' }
|
|
980
|
+
return { path: null, source: 'none' }
|
|
981
|
+
}
|
|
982
|
+
|
|
586
983
|
// --- briefings (design D2) --------------------------------------------------
|
|
587
984
|
|
|
588
985
|
// --- lane worktrees (design D7) ---------------------------------------------
|
|
@@ -631,8 +1028,9 @@ export function briefingHash(text) {
|
|
|
631
1028
|
* The Workflow host cannot hold a briefing — it has no filesystem, and asking a
|
|
632
1029
|
* ping to copy several kilobytes into a result field would breach the published
|
|
633
1030
|
* result-field cap — so it spawns the worker with a fixed bootstrap naming this
|
|
634
|
-
* path and requires the hash back
|
|
635
|
-
*
|
|
1031
|
+
* path and requires the hash back — and never sees `prompt` at all, since
|
|
1032
|
+
* `relayStep` drops it from what that host's relay copies. The runner hosts
|
|
1033
|
+
* read `prompt` and send it inline. One file serves both.
|
|
636
1034
|
*/
|
|
637
1035
|
export function writeBriefing(root, label, text) {
|
|
638
1036
|
const promptSha256 = briefingHash(text)
|
|
@@ -656,9 +1054,12 @@ const AUTONOMY_LINE =
|
|
|
656
1054
|
'guessing at product intent.'
|
|
657
1055
|
|
|
658
1056
|
/** One agent a step asks for. */
|
|
659
|
-
function spawn(root, { label, kind, model, effort, schema, prompt, isolation, worktree }) {
|
|
1057
|
+
function spawn(root, { label, title, kind, model, effort, schema, prompt, isolation, worktree }) {
|
|
660
1058
|
return {
|
|
661
1059
|
label,
|
|
1060
|
+
// What a person sees the agent as. `label` stays the key every record joins
|
|
1061
|
+
// on; a spawn with nothing to add is shown under its label.
|
|
1062
|
+
title: title || label,
|
|
662
1063
|
kind,
|
|
663
1064
|
model: model || null,
|
|
664
1065
|
effort: effort === undefined ? null : effort,
|
|
@@ -786,6 +1187,7 @@ export function decorate(ctx, step, manifest, extra = {}) {
|
|
|
786
1187
|
const spawns = lanes.map(lane =>
|
|
787
1188
|
spawn(root, {
|
|
788
1189
|
label: laneLabel(lane),
|
|
1190
|
+
title: laneTitle(lane),
|
|
789
1191
|
kind: 'implementer',
|
|
790
1192
|
model: laneModel(lane),
|
|
791
1193
|
effort: laneEffort(lane),
|
|
@@ -809,13 +1211,19 @@ export function decorate(ctx, step, manifest, extra = {}) {
|
|
|
809
1211
|
})
|
|
810
1212
|
})
|
|
811
1213
|
)
|
|
812
|
-
// The
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
1214
|
+
// The base this batch's lanes fork from: a snapshot of the shared tree as it
|
|
1215
|
+
// stands — tracked edits and every untracked file the ignore rules let
|
|
1216
|
+
// through — so it includes every earlier fold of this run. Not HEAD: the
|
|
1217
|
+
// ship commit is the run's last step, so HEAD never moves while batches
|
|
1218
|
+
// fold, and a lane forked from it edits a copy that lacks them; its
|
|
1219
|
+
// whole-file fold would then revert the earlier batch with exit 0. A clean
|
|
1220
|
+
// tree snapshots to HEAD itself. Captured now, not after, so nothing lands
|
|
1221
|
+
// on the shared tree between the capture and the fold. Only under
|
|
1222
|
+
// isolation; unset, a batch runs byte-for-byte as it did before worktree
|
|
1223
|
+
// isolation existed.
|
|
816
1224
|
let mergeBase = null
|
|
817
1225
|
if (isolate) {
|
|
818
|
-
mergeBase = ctx.deps.
|
|
1226
|
+
mergeBase = ctx.deps.snapshotTree(root, change)
|
|
819
1227
|
if (!mergeBase) {
|
|
820
1228
|
return haltStep(
|
|
821
1229
|
'could not capture the shared-tree base commit before an isolated batch — merge-lanes ' +
|
|
@@ -826,8 +1234,8 @@ export function decorate(ctx, step, manifest, extra = {}) {
|
|
|
826
1234
|
// Onto the manifest as well as onto the step. The step goes to the driver
|
|
827
1235
|
// and the driver returns only results; `record-batch` reads a pure
|
|
828
1236
|
// wave-state step that carries no such field, so without this the fold
|
|
829
|
-
// would re-read HEAD *after* the lanes ran —
|
|
830
|
-
//
|
|
1237
|
+
// would re-read HEAD *after* the lanes ran — never the snapshot they
|
|
1238
|
+
// forked from.
|
|
831
1239
|
manifest.mergeBase = mergeBase
|
|
832
1240
|
}
|
|
833
1241
|
return makeStep({
|
|
@@ -859,6 +1267,7 @@ export function decorate(ctx, step, manifest, extra = {}) {
|
|
|
859
1267
|
label: `replan-${manifest.steps}`,
|
|
860
1268
|
kind: 'ping',
|
|
861
1269
|
model: 'haiku',
|
|
1270
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
862
1271
|
effort: null,
|
|
863
1272
|
schema: REPLAN_SCHEMA,
|
|
864
1273
|
prompt: assembleReplanPrompt({ change, replanPath: REPLAN_PATH })
|
|
@@ -912,7 +1321,7 @@ export function finalVerifyStep(ctx, manifest, carried = {}) {
|
|
|
912
1321
|
function verifyStep(ctx, manifest, { context, label, changed, carried, action = 'verify' }) {
|
|
913
1322
|
const { root } = ctx
|
|
914
1323
|
const interWave = context === 'inter-wave'
|
|
915
|
-
const profile = readProfile(root)
|
|
1324
|
+
const profile = readProfile(root, manifest)
|
|
916
1325
|
const opts = { context, changed }
|
|
917
1326
|
if (context === 'final') {
|
|
918
1327
|
opts.e2e = manifest.flags.skipE2e !== true
|
|
@@ -927,6 +1336,9 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
|
|
|
927
1336
|
}
|
|
928
1337
|
const plan = planVerification(profile, opts)
|
|
929
1338
|
writeWork(root, `vplan-${context}.json`, plan)
|
|
1339
|
+
// A timing left by an earlier checkpoint is not this one's, and the judge
|
|
1340
|
+
// cannot tell them apart — so the slate is cleared where the checks are named.
|
|
1341
|
+
clearVerifyTimings(root)
|
|
930
1342
|
|
|
931
1343
|
if (!plan.steps.length) {
|
|
932
1344
|
// A spent budget is the reason, not whichever kind happens to sit first in
|
|
@@ -963,7 +1375,7 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
|
|
|
963
1375
|
label,
|
|
964
1376
|
kind: 'verify',
|
|
965
1377
|
model: null,
|
|
966
|
-
effort:
|
|
1378
|
+
effort: EFFORT.verify,
|
|
967
1379
|
schema: VERIFY_RESULT_SCHEMA,
|
|
968
1380
|
prompt:
|
|
969
1381
|
(context === 'final'
|
|
@@ -1189,7 +1601,7 @@ function adjudicateReview(ctx, manifest) {
|
|
|
1189
1601
|
writeWork(root, 'review.json', result)
|
|
1190
1602
|
|
|
1191
1603
|
const banners = []
|
|
1192
|
-
const metrics = writeReviewMetrics(
|
|
1604
|
+
const metrics = writeReviewMetrics(stateHomeOf(ctx, manifest), { change: manifest.change, counts: result.counts })
|
|
1193
1605
|
if (!metrics.written) {
|
|
1194
1606
|
banners.push(`REVIEW METRICS NOT WRITTEN: ${metrics.reason}`)
|
|
1195
1607
|
}
|
|
@@ -1205,15 +1617,18 @@ function adjudicateReview(ctx, manifest) {
|
|
|
1205
1617
|
* round one: a fixing round, or straight to final verification when nothing
|
|
1206
1618
|
* survived to fix.
|
|
1207
1619
|
*/
|
|
1208
|
-
export function runReviewed(ctx, { results }) {
|
|
1620
|
+
export function runReviewed(ctx, { results, hostRecords }) {
|
|
1209
1621
|
const { root } = ctx
|
|
1210
1622
|
const manifest = readManifest(root)
|
|
1211
|
-
if (!manifest) return noManifest()
|
|
1623
|
+
if (!manifest) return noManifest(ctx)
|
|
1212
1624
|
const capped = countStep(root, manifest)
|
|
1213
1625
|
if (capped) return recordThen(root, manifest, capped)
|
|
1214
1626
|
|
|
1215
1627
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
1216
|
-
|
|
1628
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
1629
|
+
if (observed.failure) {
|
|
1630
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
1631
|
+
}
|
|
1217
1632
|
|
|
1218
1633
|
const { result, banners } = adjudicateReview(ctx, manifest)
|
|
1219
1634
|
banners.push(...stageMarkerBanners(results))
|
|
@@ -1281,15 +1696,18 @@ export function runReviewed(ctx, { results }) {
|
|
|
1281
1696
|
* the fix budget on it (moved from `workflows/ship.js`'s retired
|
|
1282
1697
|
* `remediationBudget`) — only the verdict round itself may halt.
|
|
1283
1698
|
*/
|
|
1284
|
-
export function runRemediated(ctx, { round, results }) {
|
|
1699
|
+
export function runRemediated(ctx, { round, results, hostRecords }) {
|
|
1285
1700
|
const { root } = ctx
|
|
1286
1701
|
const manifest = readManifest(root)
|
|
1287
|
-
if (!manifest) return noManifest()
|
|
1702
|
+
if (!manifest) return noManifest(ctx)
|
|
1288
1703
|
const capped = countStep(root, manifest)
|
|
1289
1704
|
if (capped) return recordThen(root, manifest, capped)
|
|
1290
1705
|
|
|
1291
1706
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
1292
|
-
|
|
1707
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
1708
|
+
if (observed.failure) {
|
|
1709
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
1710
|
+
}
|
|
1293
1711
|
|
|
1294
1712
|
const roundCap = LIMITS.remediationRounds
|
|
1295
1713
|
const completedRound = Number.isInteger(round) ? round : null
|
|
@@ -1364,9 +1782,15 @@ export function runRemediated(ctx, { round, results }) {
|
|
|
1364
1782
|
|
|
1365
1783
|
// --- small file helpers -----------------------------------------------------
|
|
1366
1784
|
|
|
1367
|
-
function readProfile(root) {
|
|
1785
|
+
function readProfile(root, manifest) {
|
|
1786
|
+
// The path `run start` located, root first and the state home second (design
|
|
1787
|
+
// D16), so a worktree run reads the main checkout's profile without copying
|
|
1788
|
+
// it. A manifest written before the field existed reads the root, as before.
|
|
1789
|
+
const recorded = manifest && Object.prototype.hasOwnProperty.call(manifest, 'testProfilePath')
|
|
1790
|
+
const path = recorded ? manifest.testProfilePath : join(root, TEST_PROFILE_PATH)
|
|
1791
|
+
if (typeof path !== 'string' || !path) return null
|
|
1368
1792
|
try {
|
|
1369
|
-
return JSON.parse(readFileSync(
|
|
1793
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
1370
1794
|
} catch {
|
|
1371
1795
|
// No profile is not a degradation to report here — `planVerification` says
|
|
1372
1796
|
// so itself, in the skip reasons it returns.
|
|
@@ -1374,6 +1798,39 @@ function readProfile(root) {
|
|
|
1374
1798
|
}
|
|
1375
1799
|
}
|
|
1376
1800
|
|
|
1801
|
+
/**
|
|
1802
|
+
* The timings `interlock verify exec` appended since the last clear. An
|
|
1803
|
+
* unreadable line is skipped rather than fatal: a timing is a budget input, and
|
|
1804
|
+
* a kind left without one is charged the round trip, with a banner saying so.
|
|
1805
|
+
*/
|
|
1806
|
+
function readVerifyTimings(root) {
|
|
1807
|
+
let raw
|
|
1808
|
+
try {
|
|
1809
|
+
raw = readFileSync(join(root, VERIFY_TIMINGS_PATH), 'utf8')
|
|
1810
|
+
} catch {
|
|
1811
|
+
return []
|
|
1812
|
+
}
|
|
1813
|
+
const out = []
|
|
1814
|
+
for (const line of raw.split('\n')) {
|
|
1815
|
+
if (!line.trim()) continue
|
|
1816
|
+
try {
|
|
1817
|
+
out.push(JSON.parse(line))
|
|
1818
|
+
} catch {
|
|
1819
|
+
// skipped — see above
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return out
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function clearVerifyTimings(root) {
|
|
1826
|
+
try {
|
|
1827
|
+
rmSync(join(root, VERIFY_TIMINGS_PATH), { force: true })
|
|
1828
|
+
} catch {
|
|
1829
|
+
// Nothing to clear, or nothing clearable: the worst case is a stale timing
|
|
1830
|
+
// charged to the next checkpoint, which only ever makes the budget tighter.
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1377
1834
|
function writeWork(root, name, value) {
|
|
1378
1835
|
const path = join(root, WORK_DIR, name)
|
|
1379
1836
|
mkdirSync(dirname(path), { recursive: true })
|
|
@@ -1454,7 +1911,7 @@ function logSpawns(ctx, manifest, step) {
|
|
|
1454
1911
|
let failure = null
|
|
1455
1912
|
for (const s of step.spawns || []) {
|
|
1456
1913
|
if (s.kind === 'implementer') continue // logged by wave-state, at the lane
|
|
1457
|
-
const written = appendRunLogEvent(ctx
|
|
1914
|
+
const written = appendRunLogEvent(stateHomeOf(ctx, manifest), {
|
|
1458
1915
|
runId: manifest.runId || undefined,
|
|
1459
1916
|
change: manifest.change,
|
|
1460
1917
|
type: 'agent-spawn',
|
|
@@ -1474,7 +1931,7 @@ function logSpawns(ctx, manifest, step) {
|
|
|
1474
1931
|
|
|
1475
1932
|
function logCliExit(ctx, manifest, command, exitCode) {
|
|
1476
1933
|
if (!manifest.runId) return null
|
|
1477
|
-
const written = appendRunLogEvent(ctx
|
|
1934
|
+
const written = appendRunLogEvent(stateHomeOf(ctx, manifest), {
|
|
1478
1935
|
runId: manifest.runId,
|
|
1479
1936
|
change: manifest.change,
|
|
1480
1937
|
type: 'cli-exit',
|
|
@@ -1521,6 +1978,37 @@ function waveAppendReason(outcome) {
|
|
|
1521
1978
|
/** Same, for `logAgentSpawns` — it returns a boolean and reports its reason to stderr. */
|
|
1522
1979
|
const LANE_SPAWN_APPEND_FAILED = 'an implementer lane spawn did not land'
|
|
1523
1980
|
|
|
1981
|
+
/**
|
|
1982
|
+
* Log the implementer lanes a wave-state step dispatches — this batch's, and
|
|
1983
|
+
* only this batch's — at the moment the program names them.
|
|
1984
|
+
*
|
|
1985
|
+
* Every site that turns a wave-state step into a run step calls this, because
|
|
1986
|
+
* every one of them can emit a batch. Two used to, and they logged the WHOLE
|
|
1987
|
+
* wave at its first batch: a run that halted before a later batch left spawn
|
|
1988
|
+
* events for agents that never ran, while a wave entered through `run judge`
|
|
1989
|
+
* or `run replan` left none for the agents that did. The `run` program emits
|
|
1990
|
+
* one step per batch, so the batch is the unit.
|
|
1991
|
+
*
|
|
1992
|
+
* Returns false only when the append failed, read with `=== false` as every
|
|
1993
|
+
* caller already does — a stub that reports nothing is not a failure.
|
|
1994
|
+
*/
|
|
1995
|
+
function logLaneSpawns(ctx, manifest, state, step, source) {
|
|
1996
|
+
// Consulted even for a step that dispatches nothing — with no lanes, which it
|
|
1997
|
+
// logs as nothing. Whether a step owes the trajectory a line is the writer's
|
|
1998
|
+
// question, and a site that skipped the call for some actions would be one
|
|
1999
|
+
// more place the answer could drift.
|
|
2000
|
+
const dispatching = step && (step.action === 'run-batch' || step.action === 'test-wave')
|
|
2001
|
+
return (
|
|
2002
|
+
ctx.deps.logAgentSpawns(stateHomeOf(ctx, manifest), {
|
|
2003
|
+
state,
|
|
2004
|
+
change: manifest.change,
|
|
2005
|
+
step,
|
|
2006
|
+
lanes: dispatching ? lanesOf(step) : [],
|
|
2007
|
+
source
|
|
2008
|
+
}) !== false
|
|
2009
|
+
)
|
|
2010
|
+
}
|
|
2011
|
+
|
|
1524
2012
|
/** The shared tree's HEAD. Never throws: an unreadable base is null, never a guess. */
|
|
1525
2013
|
function headCommit(ctx) {
|
|
1526
2014
|
return ctx.deps.headCommit(ctx.root)
|
|
@@ -1711,6 +2199,110 @@ export function laneOutcomes(lane, result) {
|
|
|
1711
2199
|
|
|
1712
2200
|
// --- run start (design D4, D2) ----------------------------------------------
|
|
1713
2201
|
|
|
2202
|
+
/**
|
|
2203
|
+
* What the Workflow host's environment means for this run (design D1, D5).
|
|
2204
|
+
*
|
|
2205
|
+
* `interlock run start --host workflow` reads the model-routing variables from
|
|
2206
|
+
* its own environment, the way `hostSessionId` reads the session, and learns the
|
|
2207
|
+
* host version from `claude --version`; `bin/interlock` builds the observation
|
|
2208
|
+
* and this decides from it, once. The driver forwards what is decided and reads
|
|
2209
|
+
* none of these variables itself — `workflows/ship.js` used to, and raised
|
|
2210
|
+
* `MODEL ROUTING OVERRIDDEN` for a variable that, from 2.1.251, sets only the
|
|
2211
|
+
* default subagent model.
|
|
2212
|
+
*
|
|
2213
|
+
* Pure. The version-aware reading itself is `readModelRouting`, shared with the
|
|
2214
|
+
* doctor's advice row so the two cannot read one environment two ways.
|
|
2215
|
+
*
|
|
2216
|
+
* @param {object} observation from `observeClaudeEnv`
|
|
2217
|
+
* @returns {{banners: string[], notes: string[], pingModel: string|null,
|
|
2218
|
+
* modelSelect: 'forced'|null, slots: object}}
|
|
2219
|
+
*/
|
|
2220
|
+
export function decideHostEnvironment(observation) {
|
|
2221
|
+
const o = observation && typeof observation === 'object' ? observation : {}
|
|
2222
|
+
const routing = readModelRouting(o)
|
|
2223
|
+
const banners = []
|
|
2224
|
+
const notes = []
|
|
2225
|
+
const unknown = routing.versionKnown ? '' : ' (host version unknown)'
|
|
2226
|
+
|
|
2227
|
+
if (routing.forceEffective) {
|
|
2228
|
+
banners.push(
|
|
2229
|
+
`MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL_FORCE=${o.subagentModelForce} — every agent runs on ` +
|
|
2230
|
+
`one model (${o.subagentModel || "the host's default"}), so the per-tier assignment in the plan is not ` +
|
|
2231
|
+
`in effect${unknown}`
|
|
2232
|
+
)
|
|
2233
|
+
}
|
|
2234
|
+
if (routing.forceInert) {
|
|
2235
|
+
notes.push(
|
|
2236
|
+
`MODEL ROUTING NOTE: CLAUDE_CODE_SUBAGENT_MODEL_FORCE=${o.subagentModelForce} has no effect on Claude Code ` +
|
|
2237
|
+
`${routing.version}, which predates it; CLAUDE_CODE_SUBAGENT_MODEL is read as on any host of that version`
|
|
2238
|
+
)
|
|
2239
|
+
}
|
|
2240
|
+
if (routing.plainDefaultOnly) {
|
|
2241
|
+
notes.push(
|
|
2242
|
+
`MODEL ROUTING NOTE: CLAUDE_CODE_SUBAGENT_MODEL=${o.subagentModel} sets only the default subagent model on ` +
|
|
2243
|
+
`Claude Code ${routing.version}; the model each spawn names takes precedence`
|
|
2244
|
+
)
|
|
2245
|
+
}
|
|
2246
|
+
if (routing.plainOverrides) {
|
|
2247
|
+
banners.push(
|
|
2248
|
+
`MODEL ROUTING OVERRIDDEN: CLAUDE_CODE_SUBAGENT_MODEL=${o.subagentModel} — every agent runs on that model, ` +
|
|
2249
|
+
`so the per-tier assignment in the plan is not in effect${unknown}`
|
|
2250
|
+
)
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// The control-plane pings ask for haiku only when nothing overrides it and
|
|
2254
|
+
// the account can reach it. An override already decides every agent's model;
|
|
2255
|
+
// a Bedrock account often cannot reach haiku, and a failed ping halts the loop.
|
|
2256
|
+
let pingModel = 'haiku'
|
|
2257
|
+
if (routing.forceEffective || routing.plainOverrides) {
|
|
2258
|
+
pingModel = null
|
|
2259
|
+
} else if (Array.isArray(o.bedrock) && o.bedrock.length) {
|
|
2260
|
+
pingModel = null
|
|
2261
|
+
notes.push(
|
|
2262
|
+
`PING MODEL INHERITED: a Bedrock variable is set (${o.bedrock.join(', ')}), so control-plane pings run on ` +
|
|
2263
|
+
'the session model'
|
|
2264
|
+
)
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
const slots = { ...runtimeSlotsOf(o), vendorDefault: RUNTIME.maxConcurrentAgents }
|
|
2268
|
+
if (slots.invalid !== undefined) {
|
|
2269
|
+
notes.push(
|
|
2270
|
+
`RUNTIME SLOTS OVERRIDE IGNORED: CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${slots.invalid} is not an ` +
|
|
2271
|
+
`integer from ${WORKFLOW_MAX_CONCURRENT_RANGE.min} to ${WORKFLOW_MAX_CONCURRENT_RANGE.max}, so this run ` +
|
|
2272
|
+
"reads it as unset; how the runtime reads it is not observed"
|
|
2273
|
+
)
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
return { banners, notes, pingModel, modelSelect: routing.forceEffective ? 'forced' : null, slots }
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* Speak every interrupted-run note not yet spoken (design D7), whatever change
|
|
2281
|
+
* it names, and mark each spoken. Run start is the moment: the reader is about
|
|
2282
|
+
* to spend agents, and the run that was interrupted has no other record of how
|
|
2283
|
+
* it ended. Nothing here moves an exit code — the note is outcome-class.
|
|
2284
|
+
*
|
|
2285
|
+
* @returns {string[]} the banners to carry on the start step
|
|
2286
|
+
*/
|
|
2287
|
+
function speakInterruptedNotes(root, manifest) {
|
|
2288
|
+
const { notes, unreadable } = readInterruptedNotes(root)
|
|
2289
|
+
const banners = []
|
|
2290
|
+
for (const note of notes) {
|
|
2291
|
+
if (!isUnspoken(note)) continue
|
|
2292
|
+
const banner = formatInterruptedBanner(note)
|
|
2293
|
+
banners.push(banner)
|
|
2294
|
+
manifest.banners.push(banner)
|
|
2295
|
+
const marked = markSpoken(root, note.runId)
|
|
2296
|
+
if (!marked.marked) {
|
|
2297
|
+
manifest.notes.push(
|
|
2298
|
+
`INTERRUPTED NOTE NOT MARKED: ${note.file}: ${marked.reason} — it will be spoken again at the next run start`
|
|
2299
|
+
)
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
for (const entry of unreadable) manifest.notes.push(formatUnreadableNote(entry))
|
|
2303
|
+
return banners
|
|
2304
|
+
}
|
|
2305
|
+
|
|
1714
2306
|
/**
|
|
1715
2307
|
* Begin a run: write the manifest, validate the change, and either adopt a
|
|
1716
2308
|
* reusable plan or ask for a classification.
|
|
@@ -1723,13 +2315,132 @@ export function laneOutcomes(lane, result) {
|
|
|
1723
2315
|
* run; wrongly reusing would run the wrong plan.
|
|
1724
2316
|
*
|
|
1725
2317
|
* @param {{root: string, deps: object, warn: (m: string) => void}} ctx
|
|
1726
|
-
* @param {{change: string, flags: object, host?: string, hostCapabilities?: object}} input
|
|
2318
|
+
* @param {{change: string, flags: object, host?: string, hostCapabilities?: object, hostEnv?: object, stateHome?: object}} input
|
|
2319
|
+
* `hostEnv` is the Workflow host's observed environment (`observeClaudeEnv`),
|
|
2320
|
+
* passed by `bin/interlock` on that host only. `stateHome` is the state home
|
|
2321
|
+
* `bin/interlock` resolved for this root (`resolveStateHome`, the explicit
|
|
2322
|
+
* `--state-home` applied); resolved here when absent.
|
|
1727
2323
|
*/
|
|
1728
|
-
export function runStart(ctx, { change, flags, host, hostCapabilities }) {
|
|
2324
|
+
export function runStart(ctx, { change, flags, host, hostCapabilities, hostEnv, stateHome }) {
|
|
1729
2325
|
const { root } = ctx
|
|
1730
2326
|
const manifest = newManifest({ change, flags, host, hostCapabilities })
|
|
2327
|
+
// Where this run's corpora go, decided once and recorded (ship/state-home):
|
|
2328
|
+
// every later subcommand reads it back off the manifest and never resolves
|
|
2329
|
+
// again, so one run uses one home even if git state changes under it.
|
|
2330
|
+
const home = settleStateHome(ctx, manifest, stateHome)
|
|
2331
|
+
// What the Workflow host's environment means for this run, decided once here
|
|
2332
|
+
// and forwarded by the driver (design D2). Only that host's run start passes
|
|
2333
|
+
// an observation: a runner's lanes are `claude -p` processes whose top-level
|
|
2334
|
+
// model is `--model`, which the subagent-model variables do not govern.
|
|
2335
|
+
const carried = { banners: [] }
|
|
2336
|
+
if (hostEnv && typeof hostEnv === 'object') {
|
|
2337
|
+
const decided = decideHostEnvironment(hostEnv)
|
|
2338
|
+
manifest.banners.push(...decided.banners)
|
|
2339
|
+
manifest.notes.push(...decided.notes)
|
|
2340
|
+
manifest.pingModel = decided.pingModel
|
|
2341
|
+
manifest.runtimeSlots = decided.slots
|
|
2342
|
+
manifest.hostEnv = hostEnv
|
|
2343
|
+
// The same downward-only refinement the effort probe performs. A caller that
|
|
2344
|
+
// declared its own model selection keeps it.
|
|
2345
|
+
const declared = hostCapabilities && typeof hostCapabilities === 'object' && 'modelSelect' in hostCapabilities
|
|
2346
|
+
if (decided.modelSelect === 'forced' && manifest.host && !declared) manifest.host.modelSelect = 'forced'
|
|
2347
|
+
carried.banners.push(...decided.banners)
|
|
2348
|
+
carried.pingModel = decided.pingModel
|
|
2349
|
+
}
|
|
2350
|
+
// Said at the start rather than discovered at the first fold. The run still
|
|
2351
|
+
// proceeds: on a clean tree with `worktree.baseRef: head`, the first isolated
|
|
2352
|
+
// batch forks from HEAD, which is the snapshot, and folds.
|
|
2353
|
+
if (manifest.flags.isolateWaves === true && hostCapability(manifest, 'worktree') === 'runtime') {
|
|
2354
|
+
manifest.banners.push(ISOLATION_BASE_BANNER)
|
|
2355
|
+
}
|
|
2356
|
+
carried.banners.push(...home.banners)
|
|
1731
2357
|
writeManifest(root, manifest)
|
|
2358
|
+
carried.banners.push(...speakInterruptedNotes(stateHomeOf(ctx, manifest), manifest))
|
|
2359
|
+
|
|
2360
|
+
return carryStart(startSteps(ctx, manifest, change), carried)
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* Record the state home, the surface, and where the two inputs a worktree run
|
|
2365
|
+
* reads through were found (design D14, D16). Returns the banners for the start
|
|
2366
|
+
* step; they are on `manifest.banners` as well, so the close carries them.
|
|
2367
|
+
*
|
|
2368
|
+
* Every input is located root first, then home: a worktree that built its own
|
|
2369
|
+
* graph or reconfigured its own profile uses its own, and nothing is ever copied
|
|
2370
|
+
* or written into the main checkout. One banner per input at most — the
|
|
2371
|
+
* read-through, the absence, or nothing — and the absences are the lines the
|
|
2372
|
+
* Workflow driver used to raise from its own `test -f`, which no longer exists:
|
|
2373
|
+
* two probes of one file in two places is how a driver and the CLI disagree.
|
|
2374
|
+
*/
|
|
2375
|
+
function settleStateHome(ctx, manifest, given) {
|
|
2376
|
+
const root = resolve(ctx.root)
|
|
2377
|
+
const resolution =
|
|
2378
|
+
given && typeof given === 'object' && typeof given.home === 'string' && given.home
|
|
2379
|
+
? given
|
|
2380
|
+
: resolveStateHome(root)
|
|
2381
|
+
const home = resolve(resolution.home)
|
|
2382
|
+
manifest.stateHome = home
|
|
2383
|
+
manifest.surface = typeof resolution.surface === 'string' ? resolution.surface : 'unknown'
|
|
2384
|
+
manifest.stateHomeReason = typeof resolution.reason === 'string' ? resolution.reason : null
|
|
2385
|
+
// How it was decided — the resolver, an explicit setting, a lane worktree or the
|
|
2386
|
+
// fallback — so a reader that takes the home off this manifest names it right.
|
|
2387
|
+
manifest.stateHomeFrom = typeof resolution.resolvedFrom === 'string' ? resolution.resolvedFrom : null
|
|
2388
|
+
|
|
2389
|
+
const banners = []
|
|
2390
|
+
if (manifest.surface === 'unknown' && manifest.stateHomeReason) {
|
|
2391
|
+
banners.push(`STATE HOME UNRESOLVED: ${manifest.stateHomeReason}`)
|
|
2392
|
+
}
|
|
2393
|
+
if (home !== root) {
|
|
2394
|
+
manifest.notes.push(
|
|
2395
|
+
resolution.resolvedFrom === 'explicit'
|
|
2396
|
+
? `CORPORA IN STATE HOME: ${home} — set by --state-home or INTERLOCK_STATE_HOME; the trajectory, ` +
|
|
2397
|
+
`outcome line, metrics and resume card of this run are written there, not in ${root}`
|
|
2398
|
+
: `CORPORA IN MAIN CHECKOUT: ${home} — the trajectory, outcome line, metrics and resume card of ` +
|
|
2399
|
+
`this run are written there, not in this worktree`
|
|
2400
|
+
)
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const profile = locateInput(root, home, TEST_PROFILE_PATH)
|
|
2404
|
+
manifest.testProfilePath = profile.path
|
|
2405
|
+
manifest.testProfileSource = profile.source
|
|
2406
|
+
if (profile.source === 'state-home') banners.push(`TEST PROFILE FROM MAIN CHECKOUT: ${profile.path}`)
|
|
2407
|
+
else if (profile.source === 'none') banners.push(VERIFY_BANNERS.noProfile)
|
|
2408
|
+
|
|
2409
|
+
const graph = locateInput(root, home, GRAPH_FILE_PATH)
|
|
2410
|
+
manifest.graphPath = graph.path
|
|
2411
|
+
manifest.graphSource = graph.source
|
|
2412
|
+
if (graph.source === 'state-home') {
|
|
2413
|
+
banners.push(
|
|
2414
|
+
`GRAPH FROM MAIN CHECKOUT: ${graph.path} — it may be stale for this worktree; a lane without a ` +
|
|
2415
|
+
`graph falls back to grep`
|
|
2416
|
+
)
|
|
2417
|
+
} else if (graph.source === 'none') {
|
|
2418
|
+
banners.push(GRAPH_UNAVAILABLE_BANNER)
|
|
2419
|
+
}
|
|
1732
2420
|
|
|
2421
|
+
manifest.banners.push(...banners)
|
|
2422
|
+
return { home, banners }
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* Put what run start decided onto whichever step it returns — a classify, a
|
|
2427
|
+
* reused plan's first batch, a close with nothing left, or a halt. The manifest
|
|
2428
|
+
* already holds the banners; the step carries them so the driver reports them
|
|
2429
|
+
* from the first step on, and carries the ping model so the driver can set it.
|
|
2430
|
+
*/
|
|
2431
|
+
function carryStart(step, { banners, pingModel }) {
|
|
2432
|
+
if (!step || typeof step !== 'object') return step
|
|
2433
|
+
if (banners.length) {
|
|
2434
|
+
const rest = (Array.isArray(step.banners) ? step.banners : []).filter(b => !banners.includes(b))
|
|
2435
|
+
step.banners = [...banners, ...rest]
|
|
2436
|
+
}
|
|
2437
|
+
if (pingModel !== undefined) step.pingModel = pingModel
|
|
2438
|
+
return step
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/** Every path out of run start, after the manifest exists. */
|
|
2442
|
+
function startSteps(ctx, manifest, change) {
|
|
2443
|
+
const { root } = ctx
|
|
1733
2444
|
const capped = countStep(root, manifest)
|
|
1734
2445
|
if (capped) return recordThen(root, manifest, capped)
|
|
1735
2446
|
|
|
@@ -1791,6 +2502,7 @@ export function runStart(ctx, { change, flags, host, hostCapabilities }) {
|
|
|
1791
2502
|
label: 'plan-waves',
|
|
1792
2503
|
kind: 'planner',
|
|
1793
2504
|
model: null,
|
|
2505
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
1794
2506
|
effort: null,
|
|
1795
2507
|
schema: PLANNER_SCHEMA,
|
|
1796
2508
|
prompt: assemblePlannerPrompt({ change, classifiedPath: CLASSIFIED_PATH })
|
|
@@ -1817,8 +2529,15 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
1817
2529
|
}
|
|
1818
2530
|
writeState(root, state)
|
|
1819
2531
|
manifest.runId = typeof state.runId === 'string' ? state.runId : null
|
|
1820
|
-
|
|
1821
|
-
|
|
2532
|
+
// On the manifest as well as on the trajectory, so the SessionEnd recorder
|
|
2533
|
+
// compares the ending session against one small file (design D7). Absent on a
|
|
2534
|
+
// host with no session, which never matches, so that recorder writes nothing.
|
|
2535
|
+
const sessionId = hostSessionId(manifest, ctx.env)
|
|
2536
|
+
if (sessionId) manifest.sessionId = sessionId
|
|
2537
|
+
const slotsBanner = runtimeSlotsBanner(manifest, state)
|
|
2538
|
+
if (slotsBanner) manifest.banners.push(slotsBanner)
|
|
2539
|
+
|
|
2540
|
+
const started = appendRunLogEvent(stateHomeOf(ctx, manifest), {
|
|
1822
2541
|
runId: manifest.runId || undefined,
|
|
1823
2542
|
change: manifest.change,
|
|
1824
2543
|
type: 'run-start',
|
|
@@ -1828,14 +2547,20 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
1828
2547
|
// `undefined`, not a placeholder: a host with no session records absent, and
|
|
1829
2548
|
// the run is unaffected either way — nothing downstream reads this to decide
|
|
1830
2549
|
// anything, and `run-log check` does not require it.
|
|
1831
|
-
sessionId:
|
|
2550
|
+
sessionId: sessionId || undefined,
|
|
2551
|
+
// Where this run ran and where its corpora went (ship/state-home). `cwd` is
|
|
2552
|
+
// what lets a reader in the main checkout find a worktree run's spill and
|
|
2553
|
+
// marker; a manifest written before these existed records none.
|
|
2554
|
+
surface: manifest.surface || undefined,
|
|
2555
|
+
stateHome: manifest.stateHome || undefined,
|
|
2556
|
+
cwd: resolve(root)
|
|
1832
2557
|
})
|
|
1833
2558
|
if (!started.written) {
|
|
1834
2559
|
ctx.warn(`run-start not recorded: ${started.reason}`)
|
|
1835
2560
|
return trajectoryHaltStep('run-start', started.reason)
|
|
1836
2561
|
}
|
|
1837
2562
|
|
|
1838
|
-
const created = ctx.deps.logWaveMutation(
|
|
2563
|
+
const created = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
1839
2564
|
source: 'create',
|
|
1840
2565
|
change: manifest.change,
|
|
1841
2566
|
state
|
|
@@ -1849,11 +2574,43 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
1849
2574
|
// the lane, by wave-state). Its verdict is read for the same reason every
|
|
1850
2575
|
// other append's is: `wave-state next` exits 1 on this exact boolean, and the
|
|
1851
2576
|
// live path owes the trajectory the same fatality.
|
|
1852
|
-
if (ctx
|
|
2577
|
+
if (!logLaneSpawns(ctx, manifest, state, step, 'next')) {
|
|
1853
2578
|
ctx.warn('an implementer agent-spawn was not recorded on run start')
|
|
1854
2579
|
return trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED)
|
|
1855
2580
|
}
|
|
1856
|
-
|
|
2581
|
+
const first = decorate(ctx, step, manifest)
|
|
2582
|
+
if (slotsBanner && first && typeof first === 'object') {
|
|
2583
|
+
first.banners = [...(Array.isArray(first.banners) ? first.banners : []), slotsBanner]
|
|
2584
|
+
}
|
|
2585
|
+
return first
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* `WAVE WIDER THAN RUNTIME SLOTS`, or null (design D5).
|
|
2590
|
+
*
|
|
2591
|
+
* Raised only against an override the operator set: the vendor's own default is
|
|
2592
|
+
* reduced on small machines by an amount it does not publish, and inventing one
|
|
2593
|
+
* would be a threshold in prose. The plan is never resized — it is published
|
|
2594
|
+
* policy, and the runtime queues the extra lanes — so this only says that lanes
|
|
2595
|
+
* will wait, where they would otherwise wait without a word.
|
|
2596
|
+
*/
|
|
2597
|
+
function runtimeSlotsBanner(manifest, state) {
|
|
2598
|
+
const slots = manifest && manifest.runtimeSlots
|
|
2599
|
+
if (!slots || slots.source !== 'env' || !Number.isInteger(slots.observed)) return null
|
|
2600
|
+
const waves = state && Array.isArray(state.waves) ? state.waves : []
|
|
2601
|
+
let widest = 0
|
|
2602
|
+
for (const wave of waves) {
|
|
2603
|
+
for (const batch of Array.isArray(wave && wave.batches) ? wave.batches : []) {
|
|
2604
|
+
if (Array.isArray(batch) && batch.length > widest) widest = batch.length
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (widest <= slots.observed) return null
|
|
2608
|
+
const queued = widest - slots.observed
|
|
2609
|
+
return (
|
|
2610
|
+
`WAVE WIDER THAN RUNTIME SLOTS: the widest batch has ${widest} lanes and ` +
|
|
2611
|
+
`CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS=${slots.observed} — ${queued} lane${queued === 1 ? '' : 's'} ` +
|
|
2612
|
+
`queue${queued === 1 ? 's' : ''} for a slot; the plan is not resized`
|
|
2613
|
+
)
|
|
1857
2614
|
}
|
|
1858
2615
|
|
|
1859
2616
|
// --- run classified (design D7) ---------------------------------------------
|
|
@@ -1937,7 +2694,7 @@ export function resolveRedWave({ tddMode, redSection, tasks }) {
|
|
|
1937
2694
|
export function runClassified(ctx, { classifiedPath }) {
|
|
1938
2695
|
const { root } = ctx
|
|
1939
2696
|
const manifest = readManifest(root)
|
|
1940
|
-
if (!manifest) return noManifest()
|
|
2697
|
+
if (!manifest) return noManifest(ctx)
|
|
1941
2698
|
const capped = countStep(root, manifest)
|
|
1942
2699
|
if (capped) return recordThen(root, manifest, capped)
|
|
1943
2700
|
|
|
@@ -2034,6 +2791,42 @@ export function runClassified(ctx, { classifiedPath }) {
|
|
|
2034
2791
|
|
|
2035
2792
|
// --- run record-batch (design D5) -------------------------------------------
|
|
2036
2793
|
|
|
2794
|
+
// A runtime-worktree host forks each isolated lane from `worktree.baseRef`,
|
|
2795
|
+
// which a plugin can neither read nor set — so the lane's base is whatever that
|
|
2796
|
+
// names, not the run's snapshot. Bannered at `run start`, named at the halt.
|
|
2797
|
+
const ISOLATION_BASE_BANNER =
|
|
2798
|
+
'ISOLATION BASE NOT CONTROLLED (workflow): the runtime forks lane worktrees from worktree.baseRef, ' +
|
|
2799
|
+
"not from the run's snapshot — an isolated batch whose lanes did not fork from the snapshot halts " +
|
|
2800
|
+
'with LANE BASE MISMATCH'
|
|
2801
|
+
const RUNTIME_BASE_HALT =
|
|
2802
|
+
'the Workflow runtime chose this base through worktree.baseRef, which a plugin cannot set; ' +
|
|
2803
|
+
'--isolate-waves on this host needs the follow-up change that creates lane worktrees from the CLI'
|
|
2804
|
+
|
|
2805
|
+
/**
|
|
2806
|
+
* The fold candidates whose worktree is not checked out at `base`.
|
|
2807
|
+
*
|
|
2808
|
+
* Read through git from each worktree, never from the lane's own report: that
|
|
2809
|
+
* report is the thing being audited. A lane git cannot answer for is a mismatch
|
|
2810
|
+
* only when it reported writes — a clean lane that changed nothing may have had
|
|
2811
|
+
* its worktree removed by the runtime, and that is an empty-write lane, not a
|
|
2812
|
+
* wrong base. A lane that named no worktree at all is left to `merge-lanes`,
|
|
2813
|
+
* which already halts on it as unresolved.
|
|
2814
|
+
*
|
|
2815
|
+
* @returns {Array<{label: string, head: string|null}>}
|
|
2816
|
+
*/
|
|
2817
|
+
function laneBaseMismatches(ctx, candidates, base) {
|
|
2818
|
+
const mismatched = []
|
|
2819
|
+
for (const lane of candidates) {
|
|
2820
|
+
if (!lane.worktreePath) continue
|
|
2821
|
+
const head = ctx.deps.worktreeHead(lane.worktreePath)
|
|
2822
|
+
if (head === base) continue
|
|
2823
|
+
const wrote = Array.isArray(lane.reportedFiles) && lane.reportedFiles.length > 0
|
|
2824
|
+
if (!head && !wrote) continue
|
|
2825
|
+
mismatched.push({ label: lane.label, head })
|
|
2826
|
+
}
|
|
2827
|
+
return mismatched
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2037
2830
|
/**
|
|
2038
2831
|
* Fold, adjudicate, record and tick — one CLI call where the loop used to spend
|
|
2039
2832
|
* three agent turns.
|
|
@@ -2042,10 +2835,10 @@ export function runClassified(ctx, { classifiedPath }) {
|
|
|
2042
2835
|
* named rather than removed: folding a partial write from a lane that stopped
|
|
2043
2836
|
* mid-way is exactly the silent-overwrite defect the merge policy refuses.
|
|
2044
2837
|
*/
|
|
2045
|
-
export function runRecordBatch(ctx, { results }) {
|
|
2838
|
+
export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
2046
2839
|
const { root } = ctx
|
|
2047
2840
|
const manifest = readManifest(root)
|
|
2048
|
-
if (!manifest) return noManifest()
|
|
2841
|
+
if (!manifest) return noManifest(ctx)
|
|
2049
2842
|
const capped = countStep(root, manifest)
|
|
2050
2843
|
if (capped) return recordThen(root, manifest, capped)
|
|
2051
2844
|
|
|
@@ -2067,7 +2860,12 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2067
2860
|
const isolate = manifest.flags.isolateWaves === true
|
|
2068
2861
|
const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
|
|
2069
2862
|
|
|
2070
|
-
|
|
2863
|
+
// Before anything is folded or ticked: an `agent-result` that did not land
|
|
2864
|
+
// is a gap in the trajectory, and this batch's ticks must not outrun it.
|
|
2865
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords, wave: pending.wave })
|
|
2866
|
+
if (observed.failure) {
|
|
2867
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
2868
|
+
}
|
|
2071
2869
|
|
|
2072
2870
|
lanes.forEach((lane, i) => {
|
|
2073
2871
|
const outcomes = laneOutcomes(lane, results[i])
|
|
@@ -2116,7 +2914,8 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2116
2914
|
}
|
|
2117
2915
|
|
|
2118
2916
|
// Fold this batch's successful lanes back into the shared tree before moving
|
|
2119
|
-
// on
|
|
2917
|
+
// on. The NEXT isolated batch snapshots this tree for its own base — that is
|
|
2918
|
+
// how a later batch, and a later wave, sees every fold before it.
|
|
2120
2919
|
if (isolate && laneFoldCandidates.length) {
|
|
2121
2920
|
// The base the lanes actually forked from, captured at dispatch and carried
|
|
2122
2921
|
// on the manifest across the driver round trip. `pending` is a pure
|
|
@@ -2132,6 +2931,27 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2132
2931
|
`fold, so lane diffs are taken against the post-batch HEAD (${base || 'unreadable'})`
|
|
2133
2932
|
)
|
|
2134
2933
|
}
|
|
2934
|
+
// Every lane's base, read from its own worktree before anything folds. A
|
|
2935
|
+
// lane forked from anything else reports the difference between the two as
|
|
2936
|
+
// its own write, and the whole-file fold silently reverts whatever that
|
|
2937
|
+
// difference held — so a wrong base halts the batch, and no lane of it folds.
|
|
2938
|
+
const mismatched = base ? laneBaseMismatches(ctx, laneFoldCandidates, base) : []
|
|
2939
|
+
if (mismatched.length) {
|
|
2940
|
+
const survivors = [
|
|
2941
|
+
...laneFoldCandidates.filter(l => l.worktreePath && existsSync(l.worktreePath)),
|
|
2942
|
+
...laneWorktreesPreserved
|
|
2943
|
+
]
|
|
2944
|
+
.map(w => `${w.label}: ${w.worktreePath}`)
|
|
2945
|
+
.join('; ')
|
|
2946
|
+
return finishStep(ctx, manifest, haltStep(
|
|
2947
|
+
'LANE BASE MISMATCH: ' +
|
|
2948
|
+
mismatched.map(m => `${m.label} forked from ${m.head || '(unreadable)'}, expected ${base}`).join('; ') +
|
|
2949
|
+
` — no lane of wave ${pending.wave}'s batch was folded` +
|
|
2950
|
+
(hostCapability(manifest, 'worktree') === 'runtime' ? ` — ${RUNTIME_BASE_HALT}` : '') +
|
|
2951
|
+
` — surviving worktrees: ${survivors || '(none)'}`,
|
|
2952
|
+
{ banners }
|
|
2953
|
+
))
|
|
2954
|
+
}
|
|
2135
2955
|
const merged = base ? ctx.deps.runMergeLanes(root, laneFoldCandidates, base) : null
|
|
2136
2956
|
if (Array.isArray(merged && merged.cleanupWarnings) && merged.cleanupWarnings.length) {
|
|
2137
2957
|
banners.push(
|
|
@@ -2176,7 +2996,7 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2176
2996
|
changedPaths: ctx.deps.observedChangedPaths(root)
|
|
2177
2997
|
})
|
|
2178
2998
|
writeState(root, after)
|
|
2179
|
-
const recorded = ctx.deps.logWaveMutation(
|
|
2999
|
+
const recorded = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
2180
3000
|
source: 'record-batch',
|
|
2181
3001
|
change: manifest.change,
|
|
2182
3002
|
state: after
|
|
@@ -2198,13 +3018,7 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2198
3018
|
// Before the tick and before the commit, exactly like the wave-action above:
|
|
2199
3019
|
// a batch whose implementer spawns went unrecorded is a batch nobody can
|
|
2200
3020
|
// attribute, and `emitRecordedState` already exits 1 on this same boolean.
|
|
2201
|
-
|
|
2202
|
-
state: after,
|
|
2203
|
-
change: manifest.change,
|
|
2204
|
-
step: nextRaw,
|
|
2205
|
-
source: 'record-batch'
|
|
2206
|
-
})
|
|
2207
|
-
if (laneSpawnsLogged === false) {
|
|
3021
|
+
if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'record-batch')) {
|
|
2208
3022
|
ctx.warn('an implementer agent-spawn was not recorded on record-batch')
|
|
2209
3023
|
manifest.banners.push(...banners)
|
|
2210
3024
|
return finishStep(
|
|
@@ -2267,15 +3081,18 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2267
3081
|
* reports exit codes and counts; whether the run may continue is not its
|
|
2268
3082
|
* question, and both drivers used to let it answer.
|
|
2269
3083
|
*/
|
|
2270
|
-
export function runJudge(ctx, { context, results }) {
|
|
3084
|
+
export function runJudge(ctx, { context, results, hostRecords }) {
|
|
2271
3085
|
const { root } = ctx
|
|
2272
3086
|
const manifest = readManifest(root)
|
|
2273
|
-
if (!manifest) return noManifest()
|
|
3087
|
+
if (!manifest) return noManifest(ctx)
|
|
2274
3088
|
const capped = countStep(root, manifest)
|
|
2275
3089
|
if (capped) return recordThen(root, manifest, capped)
|
|
2276
3090
|
|
|
2277
3091
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
2278
|
-
|
|
3092
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
3093
|
+
if (observed.failure) {
|
|
3094
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
3095
|
+
}
|
|
2279
3096
|
|
|
2280
3097
|
const state = readState(root)
|
|
2281
3098
|
const plan = readJson(join(root, WORK_DIR, `vplan-${context}.json`))
|
|
@@ -2286,10 +3103,34 @@ export function runJudge(ctx, { context, results }) {
|
|
|
2286
3103
|
// process — the next one — can see how long the agent between them took. An
|
|
2287
3104
|
// unparseable or negative mark contributes nothing rather than a wrong number,
|
|
2288
3105
|
// and the mark is cleared either way so it can never be counted twice.
|
|
3106
|
+
//
|
|
3107
|
+
// What is charged is the checks' own time, as `interlock verify exec` measured
|
|
3108
|
+
// it around each command. The interval from dispatch to judgement is only the
|
|
3109
|
+
// fallback: it also holds two relays and the verify agent's own turns, and
|
|
3110
|
+
// charging those as verification spent the whole budget on the first
|
|
3111
|
+
// checkpoint of a Workflow run and left every later one typecheck-only. The
|
|
3112
|
+
// fallback is spoken, never silent.
|
|
3113
|
+
const timings = readVerifyTimings(root)
|
|
3114
|
+
clearVerifyTimings(root)
|
|
2289
3115
|
if (manifest.verifyStartedAt) {
|
|
2290
|
-
const
|
|
2291
|
-
|
|
2292
|
-
|
|
3116
|
+
const planned = plan && Array.isArray(plan.steps) ? plan.steps : []
|
|
3117
|
+
const timed = foldCheckTimings(planned, timings)
|
|
3118
|
+
const roundTrip = Date.now() - Date.parse(manifest.verifyStartedAt)
|
|
3119
|
+
let charged = null
|
|
3120
|
+
if (planned.length && !timed.missing.length) {
|
|
3121
|
+
charged = timed.ms
|
|
3122
|
+
} else if (Number.isFinite(roundTrip) && roundTrip >= 0) {
|
|
3123
|
+
charged = Math.round(roundTrip)
|
|
3124
|
+
if (planned.length) {
|
|
3125
|
+
banners.push(
|
|
3126
|
+
`VERIFY BUDGET CLOCKED BY ROUND TRIP: no \`interlock verify exec\` timing for ` +
|
|
3127
|
+
`${timed.missing.join(', ')} — ${charged}ms of relay and agent time counted against ` +
|
|
3128
|
+
`the inter-wave verify budget`
|
|
3129
|
+
)
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
if (charged !== null) {
|
|
3133
|
+
manifest.verifyElapsedMs = (numberOrUndefined(manifest.verifyElapsedMs) || 0) + charged
|
|
2293
3134
|
}
|
|
2294
3135
|
manifest.verifyStartedAt = null
|
|
2295
3136
|
}
|
|
@@ -2360,7 +3201,7 @@ export function runJudge(ctx, { context, results }) {
|
|
|
2360
3201
|
}
|
|
2361
3202
|
const after = recordVerifyResult(state, record)
|
|
2362
3203
|
writeState(root, after)
|
|
2363
|
-
const verified = ctx.deps.logWaveMutation(
|
|
3204
|
+
const verified = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
2364
3205
|
source: 'record-verify',
|
|
2365
3206
|
change: manifest.change,
|
|
2366
3207
|
state: after
|
|
@@ -2375,6 +3216,11 @@ export function runJudge(ctx, { context, results }) {
|
|
|
2375
3216
|
trajectoryHaltStep('wave-state record-verify', waveAppendReason(verified), { banners })
|
|
2376
3217
|
)
|
|
2377
3218
|
}
|
|
3219
|
+
// The next wave's first batch is named here, so its lanes are logged here.
|
|
3220
|
+
if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'record-verify')) {
|
|
3221
|
+
ctx.warn('an implementer agent-spawn was not recorded on record-verify')
|
|
3222
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED, { banners }))
|
|
3223
|
+
}
|
|
2378
3224
|
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
|
|
2379
3225
|
}
|
|
2380
3226
|
|
|
@@ -2459,6 +3305,7 @@ function handoffStep(ctx, manifest, banners = []) {
|
|
|
2459
3305
|
label: 'handoff',
|
|
2460
3306
|
kind: 'handoff',
|
|
2461
3307
|
model: null,
|
|
3308
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
2462
3309
|
effort: null,
|
|
2463
3310
|
schema: HANDOFF_RESULT_SCHEMA,
|
|
2464
3311
|
prompt: assembleHandoffPrompt({
|
|
@@ -2495,6 +3342,7 @@ function commitStep(ctx, manifest, banners = []) {
|
|
|
2495
3342
|
label: 'commit',
|
|
2496
3343
|
kind: 'commit',
|
|
2497
3344
|
model: null,
|
|
3345
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
2498
3346
|
effort: null,
|
|
2499
3347
|
schema: COMMIT_SCHEMA,
|
|
2500
3348
|
prompt: assembleCommitPrompt({ change: manifest.change, stageIndex: ++manifest.stageIndex })
|
|
@@ -2511,15 +3359,18 @@ function commitStep(ctx, manifest, banners = []) {
|
|
|
2511
3359
|
* with no handoff step never calls it: `run judge --context final` emits the
|
|
2512
3360
|
* commit directly.
|
|
2513
3361
|
*/
|
|
2514
|
-
export function runCommit(ctx, { results }) {
|
|
3362
|
+
export function runCommit(ctx, { results, hostRecords }) {
|
|
2515
3363
|
const { root } = ctx
|
|
2516
3364
|
const manifest = readManifest(root)
|
|
2517
|
-
if (!manifest) return noManifest()
|
|
3365
|
+
if (!manifest) return noManifest(ctx)
|
|
2518
3366
|
const capped = countStep(root, manifest)
|
|
2519
3367
|
if (capped) return recordThen(root, manifest, capped)
|
|
2520
3368
|
|
|
2521
3369
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
2522
|
-
|
|
3370
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
3371
|
+
if (observed.failure) {
|
|
3372
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
3373
|
+
}
|
|
2523
3374
|
|
|
2524
3375
|
const reported = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
2525
3376
|
const count = value => (Number.isInteger(value) ? value : undefined)
|
|
@@ -2569,7 +3420,7 @@ function flattenVerifyResults(results) {
|
|
|
2569
3420
|
export function runVerifyFinal(ctx) {
|
|
2570
3421
|
const { root } = ctx
|
|
2571
3422
|
const manifest = readManifest(root)
|
|
2572
|
-
if (!manifest) return noManifest()
|
|
3423
|
+
if (!manifest) return noManifest(ctx)
|
|
2573
3424
|
const capped = countStep(root, manifest)
|
|
2574
3425
|
if (capped) return recordThen(root, manifest, capped)
|
|
2575
3426
|
return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest))
|
|
@@ -2578,15 +3429,18 @@ export function runVerifyFinal(ctx) {
|
|
|
2578
3429
|
// --- run replan -------------------------------------------------------------
|
|
2579
3430
|
|
|
2580
3431
|
/** Apply a revision the replan step produced, or carry on with the next step. */
|
|
2581
|
-
export function runReplan(ctx, { results }) {
|
|
3432
|
+
export function runReplan(ctx, { results, hostRecords }) {
|
|
2582
3433
|
const { root } = ctx
|
|
2583
3434
|
const manifest = readManifest(root)
|
|
2584
|
-
if (!manifest) return noManifest()
|
|
3435
|
+
if (!manifest) return noManifest(ctx)
|
|
2585
3436
|
const capped = countStep(root, manifest)
|
|
2586
3437
|
if (capped) return recordThen(root, manifest, capped)
|
|
2587
3438
|
|
|
2588
3439
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
2589
|
-
|
|
3440
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
3441
|
+
if (observed.failure) {
|
|
3442
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
3443
|
+
}
|
|
2590
3444
|
|
|
2591
3445
|
const state = readState(root)
|
|
2592
3446
|
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
@@ -2596,7 +3450,12 @@ export function runReplan(ctx, { results }) {
|
|
|
2596
3450
|
if (!revised || !Array.isArray(groups)) {
|
|
2597
3451
|
// Nothing to revise is the ordinary outcome, and re-reading the state is
|
|
2598
3452
|
// how the loop moves past a replan offer it declined.
|
|
2599
|
-
|
|
3453
|
+
const declined = nextStep(state)
|
|
3454
|
+
if (!logLaneSpawns(ctx, manifest, state, declined, 'replan')) {
|
|
3455
|
+
ctx.warn('an implementer agent-spawn was not recorded on replan')
|
|
3456
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
|
|
3457
|
+
}
|
|
3458
|
+
return finishStep(ctx, manifest, decorate(ctx, declined, manifest))
|
|
2600
3459
|
}
|
|
2601
3460
|
|
|
2602
3461
|
let after
|
|
@@ -2606,7 +3465,7 @@ export function runReplan(ctx, { results }) {
|
|
|
2606
3465
|
return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
|
|
2607
3466
|
}
|
|
2608
3467
|
writeState(root, after)
|
|
2609
|
-
const replanned = ctx.deps.logWaveMutation(
|
|
3468
|
+
const replanned = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
2610
3469
|
source: 'replan',
|
|
2611
3470
|
change: manifest.change,
|
|
2612
3471
|
state: after
|
|
@@ -2616,6 +3475,10 @@ export function runReplan(ctx, { results }) {
|
|
|
2616
3475
|
ctx.warn('the wave-state trajectory append failed on replan')
|
|
2617
3476
|
return finishStep(ctx, manifest, trajectoryHaltStep('wave-state replan', waveAppendReason(replanned)))
|
|
2618
3477
|
}
|
|
3478
|
+
if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'replan')) {
|
|
3479
|
+
ctx.warn('an implementer agent-spawn was not recorded on replan')
|
|
3480
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
|
|
3481
|
+
}
|
|
2619
3482
|
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
|
|
2620
3483
|
}
|
|
2621
3484
|
|
|
@@ -2625,22 +3488,50 @@ export function runReplan(ctx, { results }) {
|
|
|
2625
3488
|
export function runNext(ctx) {
|
|
2626
3489
|
const { root } = ctx
|
|
2627
3490
|
const manifest = readManifest(root)
|
|
2628
|
-
if (!manifest) return noManifest()
|
|
3491
|
+
if (!manifest) return noManifest(ctx)
|
|
2629
3492
|
const capped = countStep(root, manifest)
|
|
2630
3493
|
if (capped) return recordThen(root, manifest, capped)
|
|
2631
3494
|
const state = readState(root)
|
|
2632
3495
|
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
2633
|
-
|
|
3496
|
+
// A re-read that names a batch is a batch the driver will dispatch again, so
|
|
3497
|
+
// those agents are spawns like any other.
|
|
3498
|
+
const pending = nextStep(state)
|
|
3499
|
+
if (!logLaneSpawns(ctx, manifest, state, pending, 'next')) {
|
|
3500
|
+
ctx.warn('an implementer agent-spawn was not recorded on next')
|
|
3501
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED))
|
|
3502
|
+
}
|
|
3503
|
+
return finishStep(ctx, manifest, decorate(ctx, pending, manifest))
|
|
2634
3504
|
}
|
|
2635
3505
|
|
|
2636
3506
|
// --- helpers used by every subcommand ---------------------------------------
|
|
2637
3507
|
|
|
2638
|
-
function noManifest() {
|
|
3508
|
+
function noManifest(ctx) {
|
|
3509
|
+
const elsewhere = manifestInStateHome(ctx.root)
|
|
3510
|
+
if (elsewhere) {
|
|
3511
|
+
return haltStep(
|
|
3512
|
+
`no run manifest at ${elsewhere.here} — one exists at ${elsewhere.there}; if this session moved into ` +
|
|
3513
|
+
`a worktree after run start, the run continues from there`
|
|
3514
|
+
)
|
|
3515
|
+
}
|
|
2639
3516
|
return haltStep(
|
|
2640
3517
|
`no run manifest at ${MANIFEST_PATH} — a run subcommand was reached without \`interlock run start\``
|
|
2641
3518
|
)
|
|
2642
3519
|
}
|
|
2643
3520
|
|
|
3521
|
+
/**
|
|
3522
|
+
* A manifest the state home holds when this root holds none (design D18): a
|
|
3523
|
+
* session that moved into a worktree after `run start` halts naming both, and
|
|
3524
|
+
* one that did not sees today's message. Resolved here because there is no
|
|
3525
|
+
* manifest to read a recorded home from.
|
|
3526
|
+
*/
|
|
3527
|
+
function manifestInStateHome(root) {
|
|
3528
|
+
const here = resolve(root)
|
|
3529
|
+
const { home } = resolveStateHome(here)
|
|
3530
|
+
if (!home || resolve(home) === here) return null
|
|
3531
|
+
const there = join(home, MANIFEST_PATH)
|
|
3532
|
+
return existsSync(there) ? { here: join(here, MANIFEST_PATH), there } : null
|
|
3533
|
+
}
|
|
3534
|
+
|
|
2644
3535
|
/**
|
|
2645
3536
|
* Persist what this step observed and record the continuation it names.
|
|
2646
3537
|
*
|
|
@@ -2649,6 +3540,8 @@ function noManifest() {
|
|
|
2649
3540
|
* process — each `run` subcommand is its own.
|
|
2650
3541
|
*/
|
|
2651
3542
|
function finishStep(ctx, manifest, step) {
|
|
3543
|
+
carryRaisedBanners(manifest, step)
|
|
3544
|
+
recordDispatched(manifest, step)
|
|
2652
3545
|
const failure = logSpawns(ctx, manifest, step)
|
|
2653
3546
|
// An agent-spawn nobody recorded is a gap in the trajectory, and the step it
|
|
2654
3547
|
// belongs to must not run. A step that was ALREADY halting keeps its own
|
|
@@ -2663,6 +3556,48 @@ function finishStep(ctx, manifest, step) {
|
|
|
2663
3556
|
return recordThen(ctx.root, manifest, final)
|
|
2664
3557
|
}
|
|
2665
3558
|
|
|
3559
|
+
/**
|
|
3560
|
+
* Put on the step every banner the run raised since the last step carried one
|
|
3561
|
+
* (draw-the-ship-run-live, design D1).
|
|
3562
|
+
*
|
|
3563
|
+
* A banner pushed onto `manifest.banners` reaches the close summary whatever
|
|
3564
|
+
* happens; this makes sure it reaches a step as well, so a reader of the step
|
|
3565
|
+
* stream (the ship meter) displays what the CLI named instead of discovering it
|
|
3566
|
+
* at close. Derived from the same list the summary prints, in its order, and
|
|
3567
|
+
* additive: a step's own banners stay first, nothing already carried is raised
|
|
3568
|
+
* again. `bannersCarried` is how far into the list the steps have reached.
|
|
3569
|
+
*/
|
|
3570
|
+
function carryRaisedBanners(manifest, step) {
|
|
3571
|
+
if (!step || typeof step !== 'object' || !Array.isArray(manifest.banners)) return
|
|
3572
|
+
const from = Number.isInteger(manifest.bannersCarried) ? manifest.bannersCarried : 0
|
|
3573
|
+
const raised = manifest.banners.slice(from)
|
|
3574
|
+
manifest.bannersCarried = manifest.banners.length
|
|
3575
|
+
const own = Array.isArray(step.banners) ? step.banners : []
|
|
3576
|
+
const missing = raised.filter((b, i) => typeof b === 'string' && !own.includes(b) && raised.indexOf(b) === i)
|
|
3577
|
+
if (missing.length) step.banners = [...own, ...missing]
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/**
|
|
3581
|
+
* The join table for the close (design D4): every spawn on the step, lane
|
|
3582
|
+
* spawns included, with the sha its bootstrap carries. Only once the run has an
|
|
3583
|
+
* id — the same rule `logSpawns` follows — because the recorder writes nothing
|
|
3584
|
+
* for a spawn dispatched before one, so it could never join. Bounded by the
|
|
3585
|
+
* published step cap times the batch width, so it needs no cap of its own.
|
|
3586
|
+
*/
|
|
3587
|
+
function recordDispatched(manifest, step) {
|
|
3588
|
+
if (!manifest.runId || !step || !Array.isArray(step.spawns) || !step.spawns.length) return
|
|
3589
|
+
if (!Array.isArray(manifest.dispatched)) manifest.dispatched = []
|
|
3590
|
+
for (const s of step.spawns) {
|
|
3591
|
+
manifest.dispatched.push({
|
|
3592
|
+
wave: Number.isInteger(step.wave) ? step.wave : null,
|
|
3593
|
+
label: s.label,
|
|
3594
|
+
kind: s.kind,
|
|
3595
|
+
model: s.model || null,
|
|
3596
|
+
sha: typeof s.promptSha256 === 'string' ? s.promptSha256 : null
|
|
3597
|
+
})
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
|
|
2666
3601
|
// --- run close (design D10) -------------------------------------------------
|
|
2667
3602
|
|
|
2668
3603
|
/**
|
|
@@ -2679,7 +3614,15 @@ function finishStep(ctx, manifest, step) {
|
|
|
2679
3614
|
*/
|
|
2680
3615
|
export async function runClose(
|
|
2681
3616
|
ctx,
|
|
2682
|
-
{
|
|
3617
|
+
{
|
|
3618
|
+
results = [],
|
|
3619
|
+
halt = null,
|
|
3620
|
+
hostBanners = [],
|
|
3621
|
+
hostObserved = null,
|
|
3622
|
+
hostRecords = [],
|
|
3623
|
+
notify = false,
|
|
3624
|
+
env = process.env
|
|
3625
|
+
} = {}
|
|
2683
3626
|
) {
|
|
2684
3627
|
const { root } = ctx
|
|
2685
3628
|
const absRoot = resolve(root)
|
|
@@ -2697,10 +3640,14 @@ export async function runClose(
|
|
|
2697
3640
|
// The change name is unknown here — there is no manifest to read it from —
|
|
2698
3641
|
// so the message carries the headline and the absent run id alone.
|
|
2699
3642
|
const push = await attemptPush(notify, env, { headline, change: null, runId: null })
|
|
3643
|
+
// Where a record would have gone, so the reader can look there (ship/close-summary).
|
|
3644
|
+
const home = resolve(resolveStateHome(absRoot).home)
|
|
3645
|
+
const elsewhere = home !== absRoot && existsSync(join(home, MANIFEST_PATH)) ? join(home, MANIFEST_PATH) : null
|
|
2700
3646
|
const rows = [
|
|
2701
3647
|
' run: none — the run halted before a plan was adopted',
|
|
2702
3648
|
` project: ${slug}`,
|
|
2703
|
-
` cwd: ${absRoot}
|
|
3649
|
+
` cwd: ${absRoot}`,
|
|
3650
|
+
...(home !== absRoot ? [` state home: ${home}`] : [])
|
|
2704
3651
|
]
|
|
2705
3652
|
if (push && push.sent !== undefined) {
|
|
2706
3653
|
rows.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
|
|
@@ -2714,13 +3661,19 @@ export async function runClose(
|
|
|
2714
3661
|
banners: Array.isArray(hostBanners) ? hostBanners : [],
|
|
2715
3662
|
summary:
|
|
2716
3663
|
`${headline}\n\n` +
|
|
2717
|
-
|
|
3664
|
+
(elsewhere
|
|
3665
|
+
? `There is no run manifest at ${join(absRoot, MANIFEST_PATH)} — one exists at ${elsewhere}; if ` +
|
|
3666
|
+
`this session moved into a worktree after run start, the run continues from there.\n`
|
|
3667
|
+
: `There is no run manifest at ${MANIFEST_PATH}, so nothing was recorded for this run.\n`) +
|
|
2718
3668
|
rows.join('\n')
|
|
2719
3669
|
}
|
|
2720
3670
|
}
|
|
2721
3671
|
|
|
2722
3672
|
const haltReason = typeof halt === 'string' && halt.trim() ? halt.trim() : manifest.halted || null
|
|
2723
3673
|
if (haltReason) manifest.halted = haltReason
|
|
3674
|
+
// Corpora go to the home `run start` recorded; the manifest, the state and the
|
|
3675
|
+
// sidecar stay in the root (ship/state-home).
|
|
3676
|
+
const home = stateHomeOf(ctx, manifest)
|
|
2724
3677
|
|
|
2725
3678
|
// The commit result, when the close is the continuation of a commit step.
|
|
2726
3679
|
const committed = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
@@ -2754,6 +3707,16 @@ export async function runClose(
|
|
|
2754
3707
|
// any, because the runtime's counter sees turns this process never does.
|
|
2755
3708
|
const counted = summarizeUsage(manifest)
|
|
2756
3709
|
const hostSpend = Array.isArray(observed.spend) && observed.spend.length ? observed.spend : null
|
|
3710
|
+
|
|
3711
|
+
// What the recorder hooks wrote for this run, joined to the spawns this program
|
|
3712
|
+
// dispatched (ship/agent-results). Decided here, before the summary and the
|
|
3713
|
+
// degradation block are assembled, so its banners reach both.
|
|
3714
|
+
const joined = joinAgentUsage(root, manifest)
|
|
3715
|
+
const closeObserved = observeJoinedAgents(manifest, joined)
|
|
3716
|
+
// The committer's host record, on a runner: the commit step's continuation is
|
|
3717
|
+
// this close, so its lane is observed here. Usage is left as it was — the close
|
|
3718
|
+
// has never counted the committer's results — and only the host's record is read.
|
|
3719
|
+
const committerObserved = recordSpawnObservations(ctx, manifest, { results: [], hostRecords })
|
|
2757
3720
|
const hostTotal =
|
|
2758
3721
|
typeof observed.outputTokens === 'number' || observed.outputTokens === null
|
|
2759
3722
|
? observed.outputTokens
|
|
@@ -2789,7 +3752,8 @@ export async function runClose(
|
|
|
2789
3752
|
if (
|
|
2790
3753
|
hostCacheRead === undefined &&
|
|
2791
3754
|
hostCacheCreation === undefined &&
|
|
2792
|
-
hostCapability(manifest, 'cacheAccounting') === false
|
|
3755
|
+
hostCapability(manifest, 'cacheAccounting') === false &&
|
|
3756
|
+
closeObserved.usable === 0
|
|
2793
3757
|
) {
|
|
2794
3758
|
manifest.notes.push(
|
|
2795
3759
|
`CACHE ACCOUNTING NOT REPORTED: the ${hostId(manifest) || 'run'} host's runtime exposes no ` +
|
|
@@ -2798,13 +3762,23 @@ export async function runClose(
|
|
|
2798
3762
|
)
|
|
2799
3763
|
}
|
|
2800
3764
|
|
|
3765
|
+
const useJoin = joined.present && joined.cacheFromHooks && joined.briefed > 0
|
|
2801
3766
|
const summary = {
|
|
2802
3767
|
waves: manifest.waves || [],
|
|
2803
3768
|
spend: hostSpend || counted.spend,
|
|
2804
3769
|
outputTokens: hostTotal !== undefined ? hostTotal : counted.outputTokens,
|
|
2805
|
-
cacheReadInputTokens:
|
|
3770
|
+
cacheReadInputTokens:
|
|
3771
|
+
hostCacheRead !== undefined
|
|
3772
|
+
? hostCacheRead
|
|
3773
|
+
: useJoin
|
|
3774
|
+
? joined.runCache.cacheReadInputTokens
|
|
3775
|
+
: counted.cacheReadInputTokens,
|
|
2806
3776
|
cacheCreationInputTokens:
|
|
2807
|
-
hostCacheCreation !== undefined
|
|
3777
|
+
hostCacheCreation !== undefined
|
|
3778
|
+
? hostCacheCreation
|
|
3779
|
+
: useJoin
|
|
3780
|
+
? joined.runCache.cacheCreationInputTokens
|
|
3781
|
+
: counted.cacheCreationInputTokens,
|
|
2808
3782
|
review: tail.review,
|
|
2809
3783
|
remediation: tail.remediation,
|
|
2810
3784
|
remediationRounds: tail.remediationRounds,
|
|
@@ -2817,6 +3791,8 @@ export async function runClose(
|
|
|
2817
3791
|
commitSkipped: manifest.commitSkipped === true,
|
|
2818
3792
|
paths: readPathSets(root, manifest)
|
|
2819
3793
|
}
|
|
3794
|
+
// The join's per-wave cache figures, where the host observed none (design D5).
|
|
3795
|
+
if (useJoin) summary.spend = fillJoinedCache(summary.spend, joined.cacheByWave)
|
|
2820
3796
|
|
|
2821
3797
|
const degradations = degradationLines({
|
|
2822
3798
|
banners: manifest.banners || [],
|
|
@@ -2880,19 +3856,36 @@ export async function runClose(
|
|
|
2880
3856
|
? fingerprint.hash
|
|
2881
3857
|
: undefined
|
|
2882
3858
|
|
|
3859
|
+
// The six observation counts: absent when the run saw no host record and no
|
|
3860
|
+
// sidecar, a measured zero otherwise (design D8).
|
|
3861
|
+
const runnerCounts = manifest.agentCounts && typeof manifest.agentCounts === 'object' ? manifest.agentCounts : null
|
|
3862
|
+
const observedAny = Boolean(runnerCounts) || joined.present
|
|
3863
|
+
const count = (value = 0) => (observedAny ? value : undefined)
|
|
2883
3864
|
const receipt = buildReceipt({
|
|
2884
3865
|
change: manifest.change,
|
|
2885
3866
|
summary,
|
|
2886
3867
|
degradations,
|
|
2887
3868
|
planFingerprint,
|
|
2888
3869
|
leftoverTaskIds,
|
|
3870
|
+
lanesStoppedByHost: count(runnerCounts ? runnerCounts.lanesStoppedByHost : 0),
|
|
3871
|
+
schemaResultsMissing: count(runnerCounts ? runnerCounts.schemaResultsMissing : 0),
|
|
3872
|
+
toolsDeniedInLanes: count(runnerCounts ? runnerCounts.toolsDeniedInLanes : 0),
|
|
3873
|
+
modelSubstitutions: count((runnerCounts ? runnerCounts.modelSubstitutions : 0) + closeObserved.substitutions),
|
|
3874
|
+
permissionPrompts: count(closeObserved.permissionPrompts),
|
|
3875
|
+
autoModeDenials: count(closeObserved.autoModeDenials),
|
|
3876
|
+
surface: manifest.surface,
|
|
3877
|
+
stateHome: manifest.stateHome,
|
|
2889
3878
|
host: manifest.host
|
|
2890
3879
|
? {
|
|
2891
3880
|
id: hostId(manifest),
|
|
2892
3881
|
billing: hostCapability(manifest, 'billing'),
|
|
2893
3882
|
hooks: hostCapability(manifest, 'hooks'),
|
|
2894
3883
|
usage: hostCapability(manifest, 'usage'),
|
|
2895
|
-
cacheAccounting: hostCapability(manifest, 'cacheAccounting')
|
|
3884
|
+
cacheAccounting: hostCapability(manifest, 'cacheAccounting'),
|
|
3885
|
+
// The manifest's own field, never `hostCapability`: that falls back to
|
|
3886
|
+
// the assumed `flag`, and a run that recorded nothing would then read
|
|
3887
|
+
// as one whose effort was applied.
|
|
3888
|
+
effort: manifest.host.effort === undefined ? null : manifest.host.effort
|
|
2896
3889
|
}
|
|
2897
3890
|
: undefined
|
|
2898
3891
|
})
|
|
@@ -2903,8 +3896,24 @@ export async function runClose(
|
|
|
2903
3896
|
// degradation and a non-zero exit, exactly what an unreconstructable run gets
|
|
2904
3897
|
// below. Silence would leave a run that reads complete and cannot be replayed.
|
|
2905
3898
|
let trajectoryComplete = true
|
|
3899
|
+
if (committerObserved.failure) {
|
|
3900
|
+
trajectoryComplete = false
|
|
3901
|
+
degradations.push(`TRAJECTORY APPEND FAILED: agent-result ${committerObserved.failure}`)
|
|
3902
|
+
}
|
|
2906
3903
|
if (manifest.runId) {
|
|
2907
|
-
|
|
3904
|
+
// One `agent-result` per joined agent, before the receipt: the same fatal
|
|
3905
|
+
// class as every other append here (ship/agent-results).
|
|
3906
|
+
for (const entry of closeObserved.results) {
|
|
3907
|
+
const result = appendRunLogEvent(home, { runId: manifest.runId, change: manifest.change, ...entry })
|
|
3908
|
+
if (!result.written) {
|
|
3909
|
+
ctx.warn(`agent-result not recorded: ${result.reason}`)
|
|
3910
|
+
trajectoryComplete = false
|
|
3911
|
+
degradations.push(`TRAJECTORY APPEND FAILED: agent-result ${entry.label}: ${result.reason}`)
|
|
3912
|
+
break
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3916
|
+
const written = appendRunLogEvent(home, {
|
|
2908
3917
|
...receipt,
|
|
2909
3918
|
runId: manifest.runId,
|
|
2910
3919
|
change: manifest.change
|
|
@@ -2915,7 +3924,7 @@ export async function runClose(
|
|
|
2915
3924
|
degradations.push(`TRAJECTORY APPEND FAILED: run-receipt: ${written.reason}`)
|
|
2916
3925
|
}
|
|
2917
3926
|
|
|
2918
|
-
const terminal = appendRunLogEvent(
|
|
3927
|
+
const terminal = appendRunLogEvent(home, {
|
|
2919
3928
|
runId: manifest.runId,
|
|
2920
3929
|
change: manifest.change,
|
|
2921
3930
|
type: haltReason ? 'run-halt' : 'run-complete',
|
|
@@ -2931,7 +3940,7 @@ export async function runClose(
|
|
|
2931
3940
|
|
|
2932
3941
|
// The outcome corpus. Its own write failure is reported and never touches the
|
|
2933
3942
|
// exit code: losing a corpus line must not fail the run that produced it.
|
|
2934
|
-
const outcome = appendOutcome(
|
|
3943
|
+
const outcome = appendOutcome(home, {
|
|
2935
3944
|
change: manifest.change,
|
|
2936
3945
|
mode: manifest.flags.mode || 'checkpoint',
|
|
2937
3946
|
observed: observedFromReceipt(receipt)
|
|
@@ -2943,7 +3952,7 @@ export async function runClose(
|
|
|
2943
3952
|
// this repository deliberately keeps different per corpus.
|
|
2944
3953
|
let reconstructable = true
|
|
2945
3954
|
if (manifest.runId) {
|
|
2946
|
-
const check = checkRunLog(
|
|
3955
|
+
const check = checkRunLog(home, manifest.runId)
|
|
2947
3956
|
reconstructable = check.ok !== false
|
|
2948
3957
|
if (!reconstructable) {
|
|
2949
3958
|
degradations.push(
|
|
@@ -2965,7 +3974,7 @@ export async function runClose(
|
|
|
2965
3974
|
if (manifest.flags.strict === true && manifest.review) {
|
|
2966
3975
|
const blockers = survivingBlockers(manifest)
|
|
2967
3976
|
try {
|
|
2968
|
-
recordAutonomy('review-code', { blockers }, { root })
|
|
3977
|
+
recordAutonomy('review-code', { blockers }, { root: home })
|
|
2969
3978
|
manifest.autonomy = { path: 'review-code', blockers }
|
|
2970
3979
|
} catch (err) {
|
|
2971
3980
|
degradations.push(
|
|
@@ -2989,7 +3998,7 @@ export async function runClose(
|
|
|
2989
3998
|
// was hand-edited, cannot change what a later run does.
|
|
2990
3999
|
let resumeCard = null
|
|
2991
4000
|
if (haltReason) {
|
|
2992
|
-
const written = writeResumeCard(
|
|
4001
|
+
const written = writeResumeCard(home, {
|
|
2993
4002
|
change: manifest.change,
|
|
2994
4003
|
runId: manifest.runId,
|
|
2995
4004
|
halted: haltReason,
|
|
@@ -2999,7 +4008,9 @@ export async function runClose(
|
|
|
2999
4008
|
waves: manifest.waves || [],
|
|
3000
4009
|
degradations,
|
|
3001
4010
|
projectSlug: slug,
|
|
3002
|
-
cwd: absRoot
|
|
4011
|
+
cwd: absRoot,
|
|
4012
|
+
stateHome: home,
|
|
4013
|
+
laneSessions: Array.isArray(manifest.laneSessions) ? manifest.laneSessions : []
|
|
3003
4014
|
})
|
|
3004
4015
|
if (written.written) resumeCard = written.path
|
|
3005
4016
|
else degradations.push(`RESUME CARD NOT WRITTEN: ${written.reason}`)
|
|
@@ -3023,6 +4034,7 @@ export async function runClose(
|
|
|
3023
4034
|
degradations,
|
|
3024
4035
|
runId: manifest.runId,
|
|
3025
4036
|
root: absRoot,
|
|
4037
|
+
stateHome: home,
|
|
3026
4038
|
projectSlug: slug,
|
|
3027
4039
|
push,
|
|
3028
4040
|
unarchived,
|
|
@@ -3031,6 +4043,87 @@ export async function runClose(
|
|
|
3031
4043
|
}
|
|
3032
4044
|
}
|
|
3033
4045
|
|
|
4046
|
+
/**
|
|
4047
|
+
* Turn the close's join into what a reader sees (ship/agent-results): the
|
|
4048
|
+
* partial and unjoined lines, the observed `hook` refinement, the permission
|
|
4049
|
+
* counts, a model substitution per joined spawn, and the `agent-result` records
|
|
4050
|
+
* the close appends before the receipt. Banners land on `manifest.banners`,
|
|
4051
|
+
* notes on `manifest.notes`, before the summary is assembled from them.
|
|
4052
|
+
*/
|
|
4053
|
+
function observeJoinedAgents(manifest, joined) {
|
|
4054
|
+
const out = { usable: 0, substitutions: 0, permissionPrompts: 0, autoModeDenials: 0, results: [] }
|
|
4055
|
+
for (const entry of joined.unreadable) {
|
|
4056
|
+
manifest.notes.push(`AGENT USAGE UNREADABLE: ${entry.file}: ${entry.reason}`)
|
|
4057
|
+
}
|
|
4058
|
+
if (!joined.present) return out
|
|
4059
|
+
|
|
4060
|
+
out.usable = joined.joined.filter(j => j.agent.usage && typeof j.agent.usage === 'object').length
|
|
4061
|
+
// The cache half is the hooks standing in for a host that declared it cannot
|
|
4062
|
+
// report: on a host that reports its own figures (the runner), a lane is a
|
|
4063
|
+
// top-level session no subagent hook ever sees, and a sidecar holding only a
|
|
4064
|
+
// permission file must not read as every lane unrecorded.
|
|
4065
|
+
if (joined.cacheFromHooks && joined.briefed > 0 && joined.unrecorded > 0) {
|
|
4066
|
+
manifest.banners.push(`CACHE ACCOUNTING PARTIAL: ${joined.unrecorded} of ${joined.briefed} agents unrecorded`)
|
|
4067
|
+
}
|
|
4068
|
+
const unjoined = joined.unjoined.noKey + joined.unjoined.unknownKey
|
|
4069
|
+
if (unjoined > 0) {
|
|
4070
|
+
manifest.notes.push(
|
|
4071
|
+
`AGENT USAGE UNJOINED: ${unjoined} recorded agents matched no dispatched spawn ` +
|
|
4072
|
+
`(${joined.unjoined.noKey} without a briefing key, ${joined.unjoined.unknownKey} with a key no spawn dispatched)`
|
|
4073
|
+
)
|
|
4074
|
+
}
|
|
4075
|
+
// Observed only, and only from a declared `false`: no adapter declares `hook`,
|
|
4076
|
+
// and a partial join keeps what the host declared.
|
|
4077
|
+
if (joined.cacheFromHooks && joined.complete && manifest.host && typeof manifest.host === 'object') {
|
|
4078
|
+
manifest.host.cacheAccounting = 'hook'
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
for (const { spawned, agent } of joined.joined) {
|
|
4082
|
+
const verdict = modelSubstitution(spawned.model, { servedModels: agent.models })
|
|
4083
|
+
if (verdict.substituted === true) {
|
|
4084
|
+
manifest.banners.push(modelSubstitutedBanner(spawned.label, spawned.model, verdict.ran))
|
|
4085
|
+
out.substitutions++
|
|
4086
|
+
}
|
|
4087
|
+
out.results.push({
|
|
4088
|
+
type: 'agent-result',
|
|
4089
|
+
label: spawned.label,
|
|
4090
|
+
kind: spawned.kind,
|
|
4091
|
+
agentId: agent.agentId,
|
|
4092
|
+
source: 'transcript',
|
|
4093
|
+
numTurns: agent.turns,
|
|
4094
|
+
modelRouted: spawned.model || undefined,
|
|
4095
|
+
servedModels: agent.models,
|
|
4096
|
+
modelScope: verdict.scope,
|
|
4097
|
+
substituted: verdict.substituted,
|
|
4098
|
+
usage: agent.usage
|
|
4099
|
+
})
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
const requests = joined.permissions.filter(p => p && p.kind === 'request')
|
|
4103
|
+
const denials = joined.permissions.filter(p => p && p.kind === 'denied')
|
|
4104
|
+
out.permissionPrompts = requests.length
|
|
4105
|
+
out.autoModeDenials = denials.length
|
|
4106
|
+
if (requests.length) manifest.banners.push(`PERMISSION PROMPTS DURING RUN: ${requests.length}`)
|
|
4107
|
+
if (denials.length) {
|
|
4108
|
+
const tools = [...new Set(denials.map(p => p.tool).filter(t => typeof t === 'string' && t))]
|
|
4109
|
+
manifest.banners.push(`AUTO MODE DENIED ${denials.length} TOOL CALLS${tools.length ? ` (${tools.join(', ')})` : ''}`)
|
|
4110
|
+
}
|
|
4111
|
+
return out
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
/**
|
|
4115
|
+
* The spend rows with the join's cache figures where the host observed none
|
|
4116
|
+
* (design D5's precedence: the host's own figure, then the join, then what the
|
|
4117
|
+
* results carried). A wave the join could not complete is recorded unknown.
|
|
4118
|
+
*/
|
|
4119
|
+
function fillJoinedCache(rows, cacheByWave) {
|
|
4120
|
+
return rows.map(row => {
|
|
4121
|
+
if (!row || !Number.isInteger(row.wave) || !cacheByWave.has(row.wave)) return row
|
|
4122
|
+
if (Number.isFinite(row.cacheReadInputTokens)) return row
|
|
4123
|
+
return { ...row, ...cacheByWave.get(row.wave) }
|
|
4124
|
+
})
|
|
4125
|
+
}
|
|
4126
|
+
|
|
3034
4127
|
/**
|
|
3035
4128
|
* Attempt the close's push, or decide not to. Returns what `formatRunSummary`
|
|
3036
4129
|
* takes as its `push` input: `null` when no push was attempted at all, or
|