@renzrollon/interlock 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +142 -0
- package/bin/interlock +413 -65
- 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 +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- 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 +153 -45
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +83 -13
- 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 +1193 -85
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +285 -83
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
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)
|
|
@@ -1641,10 +2129,25 @@ export function claimDerivedBanner() {
|
|
|
1641
2129
|
* three tasks has told us nothing about the third — and "nothing" read as
|
|
1642
2130
|
* success is how a task silently ships unimplemented. `not-attempted` is the one
|
|
1643
2131
|
* outcome that is neither: it is reported, ticked nowhere, and counted nowhere.
|
|
2132
|
+
*
|
|
2133
|
+
* A result that is absent entirely is a lost agent — a crash, a timeout, or a
|
|
2134
|
+
* briefing the agent never acknowledged — not an agent that filed a bad report.
|
|
2135
|
+
* On a multi-task lane that costs one failure, charged to the first task, and
|
|
2136
|
+
* the rest are `not-attempted`. Charging every task would halt a chain on one
|
|
2137
|
+
* miss. A single-task lane is one failure either way, and stays that.
|
|
1644
2138
|
*/
|
|
1645
2139
|
export function laneOutcomes(lane, result) {
|
|
1646
2140
|
const failAll = error => lane.map(t => ({ id: t.id, outcome: 'failed', error, handoff: null }))
|
|
1647
|
-
if (!result)
|
|
2141
|
+
if (!result) {
|
|
2142
|
+
if (lane.length >= 2) {
|
|
2143
|
+
return lane.map((t, i) =>
|
|
2144
|
+
i === 0
|
|
2145
|
+
? { id: t.id, outcome: 'failed', error: 'agent returned no result', handoff: null }
|
|
2146
|
+
: { id: t.id, outcome: 'not-attempted' }
|
|
2147
|
+
)
|
|
2148
|
+
}
|
|
2149
|
+
return failAll('agent returned no result')
|
|
2150
|
+
}
|
|
1648
2151
|
|
|
1649
2152
|
if (lane.length === 1) {
|
|
1650
2153
|
const [only] = lane
|
|
@@ -1696,6 +2199,110 @@ export function laneOutcomes(lane, result) {
|
|
|
1696
2199
|
|
|
1697
2200
|
// --- run start (design D4, D2) ----------------------------------------------
|
|
1698
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
|
+
|
|
1699
2306
|
/**
|
|
1700
2307
|
* Begin a run: write the manifest, validate the change, and either adopt a
|
|
1701
2308
|
* reusable plan or ask for a classification.
|
|
@@ -1708,13 +2315,132 @@ export function laneOutcomes(lane, result) {
|
|
|
1708
2315
|
* run; wrongly reusing would run the wrong plan.
|
|
1709
2316
|
*
|
|
1710
2317
|
* @param {{root: string, deps: object, warn: (m: string) => void}} ctx
|
|
1711
|
-
* @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.
|
|
1712
2323
|
*/
|
|
1713
|
-
export function runStart(ctx, { change, flags, host, hostCapabilities }) {
|
|
2324
|
+
export function runStart(ctx, { change, flags, host, hostCapabilities, hostEnv, stateHome }) {
|
|
1714
2325
|
const { root } = ctx
|
|
1715
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)
|
|
1716
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)
|
|
1717
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
|
+
}
|
|
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
|
|
1718
2444
|
const capped = countStep(root, manifest)
|
|
1719
2445
|
if (capped) return recordThen(root, manifest, capped)
|
|
1720
2446
|
|
|
@@ -1776,6 +2502,7 @@ export function runStart(ctx, { change, flags, host, hostCapabilities }) {
|
|
|
1776
2502
|
label: 'plan-waves',
|
|
1777
2503
|
kind: 'planner',
|
|
1778
2504
|
model: null,
|
|
2505
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
1779
2506
|
effort: null,
|
|
1780
2507
|
schema: PLANNER_SCHEMA,
|
|
1781
2508
|
prompt: assemblePlannerPrompt({ change, classifiedPath: CLASSIFIED_PATH })
|
|
@@ -1802,8 +2529,15 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
1802
2529
|
}
|
|
1803
2530
|
writeState(root, state)
|
|
1804
2531
|
manifest.runId = typeof state.runId === 'string' ? state.runId : null
|
|
1805
|
-
|
|
1806
|
-
|
|
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), {
|
|
1807
2541
|
runId: manifest.runId || undefined,
|
|
1808
2542
|
change: manifest.change,
|
|
1809
2543
|
type: 'run-start',
|
|
@@ -1813,14 +2547,20 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
1813
2547
|
// `undefined`, not a placeholder: a host with no session records absent, and
|
|
1814
2548
|
// the run is unaffected either way — nothing downstream reads this to decide
|
|
1815
2549
|
// anything, and `run-log check` does not require it.
|
|
1816
|
-
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)
|
|
1817
2557
|
})
|
|
1818
2558
|
if (!started.written) {
|
|
1819
2559
|
ctx.warn(`run-start not recorded: ${started.reason}`)
|
|
1820
2560
|
return trajectoryHaltStep('run-start', started.reason)
|
|
1821
2561
|
}
|
|
1822
2562
|
|
|
1823
|
-
const created = ctx.deps.logWaveMutation(
|
|
2563
|
+
const created = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
1824
2564
|
source: 'create',
|
|
1825
2565
|
change: manifest.change,
|
|
1826
2566
|
state
|
|
@@ -1834,11 +2574,43 @@ function adoptPlan(ctx, manifest, plan) {
|
|
|
1834
2574
|
// the lane, by wave-state). Its verdict is read for the same reason every
|
|
1835
2575
|
// other append's is: `wave-state next` exits 1 on this exact boolean, and the
|
|
1836
2576
|
// live path owes the trajectory the same fatality.
|
|
1837
|
-
if (ctx
|
|
2577
|
+
if (!logLaneSpawns(ctx, manifest, state, step, 'next')) {
|
|
1838
2578
|
ctx.warn('an implementer agent-spawn was not recorded on run start')
|
|
1839
2579
|
return trajectoryHaltStep('agent-spawn', LANE_SPAWN_APPEND_FAILED)
|
|
1840
2580
|
}
|
|
1841
|
-
|
|
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
|
+
)
|
|
1842
2614
|
}
|
|
1843
2615
|
|
|
1844
2616
|
// --- run classified (design D7) ---------------------------------------------
|
|
@@ -1922,7 +2694,7 @@ export function resolveRedWave({ tddMode, redSection, tasks }) {
|
|
|
1922
2694
|
export function runClassified(ctx, { classifiedPath }) {
|
|
1923
2695
|
const { root } = ctx
|
|
1924
2696
|
const manifest = readManifest(root)
|
|
1925
|
-
if (!manifest) return noManifest()
|
|
2697
|
+
if (!manifest) return noManifest(ctx)
|
|
1926
2698
|
const capped = countStep(root, manifest)
|
|
1927
2699
|
if (capped) return recordThen(root, manifest, capped)
|
|
1928
2700
|
|
|
@@ -2019,6 +2791,42 @@ export function runClassified(ctx, { classifiedPath }) {
|
|
|
2019
2791
|
|
|
2020
2792
|
// --- run record-batch (design D5) -------------------------------------------
|
|
2021
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
|
+
|
|
2022
2830
|
/**
|
|
2023
2831
|
* Fold, adjudicate, record and tick — one CLI call where the loop used to spend
|
|
2024
2832
|
* three agent turns.
|
|
@@ -2027,10 +2835,10 @@ export function runClassified(ctx, { classifiedPath }) {
|
|
|
2027
2835
|
* named rather than removed: folding a partial write from a lane that stopped
|
|
2028
2836
|
* mid-way is exactly the silent-overwrite defect the merge policy refuses.
|
|
2029
2837
|
*/
|
|
2030
|
-
export function runRecordBatch(ctx, { results }) {
|
|
2838
|
+
export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
2031
2839
|
const { root } = ctx
|
|
2032
2840
|
const manifest = readManifest(root)
|
|
2033
|
-
if (!manifest) return noManifest()
|
|
2841
|
+
if (!manifest) return noManifest(ctx)
|
|
2034
2842
|
const capped = countStep(root, manifest)
|
|
2035
2843
|
if (capped) return recordThen(root, manifest, capped)
|
|
2036
2844
|
|
|
@@ -2052,7 +2860,12 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2052
2860
|
const isolate = manifest.flags.isolateWaves === true
|
|
2053
2861
|
const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
|
|
2054
2862
|
|
|
2055
|
-
|
|
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
|
+
}
|
|
2056
2869
|
|
|
2057
2870
|
lanes.forEach((lane, i) => {
|
|
2058
2871
|
const outcomes = laneOutcomes(lane, results[i])
|
|
@@ -2101,7 +2914,8 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2101
2914
|
}
|
|
2102
2915
|
|
|
2103
2916
|
// Fold this batch's successful lanes back into the shared tree before moving
|
|
2104
|
-
// 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.
|
|
2105
2919
|
if (isolate && laneFoldCandidates.length) {
|
|
2106
2920
|
// The base the lanes actually forked from, captured at dispatch and carried
|
|
2107
2921
|
// on the manifest across the driver round trip. `pending` is a pure
|
|
@@ -2117,6 +2931,27 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2117
2931
|
`fold, so lane diffs are taken against the post-batch HEAD (${base || 'unreadable'})`
|
|
2118
2932
|
)
|
|
2119
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
|
+
}
|
|
2120
2955
|
const merged = base ? ctx.deps.runMergeLanes(root, laneFoldCandidates, base) : null
|
|
2121
2956
|
if (Array.isArray(merged && merged.cleanupWarnings) && merged.cleanupWarnings.length) {
|
|
2122
2957
|
banners.push(
|
|
@@ -2161,7 +2996,7 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2161
2996
|
changedPaths: ctx.deps.observedChangedPaths(root)
|
|
2162
2997
|
})
|
|
2163
2998
|
writeState(root, after)
|
|
2164
|
-
const recorded = ctx.deps.logWaveMutation(
|
|
2999
|
+
const recorded = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
2165
3000
|
source: 'record-batch',
|
|
2166
3001
|
change: manifest.change,
|
|
2167
3002
|
state: after
|
|
@@ -2183,13 +3018,7 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2183
3018
|
// Before the tick and before the commit, exactly like the wave-action above:
|
|
2184
3019
|
// a batch whose implementer spawns went unrecorded is a batch nobody can
|
|
2185
3020
|
// attribute, and `emitRecordedState` already exits 1 on this same boolean.
|
|
2186
|
-
|
|
2187
|
-
state: after,
|
|
2188
|
-
change: manifest.change,
|
|
2189
|
-
step: nextRaw,
|
|
2190
|
-
source: 'record-batch'
|
|
2191
|
-
})
|
|
2192
|
-
if (laneSpawnsLogged === false) {
|
|
3021
|
+
if (!logLaneSpawns(ctx, manifest, after, nextRaw, 'record-batch')) {
|
|
2193
3022
|
ctx.warn('an implementer agent-spawn was not recorded on record-batch')
|
|
2194
3023
|
manifest.banners.push(...banners)
|
|
2195
3024
|
return finishStep(
|
|
@@ -2252,15 +3081,18 @@ export function runRecordBatch(ctx, { results }) {
|
|
|
2252
3081
|
* reports exit codes and counts; whether the run may continue is not its
|
|
2253
3082
|
* question, and both drivers used to let it answer.
|
|
2254
3083
|
*/
|
|
2255
|
-
export function runJudge(ctx, { context, results }) {
|
|
3084
|
+
export function runJudge(ctx, { context, results, hostRecords }) {
|
|
2256
3085
|
const { root } = ctx
|
|
2257
3086
|
const manifest = readManifest(root)
|
|
2258
|
-
if (!manifest) return noManifest()
|
|
3087
|
+
if (!manifest) return noManifest(ctx)
|
|
2259
3088
|
const capped = countStep(root, manifest)
|
|
2260
3089
|
if (capped) return recordThen(root, manifest, capped)
|
|
2261
3090
|
|
|
2262
3091
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
2263
|
-
|
|
3092
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
3093
|
+
if (observed.failure) {
|
|
3094
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
3095
|
+
}
|
|
2264
3096
|
|
|
2265
3097
|
const state = readState(root)
|
|
2266
3098
|
const plan = readJson(join(root, WORK_DIR, `vplan-${context}.json`))
|
|
@@ -2271,10 +3103,34 @@ export function runJudge(ctx, { context, results }) {
|
|
|
2271
3103
|
// process — the next one — can see how long the agent between them took. An
|
|
2272
3104
|
// unparseable or negative mark contributes nothing rather than a wrong number,
|
|
2273
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)
|
|
2274
3115
|
if (manifest.verifyStartedAt) {
|
|
2275
|
-
const
|
|
2276
|
-
|
|
2277
|
-
|
|
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
|
|
2278
3134
|
}
|
|
2279
3135
|
manifest.verifyStartedAt = null
|
|
2280
3136
|
}
|
|
@@ -2345,7 +3201,7 @@ export function runJudge(ctx, { context, results }) {
|
|
|
2345
3201
|
}
|
|
2346
3202
|
const after = recordVerifyResult(state, record)
|
|
2347
3203
|
writeState(root, after)
|
|
2348
|
-
const verified = ctx.deps.logWaveMutation(
|
|
3204
|
+
const verified = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
2349
3205
|
source: 'record-verify',
|
|
2350
3206
|
change: manifest.change,
|
|
2351
3207
|
state: after
|
|
@@ -2360,6 +3216,11 @@ export function runJudge(ctx, { context, results }) {
|
|
|
2360
3216
|
trajectoryHaltStep('wave-state record-verify', waveAppendReason(verified), { banners })
|
|
2361
3217
|
)
|
|
2362
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
|
+
}
|
|
2363
3224
|
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest, { banners }))
|
|
2364
3225
|
}
|
|
2365
3226
|
|
|
@@ -2444,6 +3305,7 @@ function handoffStep(ctx, manifest, banners = []) {
|
|
|
2444
3305
|
label: 'handoff',
|
|
2445
3306
|
kind: 'handoff',
|
|
2446
3307
|
model: null,
|
|
3308
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
2447
3309
|
effort: null,
|
|
2448
3310
|
schema: HANDOFF_RESULT_SCHEMA,
|
|
2449
3311
|
prompt: assembleHandoffPrompt({
|
|
@@ -2480,6 +3342,7 @@ function commitStep(ctx, manifest, banners = []) {
|
|
|
2480
3342
|
label: 'commit',
|
|
2481
3343
|
kind: 'commit',
|
|
2482
3344
|
model: null,
|
|
3345
|
+
// effort-routing: "Control-plane and prose spawns SHALL inherit the host's effort".
|
|
2483
3346
|
effort: null,
|
|
2484
3347
|
schema: COMMIT_SCHEMA,
|
|
2485
3348
|
prompt: assembleCommitPrompt({ change: manifest.change, stageIndex: ++manifest.stageIndex })
|
|
@@ -2496,15 +3359,18 @@ function commitStep(ctx, manifest, banners = []) {
|
|
|
2496
3359
|
* with no handoff step never calls it: `run judge --context final` emits the
|
|
2497
3360
|
* commit directly.
|
|
2498
3361
|
*/
|
|
2499
|
-
export function runCommit(ctx, { results }) {
|
|
3362
|
+
export function runCommit(ctx, { results, hostRecords }) {
|
|
2500
3363
|
const { root } = ctx
|
|
2501
3364
|
const manifest = readManifest(root)
|
|
2502
|
-
if (!manifest) return noManifest()
|
|
3365
|
+
if (!manifest) return noManifest(ctx)
|
|
2503
3366
|
const capped = countStep(root, manifest)
|
|
2504
3367
|
if (capped) return recordThen(root, manifest, capped)
|
|
2505
3368
|
|
|
2506
3369
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
2507
|
-
|
|
3370
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
3371
|
+
if (observed.failure) {
|
|
3372
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
3373
|
+
}
|
|
2508
3374
|
|
|
2509
3375
|
const reported = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
2510
3376
|
const count = value => (Number.isInteger(value) ? value : undefined)
|
|
@@ -2554,7 +3420,7 @@ function flattenVerifyResults(results) {
|
|
|
2554
3420
|
export function runVerifyFinal(ctx) {
|
|
2555
3421
|
const { root } = ctx
|
|
2556
3422
|
const manifest = readManifest(root)
|
|
2557
|
-
if (!manifest) return noManifest()
|
|
3423
|
+
if (!manifest) return noManifest(ctx)
|
|
2558
3424
|
const capped = countStep(root, manifest)
|
|
2559
3425
|
if (capped) return recordThen(root, manifest, capped)
|
|
2560
3426
|
return finishStep(ctx, manifest, finalVerifyStep(ctx, manifest))
|
|
@@ -2563,15 +3429,18 @@ export function runVerifyFinal(ctx) {
|
|
|
2563
3429
|
// --- run replan -------------------------------------------------------------
|
|
2564
3430
|
|
|
2565
3431
|
/** Apply a revision the replan step produced, or carry on with the next step. */
|
|
2566
|
-
export function runReplan(ctx, { results }) {
|
|
3432
|
+
export function runReplan(ctx, { results, hostRecords }) {
|
|
2567
3433
|
const { root } = ctx
|
|
2568
3434
|
const manifest = readManifest(root)
|
|
2569
|
-
if (!manifest) return noManifest()
|
|
3435
|
+
if (!manifest) return noManifest(ctx)
|
|
2570
3436
|
const capped = countStep(root, manifest)
|
|
2571
3437
|
if (capped) return recordThen(root, manifest, capped)
|
|
2572
3438
|
|
|
2573
3439
|
// Outside the waves, but not outside the run's spend (design D9).
|
|
2574
|
-
|
|
3440
|
+
const observed = recordSpawnObservations(ctx, manifest, { results, hostRecords })
|
|
3441
|
+
if (observed.failure) {
|
|
3442
|
+
return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
|
|
3443
|
+
}
|
|
2575
3444
|
|
|
2576
3445
|
const state = readState(root)
|
|
2577
3446
|
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
@@ -2581,7 +3450,12 @@ export function runReplan(ctx, { results }) {
|
|
|
2581
3450
|
if (!revised || !Array.isArray(groups)) {
|
|
2582
3451
|
// Nothing to revise is the ordinary outcome, and re-reading the state is
|
|
2583
3452
|
// how the loop moves past a replan offer it declined.
|
|
2584
|
-
|
|
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))
|
|
2585
3459
|
}
|
|
2586
3460
|
|
|
2587
3461
|
let after
|
|
@@ -2591,7 +3465,7 @@ export function runReplan(ctx, { results }) {
|
|
|
2591
3465
|
return finishStep(ctx, manifest, haltStep(`replan rejected: ${(err && err.message) || String(err)}`))
|
|
2592
3466
|
}
|
|
2593
3467
|
writeState(root, after)
|
|
2594
|
-
const replanned = ctx.deps.logWaveMutation(
|
|
3468
|
+
const replanned = ctx.deps.logWaveMutation(stateHomeOf(ctx, manifest), {
|
|
2595
3469
|
source: 'replan',
|
|
2596
3470
|
change: manifest.change,
|
|
2597
3471
|
state: after
|
|
@@ -2601,6 +3475,10 @@ export function runReplan(ctx, { results }) {
|
|
|
2601
3475
|
ctx.warn('the wave-state trajectory append failed on replan')
|
|
2602
3476
|
return finishStep(ctx, manifest, trajectoryHaltStep('wave-state replan', waveAppendReason(replanned)))
|
|
2603
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
|
+
}
|
|
2604
3482
|
return finishStep(ctx, manifest, decorate(ctx, nextRaw, manifest))
|
|
2605
3483
|
}
|
|
2606
3484
|
|
|
@@ -2610,22 +3488,50 @@ export function runReplan(ctx, { results }) {
|
|
|
2610
3488
|
export function runNext(ctx) {
|
|
2611
3489
|
const { root } = ctx
|
|
2612
3490
|
const manifest = readManifest(root)
|
|
2613
|
-
if (!manifest) return noManifest()
|
|
3491
|
+
if (!manifest) return noManifest(ctx)
|
|
2614
3492
|
const capped = countStep(root, manifest)
|
|
2615
3493
|
if (capped) return recordThen(root, manifest, capped)
|
|
2616
3494
|
const state = readState(root)
|
|
2617
3495
|
if (!state) return finishStep(ctx, manifest, haltStep('the run state could not be read'))
|
|
2618
|
-
|
|
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))
|
|
2619
3504
|
}
|
|
2620
3505
|
|
|
2621
3506
|
// --- helpers used by every subcommand ---------------------------------------
|
|
2622
3507
|
|
|
2623
|
-
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
|
+
}
|
|
2624
3516
|
return haltStep(
|
|
2625
3517
|
`no run manifest at ${MANIFEST_PATH} — a run subcommand was reached without \`interlock run start\``
|
|
2626
3518
|
)
|
|
2627
3519
|
}
|
|
2628
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
|
+
|
|
2629
3535
|
/**
|
|
2630
3536
|
* Persist what this step observed and record the continuation it names.
|
|
2631
3537
|
*
|
|
@@ -2634,6 +3540,8 @@ function noManifest() {
|
|
|
2634
3540
|
* process — each `run` subcommand is its own.
|
|
2635
3541
|
*/
|
|
2636
3542
|
function finishStep(ctx, manifest, step) {
|
|
3543
|
+
carryRaisedBanners(manifest, step)
|
|
3544
|
+
recordDispatched(manifest, step)
|
|
2637
3545
|
const failure = logSpawns(ctx, manifest, step)
|
|
2638
3546
|
// An agent-spawn nobody recorded is a gap in the trajectory, and the step it
|
|
2639
3547
|
// belongs to must not run. A step that was ALREADY halting keeps its own
|
|
@@ -2648,6 +3556,48 @@ function finishStep(ctx, manifest, step) {
|
|
|
2648
3556
|
return recordThen(ctx.root, manifest, final)
|
|
2649
3557
|
}
|
|
2650
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
|
+
|
|
2651
3601
|
// --- run close (design D10) -------------------------------------------------
|
|
2652
3602
|
|
|
2653
3603
|
/**
|
|
@@ -2664,7 +3614,15 @@ function finishStep(ctx, manifest, step) {
|
|
|
2664
3614
|
*/
|
|
2665
3615
|
export async function runClose(
|
|
2666
3616
|
ctx,
|
|
2667
|
-
{
|
|
3617
|
+
{
|
|
3618
|
+
results = [],
|
|
3619
|
+
halt = null,
|
|
3620
|
+
hostBanners = [],
|
|
3621
|
+
hostObserved = null,
|
|
3622
|
+
hostRecords = [],
|
|
3623
|
+
notify = false,
|
|
3624
|
+
env = process.env
|
|
3625
|
+
} = {}
|
|
2668
3626
|
) {
|
|
2669
3627
|
const { root } = ctx
|
|
2670
3628
|
const absRoot = resolve(root)
|
|
@@ -2682,10 +3640,14 @@ export async function runClose(
|
|
|
2682
3640
|
// The change name is unknown here — there is no manifest to read it from —
|
|
2683
3641
|
// so the message carries the headline and the absent run id alone.
|
|
2684
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
|
|
2685
3646
|
const rows = [
|
|
2686
3647
|
' run: none — the run halted before a plan was adopted',
|
|
2687
3648
|
` project: ${slug}`,
|
|
2688
|
-
` cwd: ${absRoot}
|
|
3649
|
+
` cwd: ${absRoot}`,
|
|
3650
|
+
...(home !== absRoot ? [` state home: ${home}`] : [])
|
|
2689
3651
|
]
|
|
2690
3652
|
if (push && push.sent !== undefined) {
|
|
2691
3653
|
rows.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
|
|
@@ -2699,13 +3661,19 @@ export async function runClose(
|
|
|
2699
3661
|
banners: Array.isArray(hostBanners) ? hostBanners : [],
|
|
2700
3662
|
summary:
|
|
2701
3663
|
`${headline}\n\n` +
|
|
2702
|
-
|
|
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`) +
|
|
2703
3668
|
rows.join('\n')
|
|
2704
3669
|
}
|
|
2705
3670
|
}
|
|
2706
3671
|
|
|
2707
3672
|
const haltReason = typeof halt === 'string' && halt.trim() ? halt.trim() : manifest.halted || null
|
|
2708
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)
|
|
2709
3677
|
|
|
2710
3678
|
// The commit result, when the close is the continuation of a commit step.
|
|
2711
3679
|
const committed = results && results[0] && typeof results[0] === 'object' ? results[0] : null
|
|
@@ -2739,6 +3707,16 @@ export async function runClose(
|
|
|
2739
3707
|
// any, because the runtime's counter sees turns this process never does.
|
|
2740
3708
|
const counted = summarizeUsage(manifest)
|
|
2741
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 })
|
|
2742
3720
|
const hostTotal =
|
|
2743
3721
|
typeof observed.outputTokens === 'number' || observed.outputTokens === null
|
|
2744
3722
|
? observed.outputTokens
|
|
@@ -2774,7 +3752,8 @@ export async function runClose(
|
|
|
2774
3752
|
if (
|
|
2775
3753
|
hostCacheRead === undefined &&
|
|
2776
3754
|
hostCacheCreation === undefined &&
|
|
2777
|
-
hostCapability(manifest, 'cacheAccounting') === false
|
|
3755
|
+
hostCapability(manifest, 'cacheAccounting') === false &&
|
|
3756
|
+
closeObserved.usable === 0
|
|
2778
3757
|
) {
|
|
2779
3758
|
manifest.notes.push(
|
|
2780
3759
|
`CACHE ACCOUNTING NOT REPORTED: the ${hostId(manifest) || 'run'} host's runtime exposes no ` +
|
|
@@ -2783,13 +3762,23 @@ export async function runClose(
|
|
|
2783
3762
|
)
|
|
2784
3763
|
}
|
|
2785
3764
|
|
|
3765
|
+
const useJoin = joined.present && joined.cacheFromHooks && joined.briefed > 0
|
|
2786
3766
|
const summary = {
|
|
2787
3767
|
waves: manifest.waves || [],
|
|
2788
3768
|
spend: hostSpend || counted.spend,
|
|
2789
3769
|
outputTokens: hostTotal !== undefined ? hostTotal : counted.outputTokens,
|
|
2790
|
-
cacheReadInputTokens:
|
|
3770
|
+
cacheReadInputTokens:
|
|
3771
|
+
hostCacheRead !== undefined
|
|
3772
|
+
? hostCacheRead
|
|
3773
|
+
: useJoin
|
|
3774
|
+
? joined.runCache.cacheReadInputTokens
|
|
3775
|
+
: counted.cacheReadInputTokens,
|
|
2791
3776
|
cacheCreationInputTokens:
|
|
2792
|
-
hostCacheCreation !== undefined
|
|
3777
|
+
hostCacheCreation !== undefined
|
|
3778
|
+
? hostCacheCreation
|
|
3779
|
+
: useJoin
|
|
3780
|
+
? joined.runCache.cacheCreationInputTokens
|
|
3781
|
+
: counted.cacheCreationInputTokens,
|
|
2793
3782
|
review: tail.review,
|
|
2794
3783
|
remediation: tail.remediation,
|
|
2795
3784
|
remediationRounds: tail.remediationRounds,
|
|
@@ -2802,6 +3791,8 @@ export async function runClose(
|
|
|
2802
3791
|
commitSkipped: manifest.commitSkipped === true,
|
|
2803
3792
|
paths: readPathSets(root, manifest)
|
|
2804
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)
|
|
2805
3796
|
|
|
2806
3797
|
const degradations = degradationLines({
|
|
2807
3798
|
banners: manifest.banners || [],
|
|
@@ -2865,19 +3856,36 @@ export async function runClose(
|
|
|
2865
3856
|
? fingerprint.hash
|
|
2866
3857
|
: undefined
|
|
2867
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)
|
|
2868
3864
|
const receipt = buildReceipt({
|
|
2869
3865
|
change: manifest.change,
|
|
2870
3866
|
summary,
|
|
2871
3867
|
degradations,
|
|
2872
3868
|
planFingerprint,
|
|
2873
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,
|
|
2874
3878
|
host: manifest.host
|
|
2875
3879
|
? {
|
|
2876
3880
|
id: hostId(manifest),
|
|
2877
3881
|
billing: hostCapability(manifest, 'billing'),
|
|
2878
3882
|
hooks: hostCapability(manifest, 'hooks'),
|
|
2879
3883
|
usage: hostCapability(manifest, 'usage'),
|
|
2880
|
-
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
|
|
2881
3889
|
}
|
|
2882
3890
|
: undefined
|
|
2883
3891
|
})
|
|
@@ -2888,8 +3896,24 @@ export async function runClose(
|
|
|
2888
3896
|
// degradation and a non-zero exit, exactly what an unreconstructable run gets
|
|
2889
3897
|
// below. Silence would leave a run that reads complete and cannot be replayed.
|
|
2890
3898
|
let trajectoryComplete = true
|
|
3899
|
+
if (committerObserved.failure) {
|
|
3900
|
+
trajectoryComplete = false
|
|
3901
|
+
degradations.push(`TRAJECTORY APPEND FAILED: agent-result ${committerObserved.failure}`)
|
|
3902
|
+
}
|
|
2891
3903
|
if (manifest.runId) {
|
|
2892
|
-
|
|
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, {
|
|
2893
3917
|
...receipt,
|
|
2894
3918
|
runId: manifest.runId,
|
|
2895
3919
|
change: manifest.change
|
|
@@ -2900,7 +3924,7 @@ export async function runClose(
|
|
|
2900
3924
|
degradations.push(`TRAJECTORY APPEND FAILED: run-receipt: ${written.reason}`)
|
|
2901
3925
|
}
|
|
2902
3926
|
|
|
2903
|
-
const terminal = appendRunLogEvent(
|
|
3927
|
+
const terminal = appendRunLogEvent(home, {
|
|
2904
3928
|
runId: manifest.runId,
|
|
2905
3929
|
change: manifest.change,
|
|
2906
3930
|
type: haltReason ? 'run-halt' : 'run-complete',
|
|
@@ -2916,7 +3940,7 @@ export async function runClose(
|
|
|
2916
3940
|
|
|
2917
3941
|
// The outcome corpus. Its own write failure is reported and never touches the
|
|
2918
3942
|
// exit code: losing a corpus line must not fail the run that produced it.
|
|
2919
|
-
const outcome = appendOutcome(
|
|
3943
|
+
const outcome = appendOutcome(home, {
|
|
2920
3944
|
change: manifest.change,
|
|
2921
3945
|
mode: manifest.flags.mode || 'checkpoint',
|
|
2922
3946
|
observed: observedFromReceipt(receipt)
|
|
@@ -2928,7 +3952,7 @@ export async function runClose(
|
|
|
2928
3952
|
// this repository deliberately keeps different per corpus.
|
|
2929
3953
|
let reconstructable = true
|
|
2930
3954
|
if (manifest.runId) {
|
|
2931
|
-
const check = checkRunLog(
|
|
3955
|
+
const check = checkRunLog(home, manifest.runId)
|
|
2932
3956
|
reconstructable = check.ok !== false
|
|
2933
3957
|
if (!reconstructable) {
|
|
2934
3958
|
degradations.push(
|
|
@@ -2950,7 +3974,7 @@ export async function runClose(
|
|
|
2950
3974
|
if (manifest.flags.strict === true && manifest.review) {
|
|
2951
3975
|
const blockers = survivingBlockers(manifest)
|
|
2952
3976
|
try {
|
|
2953
|
-
recordAutonomy('review-code', { blockers }, { root })
|
|
3977
|
+
recordAutonomy('review-code', { blockers }, { root: home })
|
|
2954
3978
|
manifest.autonomy = { path: 'review-code', blockers }
|
|
2955
3979
|
} catch (err) {
|
|
2956
3980
|
degradations.push(
|
|
@@ -2974,7 +3998,7 @@ export async function runClose(
|
|
|
2974
3998
|
// was hand-edited, cannot change what a later run does.
|
|
2975
3999
|
let resumeCard = null
|
|
2976
4000
|
if (haltReason) {
|
|
2977
|
-
const written = writeResumeCard(
|
|
4001
|
+
const written = writeResumeCard(home, {
|
|
2978
4002
|
change: manifest.change,
|
|
2979
4003
|
runId: manifest.runId,
|
|
2980
4004
|
halted: haltReason,
|
|
@@ -2984,7 +4008,9 @@ export async function runClose(
|
|
|
2984
4008
|
waves: manifest.waves || [],
|
|
2985
4009
|
degradations,
|
|
2986
4010
|
projectSlug: slug,
|
|
2987
|
-
cwd: absRoot
|
|
4011
|
+
cwd: absRoot,
|
|
4012
|
+
stateHome: home,
|
|
4013
|
+
laneSessions: Array.isArray(manifest.laneSessions) ? manifest.laneSessions : []
|
|
2988
4014
|
})
|
|
2989
4015
|
if (written.written) resumeCard = written.path
|
|
2990
4016
|
else degradations.push(`RESUME CARD NOT WRITTEN: ${written.reason}`)
|
|
@@ -3008,6 +4034,7 @@ export async function runClose(
|
|
|
3008
4034
|
degradations,
|
|
3009
4035
|
runId: manifest.runId,
|
|
3010
4036
|
root: absRoot,
|
|
4037
|
+
stateHome: home,
|
|
3011
4038
|
projectSlug: slug,
|
|
3012
4039
|
push,
|
|
3013
4040
|
unarchived,
|
|
@@ -3016,6 +4043,87 @@ export async function runClose(
|
|
|
3016
4043
|
}
|
|
3017
4044
|
}
|
|
3018
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
|
+
|
|
3019
4127
|
/**
|
|
3020
4128
|
* Attempt the close's push, or decide not to. Returns what `formatRunSummary`
|
|
3021
4129
|
* takes as its `push` input: `null` when no push was attempted at all, or
|