@renzrollon/interlock 1.0.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 +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/waves.mjs
ADDED
|
@@ -0,0 +1,2661 @@
|
|
|
1
|
+
// Deterministic wave planner.
|
|
2
|
+
//
|
|
3
|
+
// Turns a classified task list into an execution plan: which tasks run in which
|
|
4
|
+
// wave, in which parallel batch, on which model. Classification itself is a
|
|
5
|
+
// judgement call and stays with the model; everything downstream of it is
|
|
6
|
+
// mechanical and lives here.
|
|
7
|
+
//
|
|
8
|
+
// Two rules exist specifically to stop the classifier from harming the run:
|
|
9
|
+
//
|
|
10
|
+
// 1. Model clamp. Classifiers reliably over-assign `opus` to anything that
|
|
11
|
+
// touches several files. Sonnet handles tiers 1-4; opus survives only on a
|
|
12
|
+
// true tier-5 task. The clamp is applied after classification so the model
|
|
13
|
+
// cannot escalate itself, and every clamp is reported.
|
|
14
|
+
//
|
|
15
|
+
// 2. Fan-out cap. A wave is a set of independent tasks, so the temptation is
|
|
16
|
+
// to spawn all of them at once. The original implementation did exactly
|
|
17
|
+
// that, which meant a classifier that put 30 tasks in group 1 spawned 30
|
|
18
|
+
// parallel agents. Waves are chunked into batches of at most maxParallel.
|
|
19
|
+
//
|
|
20
|
+
// 3. Singleton fold. A classifier that mints a group per sequential slice of
|
|
21
|
+
// one file turns 22 tasks into 15 waves, and each of those waves still
|
|
22
|
+
// buys a record ping and — until the cap — an inter-wave verify. A 1-task
|
|
23
|
+
// wave is ordering, not a checkpoint, so it is folded onto the previous
|
|
24
|
+
// implementation wave as a later batch. Order survives; the checkpoint
|
|
25
|
+
// does not. Every fold is reported.
|
|
26
|
+
//
|
|
27
|
+
// 4. Dependency edges. A group is a barrier and a path collision is a lane,
|
|
28
|
+
// which left the classifier with no way to say "B needs A's output" when A
|
|
29
|
+
// and B edit different files — so it minted a whole new section, dragging
|
|
30
|
+
// every task independent of A along with it. `dependsOn` is that missing
|
|
31
|
+
// signal: a task names the earlier tasks whose output it needs, and the
|
|
32
|
+
// group is split into dependency LAYERS, each of which is a wave. The
|
|
33
|
+
// singleton fold below then reclaims the checkpoint, so a precise edge
|
|
34
|
+
// costs a later batch rather than an extra inter-wave verify. An edge is
|
|
35
|
+
// additive — it can only push a task later than the section model already
|
|
36
|
+
// put it — and a plan with no edges is planned exactly as it was before
|
|
37
|
+
// this existed.
|
|
38
|
+
//
|
|
39
|
+
// 5. Lanes. A batch holds LANES, not tasks: a lane is an ordered task list
|
|
40
|
+
// one agent executes start to finish. Tasks a path collision already
|
|
41
|
+
// forced to run one after another gain nothing from separate agents —
|
|
42
|
+
// isolating a task from the other edits to the file it is about to edit is
|
|
43
|
+
// not isolation — and each separate agent pays its own spawn prefix. So
|
|
44
|
+
// colliding tasks become one lane. Lanes in a batch are path-disjoint and
|
|
45
|
+
// run in parallel; `LANE_CAPS.byTier` bounds how much one interruption can
|
|
46
|
+
// discard, and a uniform override of 1 reproduces one agent per task.
|
|
47
|
+
//
|
|
48
|
+
// 6. Cohesion. Collision alone made every path-disjoint task its own agent,
|
|
49
|
+
// however small: seven tasks each writing one `evals/*/case.yaml` were
|
|
50
|
+
// seven spawns that then had to be told, by a hand-written eighth task,
|
|
51
|
+
// what convention to agree on. So after collision components form, the
|
|
52
|
+
// LOW-TIER ones in a layer pack greedily into shared lanes — next-fit,
|
|
53
|
+
// hardest component first, bounded by that lane's per-tier cap. Tier 4 and
|
|
54
|
+
// 5 work is excluded on purpose: cross-file pattern-following is where a
|
|
55
|
+
// fresh context per task still pays for itself. Every fold is reported.
|
|
56
|
+
//
|
|
57
|
+
// 7. Solo. A small change may be planned as ONE lane — every implementation
|
|
58
|
+
// task in section, layer and id order, then every test task — run by one
|
|
59
|
+
// opus agent inside the same verify-and-commit loop. The decision is made
|
|
60
|
+
// inside a published envelope (`SOLO.maxTasks`): a flag wins, then the
|
|
61
|
+
// classifier's recommendation if the change fits, then waves. The mode,
|
|
62
|
+
// its source and its reason are on the plan, because a run that silently
|
|
63
|
+
// changed its own shape is the failure the plan preview exists to remove.
|
|
64
|
+
//
|
|
65
|
+
// The second half of this file *executes* that plan — as a state machine, not a
|
|
66
|
+
// runner. The orchestration is moving into a dynamic-workflow script, which can
|
|
67
|
+
// import nothing and has no filesystem, shell or agent access of its own; only
|
|
68
|
+
// the agents it spawns do work. So the engine below never spawns an agent,
|
|
69
|
+
// never runs a command and never touches fs. It is handed the run state plus
|
|
70
|
+
// the result of whatever just happened, and answers one question: what next.
|
|
71
|
+
//
|
|
72
|
+
// Every transition returns a NEW state and leaves its input untouched. That is
|
|
73
|
+
// not stylistic. A resumed run is replayed through the same transitions from
|
|
74
|
+
// cached agent results, so an engine that mutated in place would answer
|
|
75
|
+
// differently on the replay than it did on the first pass. The returned state is
|
|
76
|
+
// deep-frozen and JSON-safe so the property stays honest rather than aspirational.
|
|
77
|
+
//
|
|
78
|
+
// The caps are not restated here — they are read from `lib/limits.mjs`, because
|
|
79
|
+
// a cap written down twice is a cap that drifts. What the failure policy in
|
|
80
|
+
// `skills/ship/SKILL.md` describes in prose, this file decides.
|
|
81
|
+
//
|
|
82
|
+
// Pure: no fs, no agent, no I/O, no clock.
|
|
83
|
+
// Exposed to skills as `interlock waves --classified <file>`.
|
|
84
|
+
|
|
85
|
+
import { randomUUID } from 'node:crypto'
|
|
86
|
+
|
|
87
|
+
import { LIMITS, EFFORT, LANE_CAPS, SOLO, clampParallel } from './limits.mjs'
|
|
88
|
+
import { diffIndex, locatorPath } from './locators.mjs'
|
|
89
|
+
import { canonicalizePath, isDocsPath } from './risk.mjs'
|
|
90
|
+
|
|
91
|
+
/** Skip reason when a completed wave claimed only documentation paths. */
|
|
92
|
+
export const SKIP_VERIFY_DOCS = 'docs-only-wave'
|
|
93
|
+
/** Skip reason when LIMITS.interWaveVerifications checkpoints are already spent. */
|
|
94
|
+
export const SKIP_VERIFY_CAP = 'verify-cap-reached'
|
|
95
|
+
/**
|
|
96
|
+
* Skip reason for the leading failing-test wave of a TDD change.
|
|
97
|
+
*
|
|
98
|
+
* The suite is SUPPOSED to be failing at that point, so the ordinary check
|
|
99
|
+
* would spend `LIMITS.interWaveFixAttempts` trying to repair tests that were
|
|
100
|
+
* just written on purpose, and then halt the run. The next wave's check is the
|
|
101
|
+
* real gate: by then the implementation exists, and a still-red suite is a
|
|
102
|
+
* genuine failure that correctly burns those attempts.
|
|
103
|
+
*/
|
|
104
|
+
export const SKIP_VERIFY_RED = 'red-wave-expected-failing'
|
|
105
|
+
|
|
106
|
+
export const DEFAULT_MAX_PARALLEL = 8
|
|
107
|
+
|
|
108
|
+
/** A lane whose tasks were joined by a canonical-path collision. */
|
|
109
|
+
export const LANE_COLLISION = 'collision'
|
|
110
|
+
/** A lane packing several path-disjoint low-tier components from one layer. */
|
|
111
|
+
export const LANE_COHESION = 'cohesion'
|
|
112
|
+
/** The single lane of a solo plan: the whole change, in order, on one agent. */
|
|
113
|
+
export const LANE_SOLO = 'solo'
|
|
114
|
+
|
|
115
|
+
/** The two plan shapes. `auto` is not one — it is the absence of an override. */
|
|
116
|
+
export const MODE_WAVES = 'waves'
|
|
117
|
+
export const MODE_SOLO = 'solo'
|
|
118
|
+
|
|
119
|
+
const MODES = new Set([MODE_WAVES, MODE_SOLO])
|
|
120
|
+
|
|
121
|
+
const MODELS = new Set(['haiku', 'sonnet', 'opus'])
|
|
122
|
+
|
|
123
|
+
function fail(message) {
|
|
124
|
+
const err = new Error(message)
|
|
125
|
+
err.userFacing = true
|
|
126
|
+
throw err
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Apply the clamp to one task, returning a clamp record when it changed.
|
|
130
|
+
function clampModel(task) {
|
|
131
|
+
const before = task.model
|
|
132
|
+
if (task.tier < 5 || task.model !== 'opus') {
|
|
133
|
+
task.model = task.model === 'haiku' ? 'haiku' : 'sonnet'
|
|
134
|
+
}
|
|
135
|
+
if (task.model === before) return null
|
|
136
|
+
return { id: task.id, from: before, to: task.model, tier: task.tier }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One task's predicted paths, as canonical identities paired with the spelling
|
|
141
|
+
* its author wrote.
|
|
142
|
+
*
|
|
143
|
+
* THIS IS THE ONLY READER of `task.paths` in this module. The collision key,
|
|
144
|
+
* the changed-file dedup and the docs-only test all go through it, because the
|
|
145
|
+
* defect it closes was never "the collision map used the wrong key" — it was
|
|
146
|
+
* "three readers of one value each did their own thing, and two of them were
|
|
147
|
+
* never looked at". Fixing the reported call site while its siblings stay on
|
|
148
|
+
* the raw form is how the original bug survives its own fix.
|
|
149
|
+
*
|
|
150
|
+
* A path that cannot be placed inside the repository (absolute, or escaping the
|
|
151
|
+
* root) yields `canonical: null`. It is reported, and it never becomes a key:
|
|
152
|
+
* an identity we cannot compute is not an identity that matches nothing.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} task
|
|
155
|
+
* @returns {Array<{authored: string, canonical: string|null}>}
|
|
156
|
+
*/
|
|
157
|
+
function predictedPaths(task) {
|
|
158
|
+
const raw = task && Array.isArray(task.paths) ? task.paths : []
|
|
159
|
+
const out = []
|
|
160
|
+
for (const authored of raw) {
|
|
161
|
+
if (typeof authored !== 'string' || !authored.trim()) continue
|
|
162
|
+
out.push({ authored, canonical: canonicalizePath(authored) })
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Every predicted path in a task list that cannot be used as an identity. */
|
|
168
|
+
function unusablePaths(tasks) {
|
|
169
|
+
const out = []
|
|
170
|
+
for (const t of tasks) {
|
|
171
|
+
for (const p of predictedPaths(t)) {
|
|
172
|
+
if (p.canonical === null) out.push({ id: t.id, path: p.authored })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One task's declared dependency edges, deduplicated, in the order written.
|
|
180
|
+
*
|
|
181
|
+
* THE ONLY READER of `task.dependsOn`, for the same reason `predictedPaths` is
|
|
182
|
+
* the only reader of `task.paths`: validation, layering and the replan path all
|
|
183
|
+
* ask the same question, and three readers each doing their own trimming is how
|
|
184
|
+
* one of them ends up matching an id the others do not.
|
|
185
|
+
*
|
|
186
|
+
* Entries are trimmed. A whitespace-only entry is dropped here and rejected by
|
|
187
|
+
* `validate`, which runs first on every path into the planner.
|
|
188
|
+
*/
|
|
189
|
+
function dependsOnIds(task) {
|
|
190
|
+
const raw = task && Array.isArray(task.dependsOn) ? task.dependsOn : []
|
|
191
|
+
const out = []
|
|
192
|
+
for (const entry of raw) {
|
|
193
|
+
if (typeof entry !== 'string' || !entry.trim()) continue
|
|
194
|
+
const id = entry.trim()
|
|
195
|
+
if (!out.includes(id)) out.push(id)
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function chunk(items, size) {
|
|
201
|
+
const out = []
|
|
202
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
203
|
+
return out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Hardest first, within a group.
|
|
208
|
+
*
|
|
209
|
+
* Tasks in a group are independent and run in parallel, so this changes nothing
|
|
210
|
+
* until the group is wider than `maxParallel` and gets split into batches. Then
|
|
211
|
+
* it decides which tasks go in batch one — and the tier-5 task is the one whose
|
|
212
|
+
* failure means the design was wrong. Discovering that in the first batch is
|
|
213
|
+
* cheaper than discovering it in the third, after the wave's budget is spent.
|
|
214
|
+
* This is the Boehm/spiral "riskiest first" ordering, applied inside a wave
|
|
215
|
+
* rather than across a release.
|
|
216
|
+
*
|
|
217
|
+
* Tie-break on id so the plan is deterministic: the same classified input must
|
|
218
|
+
* produce the same batches on every run, or nothing downstream is reproducible.
|
|
219
|
+
*/
|
|
220
|
+
function byHardestFirst(a, b) {
|
|
221
|
+
if (b.tier !== a.tier) return b.tier - a.tier
|
|
222
|
+
return String(a.id).localeCompare(String(b.id))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Per-task shape only. The dependency check is a SEPARATE call because it needs
|
|
226
|
+
// the resolved red wave, and the red wave can only be resolved once the tasks
|
|
227
|
+
// are known to be well-formed — so the two cannot happen in one pass.
|
|
228
|
+
function validateShape(input) {
|
|
229
|
+
if (!input || typeof input !== 'object') fail('classified input must be a JSON object')
|
|
230
|
+
if (!Array.isArray(input.tasks)) fail('classified input must have a "tasks" array')
|
|
231
|
+
|
|
232
|
+
const seen = new Set()
|
|
233
|
+
input.tasks.forEach((t, i) => {
|
|
234
|
+
const at = `tasks[${i}]`
|
|
235
|
+
if (!t || typeof t !== 'object') fail(`${at} must be an object`)
|
|
236
|
+
if (typeof t.id !== 'string' || !t.id.trim()) fail(`${at}.id must be a non-empty string`)
|
|
237
|
+
if (seen.has(t.id)) fail(`${at}.id "${t.id}" is duplicated`)
|
|
238
|
+
seen.add(t.id)
|
|
239
|
+
if (!Number.isInteger(t.group)) fail(`${at}.group must be an integer (task ${t.id})`)
|
|
240
|
+
if (typeof t.description !== 'string' || !t.description.trim()) {
|
|
241
|
+
fail(`${at}.description must be a non-empty string (task ${t.id})`)
|
|
242
|
+
}
|
|
243
|
+
if (!Number.isInteger(t.tier) || t.tier < 1 || t.tier > 5) {
|
|
244
|
+
fail(`${at}.tier must be an integer 1-5 (task ${t.id})`)
|
|
245
|
+
}
|
|
246
|
+
if (!MODELS.has(t.model)) {
|
|
247
|
+
fail(`${at}.model must be one of haiku|sonnet|opus (task ${t.id})`)
|
|
248
|
+
}
|
|
249
|
+
if (typeof t.isTestTask !== 'boolean') {
|
|
250
|
+
fail(`${at}.isTestTask must be a boolean (task ${t.id})`)
|
|
251
|
+
}
|
|
252
|
+
// `paths` is optional by design. A classifier that cannot predict which
|
|
253
|
+
// files a task will touch must be able to say nothing rather than guess —
|
|
254
|
+
// an invented path would produce a re-group that costs a wave of
|
|
255
|
+
// parallelism for no reason. Shape is checked; content never is.
|
|
256
|
+
if (t.paths !== undefined) {
|
|
257
|
+
if (!Array.isArray(t.paths) || t.paths.some(p => typeof p !== 'string')) {
|
|
258
|
+
fail(`${at}.paths must be an array of strings when present (task ${t.id})`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// `dependsOn` is optional the way `paths` is optional — a classifier that
|
|
262
|
+
// sees no dependency says nothing — but unlike `paths` it is a STRUCTURAL
|
|
263
|
+
// claim, so its content is checked too (below). A wrong path costs a
|
|
264
|
+
// needless serialization; a wrong edge is a task that runs before the work
|
|
265
|
+
// it needed, which is the failure this planner exists to prevent.
|
|
266
|
+
if (t.dependsOn !== undefined) {
|
|
267
|
+
if (
|
|
268
|
+
!Array.isArray(t.dependsOn) ||
|
|
269
|
+
t.dependsOn.some(d => typeof d !== 'string' || !d.trim())
|
|
270
|
+
) {
|
|
271
|
+
fail(`${at}.dependsOn must be an array of non-empty strings when present (task ${t.id})`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Check the dependency edge set fail-closed: every reference resolves, no edge
|
|
279
|
+
* points at work that runs later, and the graph is acyclic.
|
|
280
|
+
*
|
|
281
|
+
* Nothing here is repaired. Dropping an unresolved reference or breaking a
|
|
282
|
+
* cycle would produce a plan that looks successful and runs in the wrong order,
|
|
283
|
+
* which is strictly worse than a halt naming the cause — the same reason an
|
|
284
|
+
* out-of-repo `paths` entry is reported rather than normalized into scope.
|
|
285
|
+
*
|
|
286
|
+
* Two rejections beyond "dangling or cyclic" exist because the planner cannot
|
|
287
|
+
* honour those edges at all, and silently ignoring them is the degradation this
|
|
288
|
+
* is here to refuse:
|
|
289
|
+
*
|
|
290
|
+
* - An edge pointing at a LATER numbered section. Sections are a barrier the
|
|
291
|
+
* edge cannot override (a section-predecessor is always earlier), so such
|
|
292
|
+
* an edge asks for an order the section model already forbids.
|
|
293
|
+
* - An implementation task depending on a TEST task. Test tasks are deferred
|
|
294
|
+
* to a single trailing wave, so the dependency could never be satisfied.
|
|
295
|
+
* The ONE exception is a test task in the red wave of a TDD change: that
|
|
296
|
+
* one is not deferred, it runs first, and "make the failing suite pass" is
|
|
297
|
+
* exactly the dependency an author of such a change means.
|
|
298
|
+
* - A RED-WAVE task depending on anything outside the red wave. This
|
|
299
|
+
* rejection exists only because the exception above does: a deferred test
|
|
300
|
+
* task ran last, so any edge it declared was satisfiable and none was
|
|
301
|
+
* checked. A red-wave task runs FIRST, so the same edge now names work
|
|
302
|
+
* that has not happened yet.
|
|
303
|
+
*
|
|
304
|
+
* @param {Array} tasks
|
|
305
|
+
* @param {number|null} redWave the group that runs as the leading failing-test
|
|
306
|
+
* wave, or null. Null is the whole of today's behaviour: `t.group === null`
|
|
307
|
+
* is never true for a validated task, so every branch below collapses to the
|
|
308
|
+
* rule it had before this parameter existed.
|
|
309
|
+
*/
|
|
310
|
+
function validateDependencies(tasks, redWave = null) {
|
|
311
|
+
const byId = new Map(tasks.map(t => [t.id, t]))
|
|
312
|
+
const inRedWave = task => redWave !== null && task.group === redWave
|
|
313
|
+
|
|
314
|
+
for (const t of tasks) {
|
|
315
|
+
for (const dep of dependsOnIds(t)) {
|
|
316
|
+
if (!byId.has(dep)) {
|
|
317
|
+
fail(`task ${t.id} depends on "${dep}", which is not a task in this plan`)
|
|
318
|
+
}
|
|
319
|
+
const target = byId.get(dep)
|
|
320
|
+
if (!t.isTestTask && target.isTestTask && !inRedWave(target)) {
|
|
321
|
+
fail(
|
|
322
|
+
`task ${t.id} depends on test task ${dep}, which runs in the trailing test wave — ` +
|
|
323
|
+
`a dependency can only name work that runs earlier`
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
// The red wave runs first, so nothing it declares can point outward.
|
|
327
|
+
// Unchecked before this existed and correct then: a deferred test task
|
|
328
|
+
// ran after everything, so any edge it named had already happened.
|
|
329
|
+
if (inRedWave(t) && !inRedWave(target)) {
|
|
330
|
+
fail(
|
|
331
|
+
`task ${t.id} runs in the red wave (section ${redWave}) and depends on ${dep}, which ` +
|
|
332
|
+
`runs after it — a leading failing-test wave cannot depend on the work it pins`
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
// No backward-edge check is needed for a red-wave TARGET, and that is a
|
|
336
|
+
// consequence rather than an omission: `resolvePlanRedWave` only honours
|
|
337
|
+
// a claim on the LOWEST group, so a red target is always in the first
|
|
338
|
+
// section and no task can sit earlier than it. If that invariant is ever
|
|
339
|
+
// relaxed, this is the check that has to come back.
|
|
340
|
+
if (!t.isTestTask && !target.isTestTask && target.group > t.group) {
|
|
341
|
+
fail(
|
|
342
|
+
`task ${t.id} (section ${t.group}) depends on ${dep} (section ${target.group}), which ` +
|
|
343
|
+
`runs in a later section — a dependency can only name work that runs earlier`
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Depth-first, naming the ids on the cycle rather than reporting that one
|
|
350
|
+
// exists: "there is a cycle" is not something an author can act on.
|
|
351
|
+
const UNVISITED = 0
|
|
352
|
+
const ON_STACK = 1
|
|
353
|
+
const DONE = 2
|
|
354
|
+
const marks = new Map()
|
|
355
|
+
const stack = []
|
|
356
|
+
const walk = id => {
|
|
357
|
+
const mark = marks.get(id) || UNVISITED
|
|
358
|
+
if (mark === DONE) return
|
|
359
|
+
if (mark === ON_STACK) {
|
|
360
|
+
const from = stack.indexOf(id)
|
|
361
|
+
fail(`dependency cycle: ${[...stack.slice(from), id].join(' → ')}`)
|
|
362
|
+
}
|
|
363
|
+
marks.set(id, ON_STACK)
|
|
364
|
+
stack.push(id)
|
|
365
|
+
for (const dep of dependsOnIds(byId.get(id))) walk(dep)
|
|
366
|
+
stack.pop()
|
|
367
|
+
marks.set(id, DONE)
|
|
368
|
+
}
|
|
369
|
+
for (const t of tasks) walk(t.id)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Split one task set into dependency layers: everything with no dependency
|
|
374
|
+
* inside the set, then everything that depended only on that, and so on.
|
|
375
|
+
*
|
|
376
|
+
* The layer index IS the topological depth over the set's own edges, so layer 0
|
|
377
|
+
* runs first and a task is always in a strictly later layer than everything it
|
|
378
|
+
* depends on. Edges leaving the set are ignored HERE and satisfied elsewhere:
|
|
379
|
+
* a set is either one numbered section (whose predecessors are whole earlier
|
|
380
|
+
* waves) or the trailing test wave (which runs after every implementation
|
|
381
|
+
* wave), so an outbound edge is already ordered by the wave sequence.
|
|
382
|
+
*
|
|
383
|
+
* Order inside a layer is the caller's order, untouched. Re-sorting would be
|
|
384
|
+
* invisible until it changed which tasks share a batch in an edge-free plan,
|
|
385
|
+
* which is exactly the byte-identical guarantee this must not spend.
|
|
386
|
+
*
|
|
387
|
+
* With no edges every task is depth 0, so this returns `[tasks]` — one layer,
|
|
388
|
+
* in the order it was handed, and every caller behaves as it did before.
|
|
389
|
+
*/
|
|
390
|
+
function layerByDependencies(tasks) {
|
|
391
|
+
const inSet = new Map(tasks.map(t => [t.id, t]))
|
|
392
|
+
const depth = new Map()
|
|
393
|
+
const onStack = new Set()
|
|
394
|
+
|
|
395
|
+
const depthOf = id => {
|
|
396
|
+
if (depth.has(id)) return depth.get(id)
|
|
397
|
+
// `validate` rejects cycles before any plan is built, but `makeWave` lanes a
|
|
398
|
+
// replan whose tasks never went through it. A cycle there is a halt, not an
|
|
399
|
+
// infinite descent.
|
|
400
|
+
if (onStack.has(id)) fail(`dependency cycle through task ${id}`)
|
|
401
|
+
onStack.add(id)
|
|
402
|
+
let d = 0
|
|
403
|
+
for (const dep of dependsOnIds(inSet.get(id))) {
|
|
404
|
+
if (!inSet.has(dep)) continue
|
|
405
|
+
d = Math.max(d, depthOf(dep) + 1)
|
|
406
|
+
}
|
|
407
|
+
onStack.delete(id)
|
|
408
|
+
depth.set(id, d)
|
|
409
|
+
return d
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const layers = []
|
|
413
|
+
for (const t of tasks) {
|
|
414
|
+
const d = depthOf(t.id)
|
|
415
|
+
while (layers.length <= d) layers.push([])
|
|
416
|
+
layers[d].push(t)
|
|
417
|
+
}
|
|
418
|
+
return layers
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Ascending task id, natural order, so `1.9` sorts before `1.10`.
|
|
423
|
+
*
|
|
424
|
+
* This is the order tasks run in inside a lane. It is authored order, not tier
|
|
425
|
+
* order: sequential same-file work is ordered work, and a tier is a statement
|
|
426
|
+
* about difficulty, never about what depends on what.
|
|
427
|
+
*/
|
|
428
|
+
function byTaskId(a, b) {
|
|
429
|
+
return String(a.id).localeCompare(String(b.id), 'en', { numeric: true })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Where a planned lane or collision sits, for a report. A null group is the
|
|
434
|
+
* trailing test wave, which has no group number — naming it "wave null" would
|
|
435
|
+
* be a worse report than naming it what it is.
|
|
436
|
+
*/
|
|
437
|
+
function laneWhere(group) {
|
|
438
|
+
return group === null || group === undefined ? 'the test wave' : `wave ${group}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Every canonical path a lane's tasks claim, deduplicated. */
|
|
442
|
+
function laneCanonicalPaths(lane) {
|
|
443
|
+
const out = new Set()
|
|
444
|
+
for (const t of lane) {
|
|
445
|
+
for (const p of predictedPaths(t)) if (p.canonical !== null) out.add(p.canonical)
|
|
446
|
+
}
|
|
447
|
+
return out
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The tier a lane dispatches on: the highest among its tasks. */
|
|
451
|
+
export function laneTier(lane) {
|
|
452
|
+
return lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The effective per-tier lane cap table for one plan: the published table with a
|
|
457
|
+
* uniform override applied as a CEILING over every tier.
|
|
458
|
+
*
|
|
459
|
+
* A ceiling rather than a replacement, because the override's documented purpose
|
|
460
|
+
* is rollback: an override of 1 must reproduce one agent per task exactly, for
|
|
461
|
+
* cohesion lanes as well as collision lanes. Raising a tier's cap above the
|
|
462
|
+
* published table is deliberately not possible — the table is where a cap is
|
|
463
|
+
* stated, and a caller that could exceed it would be a second statement.
|
|
464
|
+
*
|
|
465
|
+
* @param {number} [override] uniform ceiling; ignored unless a positive integer
|
|
466
|
+
* @returns {Record<number, number>} tier → cap, a fresh object every call
|
|
467
|
+
*/
|
|
468
|
+
export function effectiveLaneCaps(override) {
|
|
469
|
+
const ceiling = Number.isInteger(override) && override > 0 ? override : null
|
|
470
|
+
const out = {}
|
|
471
|
+
for (const [tier, cap] of Object.entries(LANE_CAPS.byTier)) {
|
|
472
|
+
out[tier] = ceiling === null ? cap : Math.min(cap, ceiling)
|
|
473
|
+
}
|
|
474
|
+
return out
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The cap that bounds a lane of `tier`, read off an effective table.
|
|
479
|
+
*
|
|
480
|
+
* Tier 0 — a lane whose tasks carry no readable tier — uses the tier-1 entry.
|
|
481
|
+
* That is the same fallback `laneEffort` makes for an unreadable tier, and it
|
|
482
|
+
* errs toward the most permissive cap on the least demanding work.
|
|
483
|
+
*/
|
|
484
|
+
function capForTier(caps, tier) {
|
|
485
|
+
const table = caps && typeof caps === 'object' ? caps : LANE_CAPS.byTier
|
|
486
|
+
const cap = table[tier] ?? table[1] ?? LANE_CAPS.byTier[1]
|
|
487
|
+
return Number.isInteger(cap) && cap > 0 ? cap : 1
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The model a lane dispatches on — the one assigned to its hardest task.
|
|
492
|
+
*
|
|
493
|
+
* A lane is one agent, and that agent has to be capable of everything in it.
|
|
494
|
+
* Taking the first task's model would run a tier-5 task on whatever tier 1 was
|
|
495
|
+
* given, which is the one direction of this trade that is not survivable.
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* The label a lane runs under — the name its agent, its trajectory line and its
|
|
499
|
+
* briefing file all carry.
|
|
500
|
+
*
|
|
501
|
+
* Stable across replays, so a resumed run cache-hits the lane it already ran.
|
|
502
|
+
* This derivation was written three times (the workflow script, the ACP driver,
|
|
503
|
+
* and `bin/interlock`'s own trajectory logger); it is stated here once because a
|
|
504
|
+
* trajectory line and the agent it describes must carry one name, not two.
|
|
505
|
+
*/
|
|
506
|
+
export function laneLabel(lane) {
|
|
507
|
+
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function laneModel(lane) {
|
|
511
|
+
const tier = laneTier(lane)
|
|
512
|
+
const hardest = lane.find(t => t.tier === tier)
|
|
513
|
+
return (hardest && hardest.model) || (lane[0] && lane[0].model) || 'sonnet'
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The reasoning effort a lane dispatches at — a sibling of `laneModel`, derived
|
|
518
|
+
* from the same hardest-task tier, looked up in the table published by
|
|
519
|
+
* `lib/limits.mjs`. A lane is one agent, and that agent must be capable of the
|
|
520
|
+
* hardest thing in the lane, so the effort is the hardest task's, never the
|
|
521
|
+
* first task's — the one direction of this trade that is not survivable.
|
|
522
|
+
*
|
|
523
|
+
* Returns `null` (inherit the session default — do not force) for tiers 3–4 by
|
|
524
|
+
* policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
|
|
525
|
+
* effort report distinguishes the two. Effort is orthogonal to the model clamp:
|
|
526
|
+
* both are derived from tier so a future model tweak never silently moves effort.
|
|
527
|
+
*/
|
|
528
|
+
export function laneEffort(lane) {
|
|
529
|
+
const tier = laneTier(lane)
|
|
530
|
+
return EFFORT.byTier[tier] ?? null
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Group a group's tasks into lanes: connected components over canonical-path
|
|
535
|
+
* collisions, ordered by task id, split at the lane cap.
|
|
536
|
+
*
|
|
537
|
+
* Two tasks join when they claim a common canonical path, directly or through
|
|
538
|
+
* another task — component membership, not pairwise chaining. With tasks
|
|
539
|
+
* A[x,y], B[x] and C[y], pairwise chaining would put B and C in different
|
|
540
|
+
* lanes that both run beside A's writes.
|
|
541
|
+
*
|
|
542
|
+
* The key is the CANONICAL path, never the authored spelling. Keyed on raw
|
|
543
|
+
* text, `src/a.ts` and `./src/a.ts` are two identities for one file, so the
|
|
544
|
+
* two tasks claiming them would run concurrently and one would silently
|
|
545
|
+
* overwrite the other. The spelling the author wrote is still what gets
|
|
546
|
+
* reported — a report naming a path the task author does not recognize is a
|
|
547
|
+
* worse report.
|
|
548
|
+
*
|
|
549
|
+
* A path that cannot be canonicalized joins nothing: an identity we could not
|
|
550
|
+
* compute is not an identity that matches everything, and the plan reports it
|
|
551
|
+
* rather than normalizing it into scope.
|
|
552
|
+
*
|
|
553
|
+
* WHAT THIS DOES NOT DO. `paths` is a model's prediction, so a task that edits
|
|
554
|
+
* a file it never named is still unguarded. This narrows the race; it does not
|
|
555
|
+
* close it.
|
|
556
|
+
*/
|
|
557
|
+
function buildLanes(tasks, serialized, group, laneCaps) {
|
|
558
|
+
// Components are built in task-id order so the first claimant of a path is
|
|
559
|
+
// the earliest task, which is both what a lane runs first and what the
|
|
560
|
+
// collision report should name as the holder.
|
|
561
|
+
const ordered = tasks.slice().sort(byTaskId)
|
|
562
|
+
const parent = ordered.map((_, i) => i)
|
|
563
|
+
const find = i => {
|
|
564
|
+
while (parent[i] !== i) {
|
|
565
|
+
parent[i] = parent[parent[i]]
|
|
566
|
+
i = parent[i]
|
|
567
|
+
}
|
|
568
|
+
return i
|
|
569
|
+
}
|
|
570
|
+
const union = (a, b) => {
|
|
571
|
+
const ra = find(a)
|
|
572
|
+
const rb = find(b)
|
|
573
|
+
if (ra === rb) return
|
|
574
|
+
// Lower index wins so a component's root is its earliest task.
|
|
575
|
+
if (ra < rb) parent[rb] = ra
|
|
576
|
+
else parent[ra] = rb
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const owner = new Map()
|
|
580
|
+
ordered.forEach((t, i) => {
|
|
581
|
+
let reported = false
|
|
582
|
+
for (const p of predictedPaths(t)) {
|
|
583
|
+
if (p.canonical === null) continue
|
|
584
|
+
if (owner.has(p.canonical)) {
|
|
585
|
+
const held = owner.get(p.canonical)
|
|
586
|
+
if (!reported) {
|
|
587
|
+
serialized.push({
|
|
588
|
+
id: t.id,
|
|
589
|
+
group,
|
|
590
|
+
path: p.authored,
|
|
591
|
+
conflictsWith: ordered[held].id
|
|
592
|
+
})
|
|
593
|
+
reported = true
|
|
594
|
+
}
|
|
595
|
+
union(held, i)
|
|
596
|
+
} else {
|
|
597
|
+
owner.set(p.canonical, i)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
const components = new Map()
|
|
603
|
+
ordered.forEach((t, i) => {
|
|
604
|
+
const root = find(i)
|
|
605
|
+
if (!components.has(root)) components.set(root, [])
|
|
606
|
+
components.get(root).push(t)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// Hardest component first, for the same reason tasks used to sort that way:
|
|
610
|
+
// when the group is wider than maxParallel, the component whose failure means
|
|
611
|
+
// the design was wrong should be in the first batch, not the third. Split
|
|
612
|
+
// lanes of one component are emitted in order right after each other, so the
|
|
613
|
+
// packer below can only ever push a later sibling into a later batch.
|
|
614
|
+
//
|
|
615
|
+
// The order is also what makes cohesion's cap safe: eligible components are a
|
|
616
|
+
// suffix of this ranking (eligibility is `tier <= cohesionMaxTier`, and the
|
|
617
|
+
// ranking is by descending tier), so the FIRST component to join an open
|
|
618
|
+
// cohesion lane is its hardest, and the lane's cap is fixed from that moment.
|
|
619
|
+
// A cap can never shrink under a lane that already filled it.
|
|
620
|
+
const ranked = [...components.values()].sort((a, b) => {
|
|
621
|
+
const ta = laneTier(a)
|
|
622
|
+
const tb = laneTier(b)
|
|
623
|
+
if (tb !== ta) return tb - ta
|
|
624
|
+
return byTaskId(a[0], b[0])
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
const lanes = []
|
|
628
|
+
// One open lane, walked in ranked order — next-fit, not first-fit. First-fit
|
|
629
|
+
// packs marginally tighter but lets a later small component slip into an
|
|
630
|
+
// earlier lane whose tier it does not share, which makes "which agent got this
|
|
631
|
+
// task" harder to read off the plan. Next-fit is one open lane, one rule, one
|
|
632
|
+
// line in the preview.
|
|
633
|
+
let open = null
|
|
634
|
+
const close = () => {
|
|
635
|
+
if (!open) return
|
|
636
|
+
// Sorted by id exactly as a collision lane is: two components merged by
|
|
637
|
+
// cohesion may interleave, which is authored order and runs inside one
|
|
638
|
+
// agent, so no concurrency guarantee is spent on it.
|
|
639
|
+
if (open.parts > 1) open.tasks.sort(byTaskId)
|
|
640
|
+
lanes.push(open)
|
|
641
|
+
open = null
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
for (const component of ranked) {
|
|
645
|
+
const tier = laneTier(component)
|
|
646
|
+
const cap = capForTier(laneCaps, tier)
|
|
647
|
+
|
|
648
|
+
// Ineligible (tier above the cohesion ceiling), or too long to pack at all:
|
|
649
|
+
// its own lane, or its own sequential lanes when it exceeds the cap. Closing
|
|
650
|
+
// the open lane keeps next-fit's invariant honest — one open lane, appended
|
|
651
|
+
// to in ranked order — rather than leaving a stale lane behind an emission.
|
|
652
|
+
if (tier > LANE_CAPS.cohesionMaxTier || component.length > cap) {
|
|
653
|
+
close()
|
|
654
|
+
for (const part of chunk(component, cap)) {
|
|
655
|
+
lanes.push({ tasks: part, kind: LANE_COLLISION, tier: laneTier(part), cap })
|
|
656
|
+
}
|
|
657
|
+
continue
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (open && open.tasks.length + component.length <= capForTier(laneCaps, Math.max(open.tier, tier))) {
|
|
661
|
+
open.tasks.push(...component)
|
|
662
|
+
open.parts += 1
|
|
663
|
+
continue
|
|
664
|
+
}
|
|
665
|
+
close()
|
|
666
|
+
open = { tasks: component.slice(), kind: LANE_COLLISION, tier, cap, parts: 1 }
|
|
667
|
+
}
|
|
668
|
+
close()
|
|
669
|
+
|
|
670
|
+
// A lane holding more than one COMPONENT was joined by cohesion; a lane
|
|
671
|
+
// holding one component (whole, or a cap-sized slice of it) was joined by a
|
|
672
|
+
// collision. The distinction is not cosmetic — it is what the preview reports,
|
|
673
|
+
// and it cannot be derived after the fact: the two halves of a split star
|
|
674
|
+
// component are path-disjoint from each other, so a lane that re-derived its
|
|
675
|
+
// kind from its own connectivity would call that split a cohesion fold.
|
|
676
|
+
for (const lane of lanes) {
|
|
677
|
+
if (lane.parts > 1) {
|
|
678
|
+
lane.kind = LANE_COHESION
|
|
679
|
+
lane.tier = laneTier(lane.tasks)
|
|
680
|
+
lane.cap = capForTier(laneCaps, lane.tier)
|
|
681
|
+
}
|
|
682
|
+
delete lane.parts
|
|
683
|
+
}
|
|
684
|
+
return lanes
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Pack lanes into batches that never co-schedule two lanes claiming the same
|
|
689
|
+
* path, and never exceed maxParallel lanes.
|
|
690
|
+
*
|
|
691
|
+
* Lanes from different components are path-disjoint by construction, so the
|
|
692
|
+
* only collision the packer can see is between the split halves of a component
|
|
693
|
+
* too long for the cap — which is exactly the pair that must stay sequential.
|
|
694
|
+
*
|
|
695
|
+
* The two deferral kinds are deliberately distinct. A lane pushed out because
|
|
696
|
+
* the batch was full is path-disjoint from everything in it, so it waits for a
|
|
697
|
+
* later batch; it is never folded into another lane's agent, which would
|
|
698
|
+
* serialize work the planner deliberately parallelized.
|
|
699
|
+
*/
|
|
700
|
+
function packLanes(entries, maxParallel) {
|
|
701
|
+
const remaining = entries.slice()
|
|
702
|
+
// The lanes are already built, so each one's path set is immutable for the
|
|
703
|
+
// life of this call. Computed once here rather than inside the loop: a lane
|
|
704
|
+
// deferred across k batches would otherwise be re-walked k times.
|
|
705
|
+
const pathSets = new Map(entries.map(e => [e, laneCanonicalPaths(e.tasks)]))
|
|
706
|
+
const batches = []
|
|
707
|
+
while (remaining.length) {
|
|
708
|
+
const batch = []
|
|
709
|
+
const claimed = new Set()
|
|
710
|
+
const deferred = []
|
|
711
|
+
for (const entry of remaining) {
|
|
712
|
+
const paths = pathSets.get(entry)
|
|
713
|
+
let clash = false
|
|
714
|
+
for (const p of paths) {
|
|
715
|
+
if (claimed.has(p)) {
|
|
716
|
+
clash = true
|
|
717
|
+
break
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (clash || batch.length >= maxParallel) {
|
|
721
|
+
deferred.push(entry)
|
|
722
|
+
continue
|
|
723
|
+
}
|
|
724
|
+
batch.push(entry.tasks)
|
|
725
|
+
for (const p of paths) claimed.add(p)
|
|
726
|
+
}
|
|
727
|
+
if (!batch.length) {
|
|
728
|
+
batch.push(remaining[0].tasks)
|
|
729
|
+
deferred.length = 0
|
|
730
|
+
deferred.push(...remaining.slice(1))
|
|
731
|
+
}
|
|
732
|
+
batches.push(batch)
|
|
733
|
+
remaining.length = 0
|
|
734
|
+
remaining.push(...deferred)
|
|
735
|
+
}
|
|
736
|
+
return batches
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A group's tasks as batches of lanes, plus the lane entries that describe them.
|
|
741
|
+
*
|
|
742
|
+
* A batch is a list of lanes run in parallel; a lane is a list of tasks run in
|
|
743
|
+
* order by one agent. Today's pre-lane behaviour is the case where every lane
|
|
744
|
+
* holds exactly one task, which is what a uniform cap of 1 reproduces exactly.
|
|
745
|
+
*
|
|
746
|
+
* The entries are returned beside the batches rather than attached to them
|
|
747
|
+
* because a lane IS a plain array of tasks — the shape every consumer, the run
|
|
748
|
+
* state and `plan.json` already expect — and a property hung on an array does
|
|
749
|
+
* not survive `JSON.stringify`. Each entry's `tasks` is the very array placed in
|
|
750
|
+
* a batch, so the caller can report a lane's kind without re-deriving it.
|
|
751
|
+
*/
|
|
752
|
+
function collisionAwareChunk(tasks, maxParallel, serialized, group, laneCaps) {
|
|
753
|
+
const entries = buildLanes(tasks, serialized, group, laneCaps)
|
|
754
|
+
return { batches: packLanes(entries, maxParallel), entries }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Every task in a wave, in execution order, regardless of lane nesting. */
|
|
758
|
+
function waveTasks(wave) {
|
|
759
|
+
const batches = wave && Array.isArray(wave.batches) ? wave.batches : []
|
|
760
|
+
return batches.flat(2)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Every lane in a wave, in execution order. */
|
|
764
|
+
function waveLanes(wave) {
|
|
765
|
+
const batches = wave && Array.isArray(wave.batches) ? wave.batches : []
|
|
766
|
+
return batches.flat(1)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Fold every 1-task implementation wave onto the wave before it.
|
|
771
|
+
*
|
|
772
|
+
* A wave boundary is a checkpoint: a record ping, and until the cap an
|
|
773
|
+
* inter-wave verification. A wave holding a single task buys that checkpoint to
|
|
774
|
+
* express one thing — that its task runs after the previous wave's. A later
|
|
775
|
+
* *batch* of the previous wave expresses exactly the same ordering and costs
|
|
776
|
+
* nothing, so that is where the task goes.
|
|
777
|
+
*
|
|
778
|
+
* What is deliberately not folded:
|
|
779
|
+
*
|
|
780
|
+
* - A leading singleton. With no previous implementation wave there is
|
|
781
|
+
* nothing to fold onto, and inventing one would reorder the run.
|
|
782
|
+
* - Two waves that each hold two or more tasks. Their boundary is a real
|
|
783
|
+
* checkpoint between two sets of parallel work, not a staircase.
|
|
784
|
+
* - The trailing test wave, in either direction. It is deferred on purpose
|
|
785
|
+
* so a cross-cutting failure is diagnosed once, against finished work.
|
|
786
|
+
*
|
|
787
|
+
* The batches are appended as they were planned, which is what makes D3's
|
|
788
|
+
* split-only `adoptWave` load-bearing: a folded task with a disjoint path has
|
|
789
|
+
* no collision to keep it out of batch 0, so only the batch boundary the plan
|
|
790
|
+
* recorded keeps it from running too early.
|
|
791
|
+
*
|
|
792
|
+
* @param {Array<{group: number, taskCount: number, batches: Array}>} planned
|
|
793
|
+
* @returns {{waves: Array, folded: Array<{id: string, from: number, to: number}>}}
|
|
794
|
+
*/
|
|
795
|
+
function foldSingletonWaves(planned) {
|
|
796
|
+
const waves = []
|
|
797
|
+
const folded = []
|
|
798
|
+
for (const wave of planned) {
|
|
799
|
+
const previous = waves[waves.length - 1]
|
|
800
|
+
// Never fold NON-RED work onto the red wave. Folding is "ordering, not a
|
|
801
|
+
// checkpoint", which is true between two implementation waves and false
|
|
802
|
+
// here: the red wave's whole content is a suite that is supposed to be
|
|
803
|
+
// failing, so absorbing the work that makes it pass into the same wave
|
|
804
|
+
// would erase the boundary the shape exists to draw — and hand the merged
|
|
805
|
+
// wave the red wave's skipped verification. A red layer folding onto an
|
|
806
|
+
// earlier red layer is still just ordering, and still folds.
|
|
807
|
+
const ontoRed = previous && previous.red === true && wave.red !== true
|
|
808
|
+
if (wave.taskCount === 1 && previous && !ontoRed) {
|
|
809
|
+
const [task] = waveTasks(wave)
|
|
810
|
+
previous.batches.push(...wave.batches)
|
|
811
|
+
previous.taskCount += wave.taskCount
|
|
812
|
+
folded.push({ id: task.id, from: wave.group, to: previous.group })
|
|
813
|
+
continue
|
|
814
|
+
}
|
|
815
|
+
waves.push(wave)
|
|
816
|
+
}
|
|
817
|
+
return { waves, folded }
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Every claimed path on every task is documentation.
|
|
822
|
+
*
|
|
823
|
+
* Missing paths → not docs-only, and a path that could not be canonicalized →
|
|
824
|
+
* not docs-only either. "We could not place this file in the repo" is unknown,
|
|
825
|
+
* and unknown is never a reason to skip a verification checkpoint.
|
|
826
|
+
*/
|
|
827
|
+
export function isDocsOnlyWave(wave) {
|
|
828
|
+
const tasks = waveTasks(wave)
|
|
829
|
+
if (!tasks.length) return false
|
|
830
|
+
for (const t of tasks) {
|
|
831
|
+
const paths = predictedPaths(t)
|
|
832
|
+
if (!paths.length) return false
|
|
833
|
+
if (paths.some(p => p.canonical === null || !isDocsPath(p.canonical))) return false
|
|
834
|
+
}
|
|
835
|
+
return true
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* The wave's changed-file set: deduplicated by canonical identity, reported in
|
|
840
|
+
* the spelling each task's author wrote.
|
|
841
|
+
*
|
|
842
|
+
* This feeds `verify plan --changed`. Deduplicating on raw text let one file
|
|
843
|
+
* spelled two ways inflate the changed set a verification is scoped to.
|
|
844
|
+
*/
|
|
845
|
+
function uniquePaths(wave) {
|
|
846
|
+
const out = []
|
|
847
|
+
const seen = new Set()
|
|
848
|
+
for (const t of waveTasks(wave)) {
|
|
849
|
+
for (const p of predictedPaths(t)) {
|
|
850
|
+
// Prefixed with "./" because canonicalizePath never returns a leading
|
|
851
|
+
// "./", so an unusable path can never collide with a real canonical
|
|
852
|
+
// identity. (This used to be a literal NUL byte, which made the whole
|
|
853
|
+
// module read as binary to grep.)
|
|
854
|
+
const key = p.canonical === null ? `./unusable:${p.authored}` : p.canonical
|
|
855
|
+
if (seen.has(key)) continue
|
|
856
|
+
seen.add(key)
|
|
857
|
+
out.push(p.authored)
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return out
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Which shape to plan in: flag, then the classifier inside the envelope, then
|
|
865
|
+
* waves. Pure, and the only place the precedence is written down.
|
|
866
|
+
*
|
|
867
|
+
* A flag may exceed the envelope and is warned about; a classifier
|
|
868
|
+
* recommendation may not, and its refusal is warned about too. Both directions
|
|
869
|
+
* are spoken because the operator's question at a plan preview is always "why is
|
|
870
|
+
* it doing it this way", and an unexplained mode is an unanswerable one.
|
|
871
|
+
*
|
|
872
|
+
* An absent, unrecognized or `waves` recommendation is waves with no warning, so
|
|
873
|
+
* a `classified.json` written before this field existed plans exactly as before.
|
|
874
|
+
*
|
|
875
|
+
* @param {unknown} override `opts.mode`
|
|
876
|
+
* @param {unknown} recommended `input.recommendedMode`
|
|
877
|
+
* @param {unknown} reason `input.modeReason`
|
|
878
|
+
* @param {number} taskCount every task the lane would hold
|
|
879
|
+
*/
|
|
880
|
+
function decideMode(override, recommended, reason, taskCount) {
|
|
881
|
+
const warnings = []
|
|
882
|
+
// Contradictory input is rejected before planning rather than resolved by
|
|
883
|
+
// order: last-wins would silently pick one of two things the caller asked for.
|
|
884
|
+
if (Array.isArray(override)) {
|
|
885
|
+
const asked = [...new Set(override.map(m => String(m)))]
|
|
886
|
+
fail(
|
|
887
|
+
`contradictory mode overrides (${asked.join(' and ')}) — pass exactly one of ` +
|
|
888
|
+
`${MODE_SOLO}|${MODE_WAVES}`
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
if (override !== undefined && override !== null && !MODES.has(override)) {
|
|
892
|
+
fail(`unknown mode override ${JSON.stringify(override)} — expected ${MODE_SOLO}|${MODE_WAVES}`)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Carried verbatim but bounded by the handoff budget, so a runaway reason
|
|
896
|
+
// cannot bloat every plan, every fingerprint and every preview that echoes it.
|
|
897
|
+
const carried =
|
|
898
|
+
typeof reason === 'string' && reason.trim()
|
|
899
|
+
? reason.trim().slice(0, LIMITS.maxHandoffChars)
|
|
900
|
+
: null
|
|
901
|
+
|
|
902
|
+
if (MODES.has(override)) {
|
|
903
|
+
if (override === MODE_SOLO && taskCount > SOLO.maxTasks) {
|
|
904
|
+
warnings.push(
|
|
905
|
+
`solo forced by flag: ${taskCount} tasks exceeds the published envelope of ` +
|
|
906
|
+
`${SOLO.maxTasks} — the whole change runs on one agent by explicit request`
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
return { mode: override, modeSource: 'flag', modeReason: null, warnings }
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (recommended === MODE_SOLO) {
|
|
913
|
+
if (taskCount <= SOLO.maxTasks) {
|
|
914
|
+
return { mode: MODE_SOLO, modeSource: 'classifier', modeReason: carried, warnings }
|
|
915
|
+
}
|
|
916
|
+
warnings.push(
|
|
917
|
+
`classifier recommended solo for ${taskCount} tasks; the envelope is ${SOLO.maxTasks}, ` +
|
|
918
|
+
`planned as waves`
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
return { mode: MODE_WAVES, modeSource: 'default', modeReason: null, warnings }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Every task in solo order: implementation by (group, dependency layer, id),
|
|
926
|
+
* then tests by (layer, id).
|
|
927
|
+
*
|
|
928
|
+
* The order is the whole ordering guarantee of a solo plan. There are no batch
|
|
929
|
+
* boundaries left to express "after" with, so a section barrier and a
|
|
930
|
+
* `dependsOn` edge are both honoured by sequence inside the one agent — which is
|
|
931
|
+
* why layer beats id here, and why the edge report is still emitted.
|
|
932
|
+
*
|
|
933
|
+
* @returns {{lane: Array, deferred: Array}}
|
|
934
|
+
*/
|
|
935
|
+
function soloOrder(groups, byGroup, testTasks) {
|
|
936
|
+
const lane = []
|
|
937
|
+
const deferred = []
|
|
938
|
+
const orderLayers = (tasks, group) => {
|
|
939
|
+
const layers = layerByDependencies(tasks)
|
|
940
|
+
layers.forEach((layer, depth) => {
|
|
941
|
+
if (depth > 0) {
|
|
942
|
+
const earlier = new Set(layers.slice(0, depth).flat().map(t => t.id))
|
|
943
|
+
for (const t of layer) {
|
|
944
|
+
deferred.push({ id: t.id, group, after: dependsOnIds(t).filter(d => earlier.has(d)) })
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
lane.push(...layer.slice().sort(byTaskId))
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
for (const group of groups) orderLayers(byGroup.get(group), group)
|
|
951
|
+
if (testTasks.length) orderLayers(testTasks, null)
|
|
952
|
+
return { lane, deferred }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Adjudicate the caller's red-wave claim against the classified tasks.
|
|
957
|
+
*
|
|
958
|
+
* Two ways a claim is refused, both structural and both checkable here without
|
|
959
|
+
* knowing anything about where the claim came from:
|
|
960
|
+
*
|
|
961
|
+
* - The group holds no test task. Then there is no failing suite to run
|
|
962
|
+
* first, and honouring the claim would only skip that wave's verification.
|
|
963
|
+
* - The group is not the lowest one present. A failing-test section that runs
|
|
964
|
+
* after implementation is not a red wave; treating it as one would move
|
|
965
|
+
* tests earlier than the author put them, in a change they did not write
|
|
966
|
+
* for TDD.
|
|
967
|
+
*
|
|
968
|
+
* A non-integer is not a refusal, it is the absence of a claim — that is how
|
|
969
|
+
* every non-TDD run arrives here.
|
|
970
|
+
*
|
|
971
|
+
* @returns {{group: number|null, refused: string|null}}
|
|
972
|
+
*/
|
|
973
|
+
function resolvePlanRedWave(requested, tasks) {
|
|
974
|
+
if (!Number.isInteger(requested)) return { group: null, refused: null }
|
|
975
|
+
const inGroup = tasks.filter(t => t.group === requested)
|
|
976
|
+
if (!inGroup.length) {
|
|
977
|
+
return { group: null, refused: `no task is classified in section ${requested}` }
|
|
978
|
+
}
|
|
979
|
+
if (!inGroup.some(t => t.isTestTask)) {
|
|
980
|
+
return {
|
|
981
|
+
group: null,
|
|
982
|
+
refused: `section ${requested} holds no test task, so there is no failing suite to run first`
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const lowest = Math.min(...tasks.map(t => t.group))
|
|
986
|
+
if (requested !== lowest) {
|
|
987
|
+
return {
|
|
988
|
+
group: null,
|
|
989
|
+
refused:
|
|
990
|
+
`section ${requested} is not the first section (section ${lowest} runs earlier), and a ` +
|
|
991
|
+
`failing-test section that runs after implementation is not a red wave`
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { group: requested, refused: null }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* @param {{tasks: Array, recommendedMode?: string, modeReason?: string}} input
|
|
999
|
+
* classified tasks, optionally carrying the classifier's mode recommendation
|
|
1000
|
+
* @param {{maxParallel?: number, maxTasksPerAgent?: number, mode?: string}} opts
|
|
1001
|
+
* `maxTasksPerAgent` is a UNIFORM CEILING over every tier's published cap for
|
|
1002
|
+
* this plan only; the caps themselves stay stated once in `LANE_CAPS.byTier`.
|
|
1003
|
+
* It is an override because 1 is the documented rollback lever — it reproduces
|
|
1004
|
+
* one agent per task exactly, cohesion included — and a lever nothing can pull
|
|
1005
|
+
* is not a lever. `mode` forces `solo` or `waves`; absent, the classifier's
|
|
1006
|
+
* recommendation is honoured inside the published envelope.
|
|
1007
|
+
*
|
|
1008
|
+
* `redWave` is the group that runs as a LEADING failing-test wave instead of
|
|
1009
|
+
* being deferred — the execution half of the TDD task shape. It is resolved by
|
|
1010
|
+
* the caller (`resolveRedWave` in `lib/run.mjs`) from the `tasks.md` marker or
|
|
1011
|
+
* an explicit flag, never inferred here: this module is pure and cannot read
|
|
1012
|
+
* the file the shape was written in. Absent — which is every change that is
|
|
1013
|
+
* not authored for TDD — every branch it gates collapses to the behaviour this
|
|
1014
|
+
* planner had before it existed.
|
|
1015
|
+
*/
|
|
1016
|
+
export function planWaves(input, opts = {}) {
|
|
1017
|
+
validateShape(input)
|
|
1018
|
+
|
|
1019
|
+
const maxParallel = Number.isInteger(opts.maxParallel) && opts.maxParallel > 0
|
|
1020
|
+
? opts.maxParallel
|
|
1021
|
+
: DEFAULT_MAX_PARALLEL
|
|
1022
|
+
const laneCaps = effectiveLaneCaps(opts.maxTasksPerAgent)
|
|
1023
|
+
|
|
1024
|
+
// Copy so the caller's objects are never mutated by the clamp.
|
|
1025
|
+
const tasks = input.tasks.map(t => ({ ...t }))
|
|
1026
|
+
|
|
1027
|
+
// Refused rather than honoured when it names nothing runnable: a red wave is
|
|
1028
|
+
// a claim about the task list, and a claim the task list contradicts must not
|
|
1029
|
+
// reorder the run. Refusing is a null here plus a warning below — never a
|
|
1030
|
+
// halt, because the change is still perfectly implementable in the ordinary
|
|
1031
|
+
// shape, and never silence, because a shape the operator asked for and did
|
|
1032
|
+
// not get is exactly the thing that has to be said out loud.
|
|
1033
|
+
const redWave = resolvePlanRedWave(opts.redWave, tasks)
|
|
1034
|
+
validateDependencies(tasks, redWave.group)
|
|
1035
|
+
|
|
1036
|
+
const clamped = []
|
|
1037
|
+
for (const t of tasks) {
|
|
1038
|
+
const record = clampModel(t)
|
|
1039
|
+
if (record) clamped.push(record)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const decision = decideMode(opts.mode, input.recommendedMode, input.modeReason, tasks.length)
|
|
1043
|
+
|
|
1044
|
+
// The red wave's test tasks are planned as an ordinary implementation wave —
|
|
1045
|
+
// they keep their group, and therefore their lanes, batches, cohesion folds
|
|
1046
|
+
// and model clamp, all unchanged. Everything else defers as it always has.
|
|
1047
|
+
//
|
|
1048
|
+
// With no red wave `t.group === null` is never true for a validated task, so
|
|
1049
|
+
// this is the same partition it was before, expression for expression.
|
|
1050
|
+
const implTasks = tasks.filter(t => !t.isTestTask || t.group === redWave.group)
|
|
1051
|
+
const testTasks = tasks.filter(t => t.isTestTask && t.group !== redWave.group)
|
|
1052
|
+
|
|
1053
|
+
const byGroup = new Map()
|
|
1054
|
+
for (const t of implTasks) {
|
|
1055
|
+
if (!byGroup.has(t.group)) byGroup.set(t.group, [])
|
|
1056
|
+
byGroup.get(t.group).push(t)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Waves are the distinct classified group numbers, ascending. Groups execute
|
|
1060
|
+
// in order; tasks inside a group that claim the same path become later
|
|
1061
|
+
// batches of that wave, not a new group.
|
|
1062
|
+
const groups = [...byGroup.keys()].filter(g => byGroup.get(g).length).sort((a, b) => a - b)
|
|
1063
|
+
|
|
1064
|
+
const solo = decision.mode === MODE_SOLO
|
|
1065
|
+
|
|
1066
|
+
const serialized = []
|
|
1067
|
+
const deferred = []
|
|
1068
|
+
const laneEntries = []
|
|
1069
|
+
const promoted = []
|
|
1070
|
+
let planned = []
|
|
1071
|
+
let waves = []
|
|
1072
|
+
let folded = []
|
|
1073
|
+
let testWave = null
|
|
1074
|
+
let widest = 0
|
|
1075
|
+
|
|
1076
|
+
if (solo) {
|
|
1077
|
+
// Every task on one agent, so there is nothing to pack: `foldSingletonWaves`,
|
|
1078
|
+
// `packLanes` and the collision report are all answers to "what may run
|
|
1079
|
+
// beside what", and in a lane of one agent that question has no content.
|
|
1080
|
+
// What survives is the ORDER, which is the whole ordering guarantee here.
|
|
1081
|
+
//
|
|
1082
|
+
// Promotion happens after the clamp, not instead of it. The clamp's job is to
|
|
1083
|
+
// stop the CLASSIFIER escalating itself to opus; handing one agent the whole
|
|
1084
|
+
// change is the planner's own decision, so it is recorded as a promotion
|
|
1085
|
+
// rather than smuggled in as a classification.
|
|
1086
|
+
for (const t of tasks) {
|
|
1087
|
+
if (t.model === 'opus') continue
|
|
1088
|
+
promoted.push({ id: t.id, from: t.model, to: 'opus', tier: t.tier })
|
|
1089
|
+
t.model = 'opus'
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const ordered = soloOrder(groups, byGroup, testTasks)
|
|
1093
|
+
deferred.push(...ordered.deferred)
|
|
1094
|
+
if (ordered.lane.length) {
|
|
1095
|
+
const group = groups.length ? groups[0] : Math.min(...tasks.map(t => t.group))
|
|
1096
|
+
const entry = {
|
|
1097
|
+
tasks: ordered.lane,
|
|
1098
|
+
kind: LANE_SOLO,
|
|
1099
|
+
tier: laneTier(ordered.lane),
|
|
1100
|
+
cap: null
|
|
1101
|
+
}
|
|
1102
|
+
laneEntries.push({ group, entry })
|
|
1103
|
+
waves = [{ group, taskCount: ordered.lane.length, batches: [[ordered.lane]] }]
|
|
1104
|
+
widest = ordered.lane.length
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
// A group is split into dependency layers, each of which becomes a wave of
|
|
1108
|
+
// its own. With no edges a group is exactly one layer, so this loop emits the
|
|
1109
|
+
// same one-wave-per-group plan it always did; with edges, the dependent task
|
|
1110
|
+
// lands in a later layer, and the singleton fold below usually turns that
|
|
1111
|
+
// layer back into a later BATCH of the same wave rather than a new checkpoint.
|
|
1112
|
+
planned = []
|
|
1113
|
+
for (const group of groups) {
|
|
1114
|
+
const layers = layerByDependencies(byGroup.get(group))
|
|
1115
|
+
layers.forEach((layer, depth) => {
|
|
1116
|
+
if (depth > 0) {
|
|
1117
|
+
const earlier = new Set(layers.slice(0, depth).flat().map(t => t.id))
|
|
1118
|
+
for (const t of layer) {
|
|
1119
|
+
deferred.push({
|
|
1120
|
+
id: t.id,
|
|
1121
|
+
group,
|
|
1122
|
+
after: dependsOnIds(t).filter(d => earlier.has(d))
|
|
1123
|
+
})
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const layerTasks = layer.slice().sort(byHardestFirst)
|
|
1127
|
+
const { batches, entries } = collisionAwareChunk(
|
|
1128
|
+
layerTasks,
|
|
1129
|
+
maxParallel,
|
|
1130
|
+
serialized,
|
|
1131
|
+
group,
|
|
1132
|
+
laneCaps
|
|
1133
|
+
)
|
|
1134
|
+
for (const entry of entries) laneEntries.push({ group, entry })
|
|
1135
|
+
// `red` is spread in only when this IS the red wave, so a plan for an
|
|
1136
|
+
// ordinary change carries no new key and stays byte-comparable with the
|
|
1137
|
+
// plans every existing reader and fixture was written against.
|
|
1138
|
+
planned.push({
|
|
1139
|
+
group,
|
|
1140
|
+
taskCount: layerTasks.length,
|
|
1141
|
+
batches,
|
|
1142
|
+
...(group === redWave.group ? { red: true } : {})
|
|
1143
|
+
})
|
|
1144
|
+
})
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// The width warning describes what the classifier handed us — how wide a
|
|
1148
|
+
// single group was before anything was folded onto it. Measured after the
|
|
1149
|
+
// fold it would report a wave whose batches already fit the cap.
|
|
1150
|
+
widest = planned.reduce((m, w) => Math.max(m, w.taskCount), 0)
|
|
1151
|
+
|
|
1152
|
+
const folding = foldSingletonWaves(planned)
|
|
1153
|
+
waves = folding.waves
|
|
1154
|
+
folded = folding.folded
|
|
1155
|
+
|
|
1156
|
+
// Test tasks are deferred to a single trailing wave so that a cross-cutting
|
|
1157
|
+
// test failure is diagnosed once, against the finished implementation, rather
|
|
1158
|
+
// than repeatedly against half-built state.
|
|
1159
|
+
//
|
|
1160
|
+
// The wave is laned exactly as an implementation layer is. It used to be
|
|
1161
|
+
// chunked one task per lane on the reasoning that deferral had already bought
|
|
1162
|
+
// the parallelism a lane would spend — but sixteen tier-2 test tasks were
|
|
1163
|
+
// sixteen spawns for that, and two test tasks claiming one path were run side
|
|
1164
|
+
// by side, which is the collision the packer exists to prevent. The deferral
|
|
1165
|
+
// is about WHEN tests run, not about how many agents run them.
|
|
1166
|
+
//
|
|
1167
|
+
// Layered for the same reason a group is: two test tasks joined by an edge
|
|
1168
|
+
// would otherwise be chunked side by side into one concurrent batch. Layers
|
|
1169
|
+
// become later batches here rather than waves, because the test wave is one
|
|
1170
|
+
// wave by construction.
|
|
1171
|
+
if (testTasks.length) {
|
|
1172
|
+
const batches = []
|
|
1173
|
+
for (const layer of layerByDependencies(testTasks)) {
|
|
1174
|
+
const packed = collisionAwareChunk(
|
|
1175
|
+
layer.slice().sort(byHardestFirst),
|
|
1176
|
+
maxParallel,
|
|
1177
|
+
serialized,
|
|
1178
|
+
null,
|
|
1179
|
+
laneCaps
|
|
1180
|
+
)
|
|
1181
|
+
for (const entry of packed.entries) laneEntries.push({ group: null, entry })
|
|
1182
|
+
batches.push(...packed.batches)
|
|
1183
|
+
}
|
|
1184
|
+
testWave = { taskCount: testTasks.length, batches }
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const implCount = implTasks.length
|
|
1189
|
+
|
|
1190
|
+
// Every lane holding more than one task, so a fold is visible rather than
|
|
1191
|
+
// inferred from a smaller agent bill. A lane of one is not a fold and is not
|
|
1192
|
+
// listed — the plan would otherwise report a "fold" for every task it has.
|
|
1193
|
+
//
|
|
1194
|
+
// Read off the entries the packer produced rather than re-walked from the
|
|
1195
|
+
// waves, because `kind` is a fact about how the lane was BUILT and cannot be
|
|
1196
|
+
// recovered from the lane's contents afterwards. The test wave's lanes are
|
|
1197
|
+
// listed too (with a null group): a cohesion fold there is exactly as much a
|
|
1198
|
+
// fold as one in an implementation wave.
|
|
1199
|
+
const lanes = []
|
|
1200
|
+
for (const { group, entry } of laneEntries) {
|
|
1201
|
+
if (entry.tasks.length < 2) continue
|
|
1202
|
+
lanes.push({
|
|
1203
|
+
group,
|
|
1204
|
+
kind: entry.kind,
|
|
1205
|
+
ids: entry.tasks.map(t => t.id),
|
|
1206
|
+
tier: laneTier(entry.tasks),
|
|
1207
|
+
cap: entry.cap,
|
|
1208
|
+
model: laneModel(entry.tasks),
|
|
1209
|
+
effort: laneEffort(entry.tasks)
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
const laneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
|
|
1213
|
+
|
|
1214
|
+
// One effort entry per listed lane, mirroring the clamp report. An inherited
|
|
1215
|
+
// effort (null) is either "by policy" (a tier 3–4 lane the table leaves to the
|
|
1216
|
+
// session default) or "by fallback" (a lane whose tier could not be read, tier
|
|
1217
|
+
// 0) — reported apart so an inherited effort is never mistaken for no routing.
|
|
1218
|
+
const effort = lanes.map(l => ({
|
|
1219
|
+
ids: l.ids,
|
|
1220
|
+
tier: l.tier,
|
|
1221
|
+
effort: l.effort,
|
|
1222
|
+
inherited: l.effort !== null ? null : l.tier >= 1 && l.tier <= 5 ? 'policy' : 'fallback'
|
|
1223
|
+
}))
|
|
1224
|
+
|
|
1225
|
+
const warnings = []
|
|
1226
|
+
warnings.push(...decision.warnings)
|
|
1227
|
+
// Both directions are spoken. A refused claim is the operator not getting the
|
|
1228
|
+
// shape they asked for; an honoured one changes what the run verifies and
|
|
1229
|
+
// when, which nobody should have to infer from a missing checkpoint.
|
|
1230
|
+
if (redWave.refused) {
|
|
1231
|
+
warnings.push(`red wave refused: ${redWave.refused} — planned in the ordinary shape instead`)
|
|
1232
|
+
}
|
|
1233
|
+
if (redWave.group !== null) {
|
|
1234
|
+
warnings.push(
|
|
1235
|
+
`section ${redWave.group} is the red wave: its failing tests run FIRST rather than ` +
|
|
1236
|
+
`deferring to the trailing test wave, and its inter-wave check is skipped because the ` +
|
|
1237
|
+
`suite is expected to fail there — so this run cannot tell a genuinely failing suite ` +
|
|
1238
|
+
`from one that already passed. The next wave's check is the gate.`
|
|
1239
|
+
)
|
|
1240
|
+
}
|
|
1241
|
+
if (widest > maxParallel && !solo) {
|
|
1242
|
+
warnings.push(
|
|
1243
|
+
`widest wave has ${widest} tasks; splitting into batches of ${maxParallel}`
|
|
1244
|
+
)
|
|
1245
|
+
}
|
|
1246
|
+
// Measured on the collapsed list and in lanes, because a lane is one agent:
|
|
1247
|
+
// a staircase the fold already removed, or a chain the lanes already folded,
|
|
1248
|
+
// is not a shape the operator still has to fix.
|
|
1249
|
+
if (laneCount >= 2 && waves.length > laneCount * 0.5) {
|
|
1250
|
+
warnings.push(
|
|
1251
|
+
`effectively serial: ${waves.length} waves for ${laneCount} implementation lane(s)`
|
|
1252
|
+
)
|
|
1253
|
+
}
|
|
1254
|
+
// One warning per fold, phrased by KIND. A cohesion fold is not a collision:
|
|
1255
|
+
// an operator reading "share a file" about seven path-disjoint tasks would go
|
|
1256
|
+
// looking for a file they do not share.
|
|
1257
|
+
for (const lane of lanes) {
|
|
1258
|
+
if (lane.kind === LANE_SOLO) {
|
|
1259
|
+
warnings.push(
|
|
1260
|
+
`solo lane: tasks ${lane.ids.join(', ')} run in that order on one opus agent — ` +
|
|
1261
|
+
`every section barrier and dependency edge is honoured by position in the lane`
|
|
1262
|
+
)
|
|
1263
|
+
} else if (lane.kind === LANE_COHESION) {
|
|
1264
|
+
warnings.push(
|
|
1265
|
+
`tasks ${lane.ids.join(', ')} are path-disjoint tier-${lane.tier} work in ` +
|
|
1266
|
+
`${laneWhere(lane.group)}; packed into one cohesion lane run by a single agent in that ` +
|
|
1267
|
+
`order (tier-${lane.tier} lane cap ${lane.cap})`
|
|
1268
|
+
)
|
|
1269
|
+
} else {
|
|
1270
|
+
warnings.push(
|
|
1271
|
+
`tasks ${lane.ids.join(', ')} share a file in ${laneWhere(lane.group)}; folded into one ` +
|
|
1272
|
+
`lane run by a single tier-${lane.tier} agent in that order`
|
|
1273
|
+
)
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
for (const f of folded) {
|
|
1277
|
+
warnings.push(
|
|
1278
|
+
`task ${f.id} was a 1-task wave in group ${f.from}; folded into wave ${f.to} as a later ` +
|
|
1279
|
+
`batch — ordering is kept, the checkpoint is not`
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1282
|
+
if (!implTasks.length) warnings.push('no implementation tasks — only test tasks were classified')
|
|
1283
|
+
|
|
1284
|
+
// Reported at the boundary, never silently dropped: a predicted path that
|
|
1285
|
+
// cannot be placed in the repo is not compared against anything, so the task
|
|
1286
|
+
// claiming it is unguarded against a collision on that file and the plan has
|
|
1287
|
+
// to say so.
|
|
1288
|
+
const rejectedPaths = unusablePaths(tasks)
|
|
1289
|
+
for (const r of rejectedPaths) {
|
|
1290
|
+
warnings.push(
|
|
1291
|
+
`task ${r.id} predicted "${r.path}", which is absolute or escapes the repository root — ` +
|
|
1292
|
+
`it is not usable as a collision key and was not rewritten into scope`
|
|
1293
|
+
)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
for (const m of serialized) {
|
|
1297
|
+
warnings.push(
|
|
1298
|
+
`task ${m.id} serialized in ${laneWhere(m.group)}: it claims ${m.path}, already claimed by ` +
|
|
1299
|
+
`${m.conflictsWith} — they run in one lane, in task-id order`
|
|
1300
|
+
)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Named rather than left to be inferred from a batch boundary: an edge is the
|
|
1304
|
+
// one ordering signal in this planner an author wrote by hand, so an author
|
|
1305
|
+
// reading the plan has to be able to see it took effect. In solo mode the
|
|
1306
|
+
// batch boundary is gone and the edge is honoured by position in the lane, so
|
|
1307
|
+
// it matters even more that the plan says the edge was seen.
|
|
1308
|
+
for (const d of deferred) {
|
|
1309
|
+
warnings.push(
|
|
1310
|
+
solo
|
|
1311
|
+
? `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them inside the ` +
|
|
1312
|
+
`solo lane rather than beside them`
|
|
1313
|
+
: `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them in ` +
|
|
1314
|
+
`${laneWhere(d.group)}, in a later batch or wave rather than beside them`
|
|
1315
|
+
)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return {
|
|
1319
|
+
maxParallel,
|
|
1320
|
+
mode: decision.mode,
|
|
1321
|
+
modeSource: decision.modeSource,
|
|
1322
|
+
modeReason: decision.modeReason,
|
|
1323
|
+
totalTasks: tasks.length,
|
|
1324
|
+
// The resolved group, not the requested one: a refused claim reads as no
|
|
1325
|
+
// red wave here, and the refusal is in `warnings` where a reader looks for
|
|
1326
|
+
// what the planner decided against.
|
|
1327
|
+
redWave: redWave.group,
|
|
1328
|
+
implCount,
|
|
1329
|
+
testCount: testTasks.length,
|
|
1330
|
+
waveCount: waves.length,
|
|
1331
|
+
laneCount,
|
|
1332
|
+
laneCaps,
|
|
1333
|
+
waves,
|
|
1334
|
+
testWave,
|
|
1335
|
+
clamped,
|
|
1336
|
+
promoted,
|
|
1337
|
+
serialized,
|
|
1338
|
+
deferred,
|
|
1339
|
+
lanes,
|
|
1340
|
+
effort,
|
|
1341
|
+
folded,
|
|
1342
|
+
rejectedPaths,
|
|
1343
|
+
warnings
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* A wave's tasks, one line each, with a lane header for anything folded.
|
|
1349
|
+
*
|
|
1350
|
+
* A single-task lane prints exactly the line it printed before lanes existed:
|
|
1351
|
+
* naming a "lane" for every task would bury the folds this is here to show.
|
|
1352
|
+
*/
|
|
1353
|
+
function formatBatches(wave) {
|
|
1354
|
+
const lines = []
|
|
1355
|
+
for (const batch of wave.batches) {
|
|
1356
|
+
for (const lane of batch) {
|
|
1357
|
+
if (lane.length > 1) {
|
|
1358
|
+
lines.push(
|
|
1359
|
+
` lane [${laneModel(lane)}/T${laneTier(lane)}] ${lane.length} tasks, one agent, in order:`
|
|
1360
|
+
)
|
|
1361
|
+
}
|
|
1362
|
+
for (const t of lane) lines.push(` - [${t.model}/T${t.tier}] ${t.id} ${t.description}`)
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return lines
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** Human-readable execution plan, for the skill to echo before it fans out. */
|
|
1369
|
+
export function formatPlan(plan) {
|
|
1370
|
+
const lines = []
|
|
1371
|
+
// The mode opens the preview, before the shape it produced: an operator
|
|
1372
|
+
// looking at one lane for twelve tasks has to be told it was a decision, whose
|
|
1373
|
+
// decision it was, and why — at the top, not inferred from the absence of
|
|
1374
|
+
// batches further down.
|
|
1375
|
+
const source = plan.modeSource || 'default'
|
|
1376
|
+
lines.push(
|
|
1377
|
+
`mode: ${plan.mode || MODE_WAVES} (${source}${plan.modeReason ? `: ${plan.modeReason}` : ''})`
|
|
1378
|
+
)
|
|
1379
|
+
lines.push(
|
|
1380
|
+
`${plan.totalTasks} tasks → ${plan.waveCount} wave(s), ` +
|
|
1381
|
+
`${plan.implCount} impl + ${plan.testCount} test, max ${plan.maxParallel} parallel`
|
|
1382
|
+
)
|
|
1383
|
+
for (const wave of plan.waves) {
|
|
1384
|
+
const batchNote = wave.batches.length > 1 ? ` in ${wave.batches.length} batches` : ''
|
|
1385
|
+
// Named on the wave's own line rather than only in the warnings, because
|
|
1386
|
+
// this is the one wave whose failing check is not a problem — an operator
|
|
1387
|
+
// watching the run has to be able to see which one that is.
|
|
1388
|
+
const redNote = wave.red === true ? ' — RED: failing tests, check skipped' : ''
|
|
1389
|
+
lines.push(` Wave ${wave.group}: ${wave.taskCount} task(s)${batchNote}${redNote}`)
|
|
1390
|
+
for (const line of formatBatches(wave)) lines.push(line)
|
|
1391
|
+
}
|
|
1392
|
+
if (plan.testWave) {
|
|
1393
|
+
lines.push(` Test wave: ${plan.testWave.taskCount} task(s)`)
|
|
1394
|
+
for (const line of formatBatches(plan.testWave)) lines.push(line)
|
|
1395
|
+
}
|
|
1396
|
+
for (const c of plan.clamped) {
|
|
1397
|
+
lines.push(` clamped ${c.id}: ${c.from} → ${c.to} (tier ${c.tier})`)
|
|
1398
|
+
}
|
|
1399
|
+
// Beside the clamps, and phrased the same way, because they are the same kind
|
|
1400
|
+
// of fact read in opposite directions: the clamp is what the classifier was
|
|
1401
|
+
// not allowed to ask for, the promotion is what the planner decided anyway.
|
|
1402
|
+
for (const p of plan.promoted || []) {
|
|
1403
|
+
lines.push(` promoted ${p.id}: ${p.from} → ${p.to} (tier ${p.tier}, solo lane)`)
|
|
1404
|
+
}
|
|
1405
|
+
for (const m of plan.serialized || []) {
|
|
1406
|
+
lines.push(
|
|
1407
|
+
` serialized ${m.id}: same lane in ${laneWhere(m.group)} ` +
|
|
1408
|
+
`(${m.path} held by ${m.conflictsWith})`
|
|
1409
|
+
)
|
|
1410
|
+
}
|
|
1411
|
+
for (const d of plan.deferred || []) {
|
|
1412
|
+
lines.push(
|
|
1413
|
+
` ordered ${d.id}: after ${d.after.join(', ')} in ${laneWhere(d.group)} (dependsOn)`
|
|
1414
|
+
)
|
|
1415
|
+
}
|
|
1416
|
+
// The over-split is still visible even though it no longer costs a
|
|
1417
|
+
// checkpoint — a plan that silently absorbed it would stop the authoring
|
|
1418
|
+
// problem from ever being fixed.
|
|
1419
|
+
for (const f of plan.folded || []) {
|
|
1420
|
+
lines.push(` folded ${f.id}: 1-task wave ${f.from} → later batch of wave ${f.to}`)
|
|
1421
|
+
}
|
|
1422
|
+
// The fold that removed the spawns, named rather than left to be inferred
|
|
1423
|
+
// from an agent count that is smaller than the task count. Cohesion prints
|
|
1424
|
+
// under its own keyword and names the cap that bounded it, so an operator can
|
|
1425
|
+
// see both that the fold happened and what would have stopped it.
|
|
1426
|
+
for (const lane of plan.lanes || []) {
|
|
1427
|
+
if (lane.kind === LANE_SOLO) {
|
|
1428
|
+
lines.push(
|
|
1429
|
+
` solo ${lane.ids.join(' → ')}: ${lane.ids.length} tasks, the whole change on one ` +
|
|
1430
|
+
`${lane.model}/T${lane.tier} agent`
|
|
1431
|
+
)
|
|
1432
|
+
} else if (lane.kind === LANE_COHESION) {
|
|
1433
|
+
lines.push(
|
|
1434
|
+
` cohesion ${lane.ids.join(' → ')}: ${lane.ids.length} path-disjoint tasks in ` +
|
|
1435
|
+
`${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent (cap ${lane.cap})`
|
|
1436
|
+
)
|
|
1437
|
+
} else {
|
|
1438
|
+
lines.push(
|
|
1439
|
+
` lane ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ${laneWhere(lane.group)} on ` +
|
|
1440
|
+
`one ${lane.model}/T${lane.tier} agent`
|
|
1441
|
+
)
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// The effort each listed lane routes at, named the way clamps are — an
|
|
1445
|
+
// inherited effort says which kind, so "tier 3 → inherited (policy)" reads
|
|
1446
|
+
// differently from "tier unreadable → inherited (fallback)".
|
|
1447
|
+
for (const e of plan.effort || []) {
|
|
1448
|
+
const chosen =
|
|
1449
|
+
e.effort === null
|
|
1450
|
+
? `inherited (${e.inherited === 'fallback' ? 'tier unreadable' : 'by policy'})`
|
|
1451
|
+
: e.effort
|
|
1452
|
+
lines.push(` effort ${e.ids.join(' → ')}: ${chosen} (tier ${e.tier})`)
|
|
1453
|
+
}
|
|
1454
|
+
const cost = projectedWaveLoopAgents(plan)
|
|
1455
|
+
lines.push(
|
|
1456
|
+
`projected agents: ${cost.total} ` +
|
|
1457
|
+
`(${cost.implementers} implementers + ${cost.recordPings} record + ${cost.verifyPings} verify)`
|
|
1458
|
+
)
|
|
1459
|
+
for (const w of plan.warnings) lines.push(` warning: ${w}`)
|
|
1460
|
+
return lines.join('\n') + '\n'
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** Wave-loop agent bill: implementers + one record ping per wave + capped verifies. */
|
|
1464
|
+
export function projectedWaveLoopAgents(plan) {
|
|
1465
|
+
const implWaves = Array.isArray(plan.waves) ? plan.waves : []
|
|
1466
|
+
const testWaves = plan.testWave ? [plan.testWave] : []
|
|
1467
|
+
const allWaves = [...implWaves, ...testWaves]
|
|
1468
|
+
// One lane is one agent, however many tasks it carries. Billing this by task
|
|
1469
|
+
// is what made the fold invisible: the whole point of a lane is that four
|
|
1470
|
+
// tasks in it cost one spawn prefix, not four.
|
|
1471
|
+
const implementers = allWaves.reduce((n, w) => n + waveLanes(w).length, 0)
|
|
1472
|
+
const recordPings = allWaves.length
|
|
1473
|
+
let verifyEligible = 0
|
|
1474
|
+
for (let i = 0; i < allWaves.length - 1; i++) {
|
|
1475
|
+
if (!isDocsOnlyWave(allWaves[i])) verifyEligible += 1
|
|
1476
|
+
}
|
|
1477
|
+
const verifyPings = Math.min(LIMITS.interWaveVerifications, verifyEligible)
|
|
1478
|
+
return { implementers, recordPings, verifyPings, total: implementers + recordPings + verifyPings }
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// ---------------------------------------------------------------------------
|
|
1482
|
+
// Execution state machine
|
|
1483
|
+
// ---------------------------------------------------------------------------
|
|
1484
|
+
//
|
|
1485
|
+
// The walk is: for each wave in order, run its batches one after another (the
|
|
1486
|
+
// tasks inside a batch are what the caller fans out in parallel); when a wave
|
|
1487
|
+
// ends and another wave follows, verify before starting it. Verification only
|
|
1488
|
+
// ever runs *between* waves, because the one question it answers — "do these
|
|
1489
|
+
// errors block the next wave?" — is meaningless when there is no next wave. The
|
|
1490
|
+
// full suite after the trailing test wave is a separate step the caller owns.
|
|
1491
|
+
|
|
1492
|
+
/** Why a run stopped. Callers branch on these, so they are constants. */
|
|
1493
|
+
export const HALT_TASK_FAILURES = 'task-failures'
|
|
1494
|
+
export const HALT_INTER_WAVE_VERIFY = 'inter-wave-verify'
|
|
1495
|
+
|
|
1496
|
+
// ---------------------------------------------------------------------------
|
|
1497
|
+
// Wave handoff
|
|
1498
|
+
// ---------------------------------------------------------------------------
|
|
1499
|
+
//
|
|
1500
|
+
// What one wave tells the next. Before this existed the next wave inferred it
|
|
1501
|
+
// from `git log` plus a mutable state file, which is a reconstruction, not a
|
|
1502
|
+
// report — and a reconstruction cannot be validated, bounded or refused.
|
|
1503
|
+
//
|
|
1504
|
+
// So it is a schema with a character cap, and it fails closed. A packet that is
|
|
1505
|
+
// missing, malformed or over budget fails its task; it is never truncated and
|
|
1506
|
+
// never passed through as prose for the next wave to interpret. Truncation is
|
|
1507
|
+
// the exact silent degradation this replaces.
|
|
1508
|
+
|
|
1509
|
+
/** Version tag on every stored packet, so a later shape change is detectable. */
|
|
1510
|
+
export const HANDOFF_SCHEMA = 'interlock.wave-handoff/1'
|
|
1511
|
+
|
|
1512
|
+
const HANDOFF_STATUSES = new Set(['ok', 'blocked', 'partial'])
|
|
1513
|
+
|
|
1514
|
+
/** Evidence is a locator, not a file body: `path`, `path:12`, `path:12-40`. */
|
|
1515
|
+
const EVIDENCE_LOCATOR = /^[^\s:]+(:\d+(-\d+)?)?$/
|
|
1516
|
+
const MAX_EVIDENCE = 8
|
|
1517
|
+
|
|
1518
|
+
function handoffError(reason) {
|
|
1519
|
+
return { ok: false, error: reason }
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Check one task's handoff packet. Pure — no state, no clock, no I/O.
|
|
1524
|
+
*
|
|
1525
|
+
* @param {string} taskId the id the packet must be reporting on
|
|
1526
|
+
* @param {unknown} packet
|
|
1527
|
+
* @returns {{ok: true, handoff: object} | {ok: false, error: string}}
|
|
1528
|
+
*/
|
|
1529
|
+
export function validateHandoff(taskId, packet) {
|
|
1530
|
+
if (!packet || typeof packet !== 'object' || Array.isArray(packet)) {
|
|
1531
|
+
return handoffError('handoff must be an object')
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// `schema` is defaulted rather than demanded. A model that drops a constant
|
|
1535
|
+
// string it was shown verbatim has not told us anything about its work, and
|
|
1536
|
+
// failing the whole task over it would throw away a valid report. A *wrong*
|
|
1537
|
+
// version is different — that is a packet written against another contract.
|
|
1538
|
+
const schema = packet.schema === undefined ? HANDOFF_SCHEMA : packet.schema
|
|
1539
|
+
if (schema !== HANDOFF_SCHEMA) {
|
|
1540
|
+
return handoffError(`unknown handoff schema "${schema}" (expected ${HANDOFF_SCHEMA})`)
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (typeof packet.taskId !== 'string' || packet.taskId !== taskId) {
|
|
1544
|
+
return handoffError(`taskId must be "${taskId}", got ${JSON.stringify(packet.taskId)}`)
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
if (!HANDOFF_STATUSES.has(packet.status)) {
|
|
1548
|
+
return handoffError(
|
|
1549
|
+
`status must be one of ${[...HANDOFF_STATUSES].join('|')}, got ${JSON.stringify(packet.status)}`
|
|
1550
|
+
)
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (typeof packet.summary !== 'string' || !packet.summary.trim()) {
|
|
1554
|
+
return handoffError('summary must be a non-empty string')
|
|
1555
|
+
}
|
|
1556
|
+
if (typeof packet.next !== 'string' || !packet.next.trim()) {
|
|
1557
|
+
return handoffError('next must be a non-empty string')
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const blocker = packet.blocker === undefined ? null : packet.blocker
|
|
1561
|
+
if (packet.status === 'ok') {
|
|
1562
|
+
if (blocker !== null) return handoffError('status "ok" must have a null blocker')
|
|
1563
|
+
} else if (typeof blocker !== 'string' || !blocker.trim()) {
|
|
1564
|
+
return handoffError(`status "${packet.status}" must have a non-empty blocker`)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const evidence = packet.evidence === undefined ? [] : packet.evidence
|
|
1568
|
+
if (!Array.isArray(evidence)) return handoffError('evidence must be an array of locators')
|
|
1569
|
+
if (evidence.length > MAX_EVIDENCE) {
|
|
1570
|
+
return handoffError(`evidence has ${evidence.length} entries; at most ${MAX_EVIDENCE} are allowed`)
|
|
1571
|
+
}
|
|
1572
|
+
for (const entry of evidence) {
|
|
1573
|
+
if (typeof entry !== 'string' || !EVIDENCE_LOCATOR.test(entry)) {
|
|
1574
|
+
return handoffError(
|
|
1575
|
+
`evidence entry ${JSON.stringify(entry)} is not a locator (path, path:line or path:start-end)`
|
|
1576
|
+
)
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const counted =
|
|
1581
|
+
packet.summary.length + packet.next.length + (blocker || '').length + evidence.join('\n').length
|
|
1582
|
+
if (counted > LIMITS.maxHandoffChars) {
|
|
1583
|
+
return handoffError(
|
|
1584
|
+
`handoff is ${counted} characters; the cap is ${LIMITS.maxHandoffChars} ` +
|
|
1585
|
+
`(shorten it — it is never truncated for you)`
|
|
1586
|
+
)
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Rebuilt rather than spread, so unknown keys are dropped instead of stored.
|
|
1590
|
+
return {
|
|
1591
|
+
ok: true,
|
|
1592
|
+
handoff: {
|
|
1593
|
+
schema: HANDOFF_SCHEMA,
|
|
1594
|
+
taskId,
|
|
1595
|
+
status: packet.status,
|
|
1596
|
+
summary: packet.summary,
|
|
1597
|
+
evidence: evidence.slice(),
|
|
1598
|
+
next: packet.next,
|
|
1599
|
+
blocker: packet.status === 'ok' ? null : blocker
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ---------------------------------------------------------------------------
|
|
1605
|
+
// Handoff evidence audit
|
|
1606
|
+
// ---------------------------------------------------------------------------
|
|
1607
|
+
//
|
|
1608
|
+
// `validateHandoff` checks that an evidence entry has locator SHAPE. Shape
|
|
1609
|
+
// alone is satisfied by inventing `lib/nowhere.mjs:1`, which is exactly the
|
|
1610
|
+
// hole `lib/review-core.mjs` closed for review findings by adding a second,
|
|
1611
|
+
// independent condition: the cited path must be one that actually changed.
|
|
1612
|
+
// This is that condition, applied to the second surface, with the same
|
|
1613
|
+
// deliberate ceilings:
|
|
1614
|
+
//
|
|
1615
|
+
// - Path membership only. The cited LINE is never required to exist — a
|
|
1616
|
+
// locator may name a line the same wave later moved or deleted, and
|
|
1617
|
+
// rejecting it would be a false rejection of a true claim.
|
|
1618
|
+
// - No semantic judgement. Whether the cited span supports the summary needs
|
|
1619
|
+
// a model, and a model there recreates the unverified-claim problem one
|
|
1620
|
+
// layer down.
|
|
1621
|
+
//
|
|
1622
|
+
// AND IT NEVER GATES. The verdict is recorded and read; it does not decide
|
|
1623
|
+
// whether a task succeeded, whether a wave proceeds, or whether the run halts.
|
|
1624
|
+
// An unconfirmed packet is still handed to the next wave, because a report with
|
|
1625
|
+
// unverifiable evidence is still more information than no report — and because
|
|
1626
|
+
// membership here is run-scoped, not task-scoped (see `source` below), so a
|
|
1627
|
+
// halt built on it would stop good runs.
|
|
1628
|
+
|
|
1629
|
+
/** Every locator names a path in the changed-path set. */
|
|
1630
|
+
export const AUDIT_CONFIRMED = 'confirmed'
|
|
1631
|
+
/** At least one locator names a path the set does not contain. */
|
|
1632
|
+
export const AUDIT_UNCONFIRMED = 'unconfirmed'
|
|
1633
|
+
/** The check could not run. NOT a synonym for failure — see below. */
|
|
1634
|
+
export const AUDIT_NOT_AUDITED = 'not-audited'
|
|
1635
|
+
|
|
1636
|
+
/** Does one discrete locator name a path in `index`? Shape, then membership. */
|
|
1637
|
+
function locatorMatches(entry, index) {
|
|
1638
|
+
if (typeof entry !== 'string' || !EVIDENCE_LOCATOR.test(entry)) return false
|
|
1639
|
+
const path = locatorPath(entry)
|
|
1640
|
+
return path !== null && index.has(path)
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Audit one handoff packet's evidence against the paths that changed.
|
|
1645
|
+
*
|
|
1646
|
+
* Pure by construction: the path set is an ARGUMENT, never fetched. `lib/` has
|
|
1647
|
+
* no business running git, and the moment that matters is the caller's — at
|
|
1648
|
+
* `record-batch` the working tree still matches what the implementer just
|
|
1649
|
+
* claimed, and audited any later a `confirmed` verdict means nothing.
|
|
1650
|
+
*
|
|
1651
|
+
* `source` is load-bearing, not decoration. Auditing self-reported evidence
|
|
1652
|
+
* against a self-reported path set is a CONSISTENCY check — it catches an agent
|
|
1653
|
+
* that contradicts itself, not one that fabricates coherently. Recording the
|
|
1654
|
+
* two identically would present the weaker check as the stronger one, which is
|
|
1655
|
+
* the defect class this exists to remove. So the fallback is kept and labelled,
|
|
1656
|
+
* and an unlabelled path set is treated as `reported`: the weaker claim is the
|
|
1657
|
+
* safe default, because the failure mode of the other default is a lie.
|
|
1658
|
+
*
|
|
1659
|
+
* `reportedMatch` is the narrower cross-check kept separately. The observed set
|
|
1660
|
+
* is `git status` at record time, which mid-run is the accumulated diff of the
|
|
1661
|
+
* RUN, not of this task — tasks in a wave run concurrently against one working
|
|
1662
|
+
* tree, so no git question can attribute a path to a lane. Read together the
|
|
1663
|
+
* two fields say: the run demonstrably changed this path, and the task claimed
|
|
1664
|
+
* it. Collapsing them into one boolean would sell the aggregate as per-task
|
|
1665
|
+
* attribution.
|
|
1666
|
+
*
|
|
1667
|
+
* @param {unknown} packet a validated handoff packet
|
|
1668
|
+
* @param {unknown} changedPaths the path set to test against
|
|
1669
|
+
* @param {{source?: 'observed'|'reported', reportedPaths?: string[]}} [opts]
|
|
1670
|
+
* @returns {{verdict: string, source: string|null, reason: string,
|
|
1671
|
+
* unmatched: string[], reportedMatch: boolean|null}}
|
|
1672
|
+
*/
|
|
1673
|
+
export function auditHandoffEvidence(packet, changedPaths, opts = {}) {
|
|
1674
|
+
const source = opts.source === 'observed' ? 'observed' : 'reported'
|
|
1675
|
+
const evidence =
|
|
1676
|
+
packet && typeof packet === 'object' && Array.isArray(packet.evidence) ? packet.evidence : []
|
|
1677
|
+
|
|
1678
|
+
const index = diffIndex(changedPaths)
|
|
1679
|
+
const reported = diffIndex(opts.reportedPaths)
|
|
1680
|
+
// `null` means "the task reported no paths", which is not the same fact as
|
|
1681
|
+
// "the task reported paths and none of them match".
|
|
1682
|
+
const reportedMatch =
|
|
1683
|
+
reported && evidence.length ? evidence.every(e => locatorMatches(e, reported)) : null
|
|
1684
|
+
|
|
1685
|
+
const verdict = (verdict, reason, unmatched = []) => ({
|
|
1686
|
+
verdict,
|
|
1687
|
+
// Named only when a path set was actually used. A `not-audited` verdict
|
|
1688
|
+
// that still named a source would read as a check that ran.
|
|
1689
|
+
source: verdict === AUDIT_NOT_AUDITED ? null : source,
|
|
1690
|
+
reason,
|
|
1691
|
+
unmatched,
|
|
1692
|
+
reportedMatch
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
// Evidence is optional in the packet schema, so a packet without it is not
|
|
1696
|
+
// malformed — there is simply nothing to audit. Reporting that as
|
|
1697
|
+
// `unconfirmed` would blame a packet for a claim it never made.
|
|
1698
|
+
if (!evidence.length) {
|
|
1699
|
+
return verdict(AUDIT_NOT_AUDITED, 'the packet cites no evidence locators')
|
|
1700
|
+
}
|
|
1701
|
+
if (!index) {
|
|
1702
|
+
return verdict(
|
|
1703
|
+
AUDIT_NOT_AUDITED,
|
|
1704
|
+
'no changed-path set was available from either the observed source or the task\'s own report'
|
|
1705
|
+
)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const unmatched = evidence.filter(e => !locatorMatches(e, index))
|
|
1709
|
+
if (unmatched.length) {
|
|
1710
|
+
return verdict(
|
|
1711
|
+
AUDIT_UNCONFIRMED,
|
|
1712
|
+
`${unmatched.length} of ${evidence.length} locator(s) name a path absent from the ` +
|
|
1713
|
+
`${source} changed-path set: ${unmatched.map(e => JSON.stringify(e)).join(', ')}`,
|
|
1714
|
+
unmatched
|
|
1715
|
+
)
|
|
1716
|
+
}
|
|
1717
|
+
return verdict(
|
|
1718
|
+
AUDIT_CONFIRMED,
|
|
1719
|
+
`all ${evidence.length} locator(s) name a path in the ${source} changed-path set`
|
|
1720
|
+
)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function deepFreeze(value) {
|
|
1724
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
1725
|
+
Object.freeze(value)
|
|
1726
|
+
for (const inner of Object.values(value)) deepFreeze(inner)
|
|
1727
|
+
}
|
|
1728
|
+
return value
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Clone through JSON rather than by hand: it deep-copies, it strips anything a
|
|
1732
|
+
// state could not survive being persisted and rehydrated as, and it guarantees
|
|
1733
|
+
// no object the caller still holds ends up inside — or frozen by — a new state.
|
|
1734
|
+
function cloneState(state) {
|
|
1735
|
+
return JSON.parse(JSON.stringify(state))
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function finalize(state) {
|
|
1739
|
+
return deepFreeze(cloneState(state))
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function requireState(state) {
|
|
1743
|
+
if (!state || typeof state !== 'object' || !Array.isArray(state.waves) || !state.cursor) {
|
|
1744
|
+
fail('run state must be the object returned by createRunState')
|
|
1745
|
+
}
|
|
1746
|
+
return state
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* The shape a run was planned in. Absent means waves: every state written before
|
|
1751
|
+
* modes existed describes a waves run, and defaulting the other way would brief
|
|
1752
|
+
* a whole fleet of ordinary lanes as if each held a whole change.
|
|
1753
|
+
*/
|
|
1754
|
+
function runMode(state) {
|
|
1755
|
+
return state && state.mode === MODE_SOLO ? MODE_SOLO : MODE_WAVES
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function mutable(state) {
|
|
1759
|
+
requireState(state)
|
|
1760
|
+
if (state.halt) {
|
|
1761
|
+
fail(`run has halted (${state.halt.reason}); no further results can be recorded`)
|
|
1762
|
+
}
|
|
1763
|
+
const next = cloneState(state)
|
|
1764
|
+
// `record-batch` reports the batch it just recorded beside the state it
|
|
1765
|
+
// returns, so a caller that piped that stdout into its state file carries one
|
|
1766
|
+
// batch's outcomes on it. They describe work that is now history: a run
|
|
1767
|
+
// resumed from such a file must not present them as this batch's verdict.
|
|
1768
|
+
delete next.recorded
|
|
1769
|
+
return next
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** "wave 2" / "the test wave" — used in every reason string, so it lives once. */
|
|
1773
|
+
function label(wave) {
|
|
1774
|
+
if (!wave) return 'the end of the run'
|
|
1775
|
+
return wave.kind === 'test' ? 'the test wave' : `wave ${wave.group}`
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function makeWave(kind, group, tasks, maxParallel, laneCaps, opts = {}) {
|
|
1779
|
+
const serialized = []
|
|
1780
|
+
// A state written before the table existed carries no `laneCaps`. It reads as
|
|
1781
|
+
// the published table rather than as an error, the same fallback the
|
|
1782
|
+
// outcome-audit fields already make: a run resumed across an upgrade must
|
|
1783
|
+
// still be able to replan.
|
|
1784
|
+
const caps = laneCaps && typeof laneCaps === 'object' ? laneCaps : LANE_CAPS.byTier
|
|
1785
|
+
return {
|
|
1786
|
+
kind,
|
|
1787
|
+
group,
|
|
1788
|
+
// Preserved across a replan. The marker is what stops the red wave's
|
|
1789
|
+
// failing suite being treated as a defect, so a revision that rebuilt the
|
|
1790
|
+
// group without it would restore exactly the halt this wave exists to
|
|
1791
|
+
// avoid — and would do it only on replanned runs, which is the hardest
|
|
1792
|
+
// version of that bug to find.
|
|
1793
|
+
...(opts.red === true ? { red: true } : {}),
|
|
1794
|
+
taskCount: tasks.length,
|
|
1795
|
+
// A replanned group is laned under the SAME caps the rest of the run was
|
|
1796
|
+
// planned with, read off the state. Falling back to the current published
|
|
1797
|
+
// table would let a replan mid-run produce lanes of a different length than
|
|
1798
|
+
// the plan the run started from — including under a uniform override, which
|
|
1799
|
+
// is already folded into the table the state carries.
|
|
1800
|
+
//
|
|
1801
|
+
// And it is layered by `dependsOn` the way initial planning is, so a
|
|
1802
|
+
// revision that carries edges keeps their order. The layers become later
|
|
1803
|
+
// BATCHES here rather than separate waves, because a replan revises one
|
|
1804
|
+
// group into one wave — a later batch expresses the same ordering, which is
|
|
1805
|
+
// what the singleton fold would have collapsed the extra wave into anyway.
|
|
1806
|
+
batches: layerByDependencies(tasks).flatMap(
|
|
1807
|
+
layer => collisionAwareChunk(layer, maxParallel, serialized, group, caps).batches
|
|
1808
|
+
)
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// Accept a wave straight out of planWaves. The plan's batch boundaries are the
|
|
1813
|
+
// answer, not a suggestion: a batch wider than this run's ceiling is SPLIT, and
|
|
1814
|
+
// nothing is ever merged forward into an earlier batch.
|
|
1815
|
+
//
|
|
1816
|
+
// This used to flatten the wave and re-run the collision packer, which was
|
|
1817
|
+
// harmless while every later batch existed because of a path collision — the
|
|
1818
|
+
// packer would rebuild exactly the same boundary. It stopped being harmless
|
|
1819
|
+
// once the singleton fold started appending later batches whose task has a
|
|
1820
|
+
// *disjoint* path: re-packing would find no collision, see room under
|
|
1821
|
+
// maxParallel, and pull that task into batch 0 — running it in parallel with
|
|
1822
|
+
// the work it was ordered after. A higher runtime cap is not permission to
|
|
1823
|
+
// undo an ordering the plan recorded.
|
|
1824
|
+
function adoptWave(wave, kind, index, maxParallel, warnings) {
|
|
1825
|
+
if (!wave || typeof wave !== 'object') fail(`plan wave ${index} must be an object`)
|
|
1826
|
+
if (!Array.isArray(wave.batches)) fail(`plan wave ${index} must have a "batches" array`)
|
|
1827
|
+
wave.batches.forEach((batch, i) => {
|
|
1828
|
+
if (!Array.isArray(batch)) fail(`plan wave ${index} batch ${i} must be an array of lanes`)
|
|
1829
|
+
batch.forEach((lane, j) => {
|
|
1830
|
+
if (!Array.isArray(lane)) {
|
|
1831
|
+
fail(`plan wave ${index} batch ${i} lane ${j} must be an array of tasks`)
|
|
1832
|
+
}
|
|
1833
|
+
})
|
|
1834
|
+
})
|
|
1835
|
+
const group = kind === 'test' ? null : wave.group
|
|
1836
|
+
if (kind !== 'test' && !Number.isInteger(group)) {
|
|
1837
|
+
fail(`plan wave ${index} must have an integer "group"`)
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Width is measured in lanes, because a lane is what runs concurrently. A
|
|
1841
|
+
// batch of three lanes is three agents whatever the lanes hold.
|
|
1842
|
+
const batches = []
|
|
1843
|
+
let split = false
|
|
1844
|
+
for (const batch of wave.batches) {
|
|
1845
|
+
const lanes = batch.filter(lane => lane.length)
|
|
1846
|
+
if (!lanes.length) continue
|
|
1847
|
+
if (lanes.length > maxParallel) {
|
|
1848
|
+
split = true
|
|
1849
|
+
batches.push(...chunk(lanes, maxParallel))
|
|
1850
|
+
} else {
|
|
1851
|
+
batches.push(lanes.map(lane => lane.slice()))
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (split) {
|
|
1855
|
+
warnings.push(
|
|
1856
|
+
`${label({ kind, group })}: batches wider than ${maxParallel} were re-split to fit the cap`
|
|
1857
|
+
)
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
return {
|
|
1861
|
+
kind,
|
|
1862
|
+
group,
|
|
1863
|
+
// Carried from the plan rather than re-derived: whether a wave is the red
|
|
1864
|
+
// one is a fact about how it was PLANNED, and the run state is the only
|
|
1865
|
+
// thing the engine sees afterwards. Spread in only when true, so a state
|
|
1866
|
+
// for an ordinary change is unchanged — and a state written before red
|
|
1867
|
+
// waves existed reads as `undefined`, which every check below treats as
|
|
1868
|
+
// not-red rather than as an error.
|
|
1869
|
+
...(wave.red === true ? { red: true } : {}),
|
|
1870
|
+
taskCount: batches.reduce((n, b) => n + b.reduce((m, lane) => m + lane.length, 0), 0),
|
|
1871
|
+
batches
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function validateTasks(tasks, where) {
|
|
1876
|
+
if (!Array.isArray(tasks)) fail(`${where} must be an array of tasks`)
|
|
1877
|
+
tasks.forEach((t, i) => {
|
|
1878
|
+
if (!t || typeof t !== 'object') fail(`${where}[${i}] must be an object`)
|
|
1879
|
+
if (typeof t.id !== 'string' || !t.id.trim()) {
|
|
1880
|
+
fail(`${where}[${i}].id must be a non-empty string`)
|
|
1881
|
+
}
|
|
1882
|
+
})
|
|
1883
|
+
return tasks
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Start a run from a plan.
|
|
1888
|
+
*
|
|
1889
|
+
* @param {ReturnType<typeof planWaves>} plan
|
|
1890
|
+
* @param {{maxParallel?: number, change?: string}} [opts] `maxParallel` is a
|
|
1891
|
+
* runtime override, clamped by `clampParallel`, because over-asking is a
|
|
1892
|
+
* preference, not a mistake. `change` names the change this run ships and is
|
|
1893
|
+
* carried on the state for the life of the run.
|
|
1894
|
+
* @returns {object} a frozen, JSON-safe run state
|
|
1895
|
+
*/
|
|
1896
|
+
export function createRunState(plan, opts = {}) {
|
|
1897
|
+
if (!plan || typeof plan !== 'object') fail('createRunState needs the plan object from planWaves')
|
|
1898
|
+
if (!Array.isArray(plan.waves)) fail('plan must have a "waves" array — pass the output of planWaves')
|
|
1899
|
+
if (plan.testWave != null && typeof plan.testWave !== 'object') {
|
|
1900
|
+
fail('plan.testWave must be an object or null')
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const requested = Number.isInteger(opts.maxParallel) ? opts.maxParallel : plan.maxParallel
|
|
1904
|
+
const { value: maxParallel, clamped, reason } = clampParallel(requested)
|
|
1905
|
+
|
|
1906
|
+
const warnings = []
|
|
1907
|
+
if (clamped) warnings.push(`max parallel reduced to ${maxParallel}: ${reason}`)
|
|
1908
|
+
|
|
1909
|
+
const waves = plan.waves.map((w, i) => adoptWave(w, 'impl', i, maxParallel, warnings))
|
|
1910
|
+
if (plan.testWave) {
|
|
1911
|
+
waves.push(adoptWave(plan.testWave, 'test', waves.length, maxParallel, warnings))
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
return finalize({
|
|
1915
|
+
// Stable id for this run, stored on the frozen state so every later
|
|
1916
|
+
// wave-state call (and --write-state into .claude/ship/state.json) can
|
|
1917
|
+
// find it without a new flag.
|
|
1918
|
+
runId: randomUUID(),
|
|
1919
|
+
// The change this run ships, travelling the same route as `runId` for the
|
|
1920
|
+
// same reason: a per-invocation flag that every later wave-state call has to
|
|
1921
|
+
// remember is a flag that gets dropped, and it was — every recorded event in
|
|
1922
|
+
// this repo read `unnamed`. Named once, here. Absent stays absent (the JSON
|
|
1923
|
+
// clone below drops an undefined key), because a run with no name must still
|
|
1924
|
+
// run; `appendRunLogEvent` bounds and defaults it at the write site, which is
|
|
1925
|
+
// where the log's field limits already live.
|
|
1926
|
+
change: typeof opts.change === 'string' && opts.change.trim() ? opts.change.trim() : undefined,
|
|
1927
|
+
maxParallel,
|
|
1928
|
+
// The shape this run was planned in, so `wave-state next` can tell the host
|
|
1929
|
+
// how to brief the implementer without the host re-deciding it. A state
|
|
1930
|
+
// written before modes existed is a waves run — the only thing it could have
|
|
1931
|
+
// been.
|
|
1932
|
+
mode: plan.mode === MODE_SOLO ? MODE_SOLO : MODE_WAVES,
|
|
1933
|
+
// Carried so a replan lanes its revision the way the plan was laned — the
|
|
1934
|
+
// EFFECTIVE table, so a uniform override the plan was built under survives
|
|
1935
|
+
// into the revision rather than silently lapsing back to the published caps.
|
|
1936
|
+
laneCaps:
|
|
1937
|
+
plan.laneCaps && typeof plan.laneCaps === 'object'
|
|
1938
|
+
? { ...plan.laneCaps }
|
|
1939
|
+
: { ...LANE_CAPS.byTier },
|
|
1940
|
+
waves: waves.filter(w => w.batches.length > 0),
|
|
1941
|
+
// Where the walk is. `phase` is 'batch' while the wave at waveIndex still
|
|
1942
|
+
// has batches to run, 'verify' once it is finished and another wave follows.
|
|
1943
|
+
cursor: { waveIndex: 0, batchIndex: 0, phase: 'batch' },
|
|
1944
|
+
// Consecutive failed checks for the wave at the cursor: 0 means the check
|
|
1945
|
+
// has not run yet, N means N fix attempts have been consumed.
|
|
1946
|
+
verifyFailures: 0,
|
|
1947
|
+
lastVerifyErrors: [],
|
|
1948
|
+
replansUsed: 0,
|
|
1949
|
+
replanPending: false,
|
|
1950
|
+
// Groups with at least one recorded batch result. Replan is measured
|
|
1951
|
+
// against this, not against the cursor: a wave the cursor has reached but
|
|
1952
|
+
// nothing has been recorded for has not executed.
|
|
1953
|
+
executedGroups: [],
|
|
1954
|
+
// Validated per-task packets, keyed by task id. `nextStep` hands the
|
|
1955
|
+
// previous wave's back to the caller so a fresh implementer is given a
|
|
1956
|
+
// report rather than left to reconstruct one from git.
|
|
1957
|
+
handoffs: {},
|
|
1958
|
+
// What each task REPORTED changing, keyed by task id. Requested from every
|
|
1959
|
+
// implementer and, until this existed, thrown away — while being the only
|
|
1960
|
+
// cross-check available against the packet's own evidence. A report, never
|
|
1961
|
+
// presented as evidence that those paths changed.
|
|
1962
|
+
reportedPaths: {},
|
|
1963
|
+
// One `auditHandoffEvidence` verdict per stored packet. Read by a human at a
|
|
1964
|
+
// halt and by later scoring; read by no control-flow decision anywhere.
|
|
1965
|
+
evidenceAudits: {},
|
|
1966
|
+
completed: [],
|
|
1967
|
+
failures: [],
|
|
1968
|
+
unresolved: [],
|
|
1969
|
+
skippedVerifications: [],
|
|
1970
|
+
verificationsUsed: 0,
|
|
1971
|
+
warnings,
|
|
1972
|
+
halt: null
|
|
1973
|
+
})
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* What should happen next. Pure — call it as often as you like.
|
|
1978
|
+
*
|
|
1979
|
+
* @returns {{action: 'run-batch'|'test-wave'|'verify'|'replan'|'done'|'halt'}}
|
|
1980
|
+
*/
|
|
1981
|
+
export function nextStep(state) {
|
|
1982
|
+
requireState(state)
|
|
1983
|
+
|
|
1984
|
+
// On every payload, not only the one that dispatches an implementer. The host
|
|
1985
|
+
// reads a step and nothing else, so a field present on some steps and absent
|
|
1986
|
+
// on others is a field the host has to defend against — and a solo run whose
|
|
1987
|
+
// step forgot to say so briefs its one agent as an ordinary lane.
|
|
1988
|
+
const mode = runMode(state)
|
|
1989
|
+
|
|
1990
|
+
if (state.halt) {
|
|
1991
|
+
return {
|
|
1992
|
+
action: 'halt',
|
|
1993
|
+
mode,
|
|
1994
|
+
kind: state.halt.kind,
|
|
1995
|
+
reason: state.halt.reason,
|
|
1996
|
+
failures: state.failures,
|
|
1997
|
+
unresolved: state.unresolved
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const { waveIndex, batchIndex, phase } = state.cursor
|
|
2002
|
+
const wave = state.waves[waveIndex]
|
|
2003
|
+
if (!wave) return { action: 'done', mode, summary: summarize(state) }
|
|
2004
|
+
|
|
2005
|
+
const following = state.waves[waveIndex + 1] || null
|
|
2006
|
+
|
|
2007
|
+
if (phase === 'verify') {
|
|
2008
|
+
const used = state.verifyFailures
|
|
2009
|
+
return {
|
|
2010
|
+
action: 'verify',
|
|
2011
|
+
wave: wave.group,
|
|
2012
|
+
waveIndex,
|
|
2013
|
+
waveKind: wave.kind,
|
|
2014
|
+
// The ONE step whose `mode` is not the run's plan shape: this field named
|
|
2015
|
+
// the verify attempt (initial or fix) before plan modes existed, and both
|
|
2016
|
+
// hosts branch on it. The run mode is deliberately not echoed here rather
|
|
2017
|
+
// than shadowed by it — a solo plan is one wave, so it never reaches an
|
|
2018
|
+
// inter-wave verify, and nothing downstream of this step briefs an
|
|
2019
|
+
// implementer. `runMode` carries it for a reader that wants both.
|
|
2020
|
+
mode: used === 0 ? 'initial' : 'fix',
|
|
2021
|
+
runMode: mode,
|
|
2022
|
+
fixAttempt: used,
|
|
2023
|
+
fixAttemptsRemaining: Math.max(0, LIMITS.interWaveFixAttempts - used),
|
|
2024
|
+
errors: state.lastVerifyErrors,
|
|
2025
|
+
nextWave: following ? following.group : null,
|
|
2026
|
+
nextWaveKind: following ? following.kind : null,
|
|
2027
|
+
changed: uniquePaths(wave)
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Replan is offered only at a wave boundary — revising a group mid-wave would
|
|
2032
|
+
// mean revising work already in flight.
|
|
2033
|
+
if (batchIndex === 0 && state.replanPending) {
|
|
2034
|
+
const revisable = revisableGroups(state)
|
|
2035
|
+
if (state.replansUsed < LIMITS.replansPerRun && revisable.length) {
|
|
2036
|
+
return {
|
|
2037
|
+
action: 'replan',
|
|
2038
|
+
mode,
|
|
2039
|
+
revisableGroups: revisable,
|
|
2040
|
+
replansUsed: state.replansUsed,
|
|
2041
|
+
replansRemaining: LIMITS.replansPerRun - state.replansUsed
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
return {
|
|
2047
|
+
action: wave.kind === 'test' ? 'test-wave' : 'run-batch',
|
|
2048
|
+
mode,
|
|
2049
|
+
wave: wave.group,
|
|
2050
|
+
waveIndex,
|
|
2051
|
+
waveKind: wave.kind,
|
|
2052
|
+
batchIndex,
|
|
2053
|
+
batchCount: wave.batches.length,
|
|
2054
|
+
tasks: wave.batches[batchIndex],
|
|
2055
|
+
remainingBatches: wave.batches.slice(batchIndex),
|
|
2056
|
+
previousHandoffs: previousWaveHandoffs(state, waveIndex),
|
|
2057
|
+
// The wave's changed-file set, canonically deduplicated. A caller that
|
|
2058
|
+
// fuses the inter-wave verify plan into the record-batch ping (ship.js
|
|
2059
|
+
// does) needs it here, and recomputing it from raw `task.paths` is how
|
|
2060
|
+
// duplicate spellings inflate the set a verification is scoped to.
|
|
2061
|
+
changed: uniquePaths(wave),
|
|
2062
|
+
maxParallel: state.maxParallel
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* The packets recorded by the wave immediately before this one.
|
|
2068
|
+
*
|
|
2069
|
+
* Immediately before, and nothing else. Accumulating every earlier wave would
|
|
2070
|
+
* grow without bound and bury the one report that is actually load-bearing.
|
|
2071
|
+
* Earlier batches of the *current* wave are excluded too: `remainingBatches`
|
|
2072
|
+
* hands the caller a whole wave from one step, so every batch in it must see
|
|
2073
|
+
* the same input — a batch that saw its predecessor's handoff would be running
|
|
2074
|
+
* under a different contract than the batch beside it.
|
|
2075
|
+
*/
|
|
2076
|
+
function previousWaveHandoffs(state, waveIndex) {
|
|
2077
|
+
const previous = state.waves[waveIndex - 1]
|
|
2078
|
+
if (!previous) return []
|
|
2079
|
+
const stored = state.handoffs && typeof state.handoffs === 'object' ? state.handoffs : {}
|
|
2080
|
+
const audits =
|
|
2081
|
+
state.evidenceAudits && typeof state.evidenceAudits === 'object' ? state.evidenceAudits : {}
|
|
2082
|
+
const out = []
|
|
2083
|
+
for (const t of waveTasks(previous)) {
|
|
2084
|
+
const packet = stored[t.id]
|
|
2085
|
+
if (!packet) continue
|
|
2086
|
+
// The verdict travels with the packet it is about, so a reader of the step
|
|
2087
|
+
// is never left inferring which audit belongs to which report. It does NOT
|
|
2088
|
+
// change what the next implementer is told: the prompt renders taskId,
|
|
2089
|
+
// status, summary, evidence, next and blocker, and an unconfirmed packet
|
|
2090
|
+
// renders byte-identically to a confirmed one. Annotating the prompt would
|
|
2091
|
+
// tell an implementer to distrust its predecessor on an aggregate signal
|
|
2092
|
+
// (design.md — Decision 6).
|
|
2093
|
+
const audit = audits[t.id]
|
|
2094
|
+
out.push(audit ? { ...packet, audit } : packet)
|
|
2095
|
+
}
|
|
2096
|
+
return out
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Groups a replan may still touch: implementation waves at or after the first
|
|
2100
|
+
// index the cursor has not committed to, minus anything already executed.
|
|
2101
|
+
function firstMutableIndex(state) {
|
|
2102
|
+
const { waveIndex, batchIndex, phase } = state.cursor
|
|
2103
|
+
if (phase === 'verify' || batchIndex > 0) return waveIndex + 1
|
|
2104
|
+
return waveIndex
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function revisableGroups(state) {
|
|
2108
|
+
const from = firstMutableIndex(state)
|
|
2109
|
+
// Deduplicated: a group split into dependency layers occupies more than one
|
|
2110
|
+
// wave, and offering the same group number twice would read as two things a
|
|
2111
|
+
// replan could revise separately when a revision replaces the whole group.
|
|
2112
|
+
return [
|
|
2113
|
+
...new Set(
|
|
2114
|
+
state.waves
|
|
2115
|
+
.filter((w, i) => i >= from && w.kind === 'impl' && !state.executedGroups.includes(w.group))
|
|
2116
|
+
.map(w => w.group)
|
|
2117
|
+
)
|
|
2118
|
+
]
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function normalizeTaskResults(result) {
|
|
2122
|
+
if (!result || typeof result !== 'object') fail('batch result must be an object')
|
|
2123
|
+
if (!Array.isArray(result.tasks)) {
|
|
2124
|
+
fail('batch result must have a "tasks" array of { id, ok }')
|
|
2125
|
+
}
|
|
2126
|
+
return result.tasks.map((t, i) => {
|
|
2127
|
+
const at = `batch result tasks[${i}]`
|
|
2128
|
+
if (!t || typeof t !== 'object') fail(`${at} must be an object`)
|
|
2129
|
+
if (typeof t.id !== 'string' || !t.id.trim()) fail(`${at}.id must be a non-empty string`)
|
|
2130
|
+
if (typeof t.ok !== 'boolean') fail(`${at}.ok must be a boolean (task ${t.id})`)
|
|
2131
|
+
const error = typeof t.error === 'string' ? t.error : null
|
|
2132
|
+
// What the agent says it touched, kept as a REPORT. It is the cross-check
|
|
2133
|
+
// against the packet's own evidence, and it cannot be recovered later once
|
|
2134
|
+
// the working tree moves on — which is why it is retained here rather than
|
|
2135
|
+
// dropped as it was. Never substituted for by the evidence locators: a task
|
|
2136
|
+
// that reported no paths reported no paths.
|
|
2137
|
+
const filesChanged = Array.isArray(t.filesChanged)
|
|
2138
|
+
? t.filesChanged.filter(p => typeof p === 'string' && p.trim()).map(p => p.trim())
|
|
2139
|
+
: []
|
|
2140
|
+
const checked = validateHandoff(t.id, t.handoff)
|
|
2141
|
+
|
|
2142
|
+
// A task that already reported failure does not owe a packet. ship.js turns
|
|
2143
|
+
// a null agent result into ok:false with no handoff, and rewriting that as
|
|
2144
|
+
// an invalid-handoff error would replace the reason it actually failed with
|
|
2145
|
+
// a complaint about the report it was never in a position to write. A valid
|
|
2146
|
+
// packet on a failed task is still kept — a blocked task's blocker is the
|
|
2147
|
+
// most useful thing the next wave could be handed.
|
|
2148
|
+
if (!t.ok) {
|
|
2149
|
+
return {
|
|
2150
|
+
id: t.id,
|
|
2151
|
+
ok: false,
|
|
2152
|
+
error,
|
|
2153
|
+
filesChanged,
|
|
2154
|
+
handoff: checked.ok ? checked.handoff : null
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Claiming success is what obliges a report. Fail closed.
|
|
2159
|
+
if (!checked.ok) {
|
|
2160
|
+
return {
|
|
2161
|
+
id: t.id,
|
|
2162
|
+
ok: false,
|
|
2163
|
+
error: `invalid handoff: ${checked.error}`,
|
|
2164
|
+
filesChanged,
|
|
2165
|
+
handoff: null
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
return { id: t.id, ok: true, error, filesChanged, handoff: checked.handoff }
|
|
2169
|
+
})
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function advanceWave(state) {
|
|
2173
|
+
state.cursor = { waveIndex: state.cursor.waveIndex + 1, batchIndex: 0, phase: 'batch' }
|
|
2174
|
+
state.verifyFailures = 0
|
|
2175
|
+
state.lastVerifyErrors = []
|
|
2176
|
+
return state
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/**
|
|
2180
|
+
* Record the outcome of the batch `nextStep` last asked for.
|
|
2181
|
+
*
|
|
2182
|
+
* @param {object} state
|
|
2183
|
+
* @param {{tasks: Array<{id: string, ok: boolean, error?: string,
|
|
2184
|
+
* filesChanged?: string[]}>, replanSuggested?: boolean}} result
|
|
2185
|
+
* @param {{changedPaths?: string[]}} [opts] the OBSERVED changed-path set, read
|
|
2186
|
+
* from version control by the caller at this moment — the only point at which
|
|
2187
|
+
* the working tree still matches what the implementer just claimed. It is a
|
|
2188
|
+
* separate argument rather than a field on `result` on purpose: `result` is
|
|
2189
|
+
* written by the agent being audited, and an audit whose own input the subject
|
|
2190
|
+
* supplies is not an audit. Absent, each task is audited against its own
|
|
2191
|
+
* reported paths and the verdict says so.
|
|
2192
|
+
* @returns {object} a new state; `state` is left untouched
|
|
2193
|
+
*/
|
|
2194
|
+
export function recordBatchResult(state, result, opts = {}) {
|
|
2195
|
+
const next = mutable(state)
|
|
2196
|
+
const wave = next.waves[next.cursor.waveIndex]
|
|
2197
|
+
if (!wave || next.cursor.phase !== 'batch') {
|
|
2198
|
+
fail('recordBatchResult called while the run is not waiting on a batch — ask nextStep first')
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const tasks = normalizeTaskResults(result)
|
|
2202
|
+
// An in-flight state.json written before handoffs existed has no such key.
|
|
2203
|
+
if (!next.handoffs || typeof next.handoffs !== 'object') next.handoffs = {}
|
|
2204
|
+
// Same guard, same reason: a state written before these fields existed is
|
|
2205
|
+
// initialized, never treated as malformed. (design.md — Migration Plan.)
|
|
2206
|
+
if (!next.reportedPaths || typeof next.reportedPaths !== 'object') next.reportedPaths = {}
|
|
2207
|
+
if (!next.evidenceAudits || typeof next.evidenceAudits !== 'object') next.evidenceAudits = {}
|
|
2208
|
+
|
|
2209
|
+
// Resolved once per batch: git's answer is about the working tree, not about
|
|
2210
|
+
// one task, so asking it per task would be the same answer at a higher cost
|
|
2211
|
+
// and would imply an attribution it cannot make.
|
|
2212
|
+
const observed = Array.isArray(opts.changedPaths)
|
|
2213
|
+
? opts.changedPaths.filter(p => typeof p === 'string' && p.trim())
|
|
2214
|
+
: []
|
|
2215
|
+
|
|
2216
|
+
for (const t of tasks) {
|
|
2217
|
+
// Stored for every task that was actually reported on, including a failed
|
|
2218
|
+
// one: what a failed task touched is exactly what the next reader needs.
|
|
2219
|
+
next.reportedPaths[t.id] = t.filesChanged
|
|
2220
|
+
if (t.handoff) {
|
|
2221
|
+
next.handoffs[t.id] = t.handoff
|
|
2222
|
+
// Recorded, never gated on. Nothing below this line reads it.
|
|
2223
|
+
next.evidenceAudits[t.id] = auditHandoffEvidence(
|
|
2224
|
+
t.handoff,
|
|
2225
|
+
observed.length ? observed : t.filesChanged,
|
|
2226
|
+
{ source: observed.length ? 'observed' : 'reported', reportedPaths: t.filesChanged }
|
|
2227
|
+
)
|
|
2228
|
+
}
|
|
2229
|
+
if (t.ok) next.completed.push(t.id)
|
|
2230
|
+
else {
|
|
2231
|
+
next.failures.push({
|
|
2232
|
+
id: t.id,
|
|
2233
|
+
wave: wave.group,
|
|
2234
|
+
waveKind: wave.kind,
|
|
2235
|
+
error: t.error
|
|
2236
|
+
})
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
if (wave.kind === 'impl' && !next.executedGroups.includes(wave.group)) {
|
|
2241
|
+
next.executedGroups.push(wave.group)
|
|
2242
|
+
}
|
|
2243
|
+
if (result.replanSuggested === true) next.replanPending = true
|
|
2244
|
+
|
|
2245
|
+
next.cursor.batchIndex += 1
|
|
2246
|
+
if (next.cursor.batchIndex >= wave.batches.length) {
|
|
2247
|
+
if (next.waves[next.cursor.waveIndex + 1]) {
|
|
2248
|
+
const used = Number.isInteger(next.verificationsUsed) ? next.verificationsUsed : 0
|
|
2249
|
+
if (isDocsOnlyWave(wave)) {
|
|
2250
|
+
next.skippedVerifications.push({
|
|
2251
|
+
wave: wave.group,
|
|
2252
|
+
waveKind: wave.kind,
|
|
2253
|
+
reason: SKIP_VERIFY_DOCS
|
|
2254
|
+
})
|
|
2255
|
+
advanceWave(next)
|
|
2256
|
+
} else if (wave.red === true) {
|
|
2257
|
+
// Ahead of the cap check on purpose: the red wave must not consume one
|
|
2258
|
+
// of the run's inter-wave verification slots. Checking it would mean
|
|
2259
|
+
// spending `LIMITS.interWaveFixAttempts` on a suite that is failing
|
|
2260
|
+
// because it was asked to, and then halting the run over it.
|
|
2261
|
+
next.skippedVerifications.push({
|
|
2262
|
+
wave: wave.group,
|
|
2263
|
+
waveKind: wave.kind,
|
|
2264
|
+
reason: SKIP_VERIFY_RED
|
|
2265
|
+
})
|
|
2266
|
+
advanceWave(next)
|
|
2267
|
+
} else if (used >= LIMITS.interWaveVerifications) {
|
|
2268
|
+
next.skippedVerifications.push({
|
|
2269
|
+
wave: wave.group,
|
|
2270
|
+
waveKind: wave.kind,
|
|
2271
|
+
reason: SKIP_VERIFY_CAP
|
|
2272
|
+
})
|
|
2273
|
+
advanceWave(next)
|
|
2274
|
+
} else {
|
|
2275
|
+
next.cursor.phase = 'verify'
|
|
2276
|
+
next.verificationsUsed = used + 1
|
|
2277
|
+
next.verifyFailures = 0
|
|
2278
|
+
next.lastVerifyErrors = []
|
|
2279
|
+
}
|
|
2280
|
+
} else {
|
|
2281
|
+
advanceWave(next)
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
// Checked last, so the failures that tripped the cap are already in the state
|
|
2286
|
+
// the halt is reported from. The trailing test wave counts like any other.
|
|
2287
|
+
if (next.failures.length > LIMITS.taskFailureHalt) {
|
|
2288
|
+
next.halt = {
|
|
2289
|
+
kind: HALT_TASK_FAILURES,
|
|
2290
|
+
reason:
|
|
2291
|
+
`${next.failures.length} task failures accumulated across waves; ` +
|
|
2292
|
+
`more than ${LIMITS.taskFailureHalt} halts the run`
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
return finalize(next)
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// ---------------------------------------------------------------------------
|
|
2300
|
+
// What a batch recorded
|
|
2301
|
+
// ---------------------------------------------------------------------------
|
|
2302
|
+
//
|
|
2303
|
+
// `recordBatchResult` already adjudicates every task — an invalid or oversized
|
|
2304
|
+
// packet fails it, a lane result missing an outcome fails the whole lane — and
|
|
2305
|
+
// then tells its caller nothing but the next action. So a caller learned what
|
|
2306
|
+
// to do next and never learned what had just been decided, and both ship hosts
|
|
2307
|
+
// ticked checkboxes and counted waves from the agent's own claim instead. That
|
|
2308
|
+
// is how five tasks the machine failed got ticked beside a halt naming them.
|
|
2309
|
+
//
|
|
2310
|
+
// The delta is DERIVED from two states rather than stored on one: `recorded`
|
|
2311
|
+
// describes one batch, and a durable field holding it would be read as current
|
|
2312
|
+
// by a run resumed a day later.
|
|
2313
|
+
|
|
2314
|
+
/** A task the batch recorded as done. */
|
|
2315
|
+
export const OUTCOME_OK = 'ok'
|
|
2316
|
+
/** A task the batch adjudicated as failed, with the reason it was failed for. */
|
|
2317
|
+
export const OUTCOME_FAILED = 'failed'
|
|
2318
|
+
/**
|
|
2319
|
+
* A task in the batch that no result accounted for. Distinct from failed on
|
|
2320
|
+
* purpose: it is ticked nowhere and counted in no failure budget.
|
|
2321
|
+
*/
|
|
2322
|
+
export const OUTCOME_NOT_ATTEMPTED = 'not-attempted'
|
|
2323
|
+
|
|
2324
|
+
/** An adjudication reason travels through an agent, so it is a string, not a log. */
|
|
2325
|
+
const MAX_OUTCOME_REASON = 300
|
|
2326
|
+
|
|
2327
|
+
function shortReason(reason) {
|
|
2328
|
+
const text = typeof reason === 'string' ? reason.trim() : ''
|
|
2329
|
+
if (!text) return undefined
|
|
2330
|
+
return text.length > MAX_OUTCOME_REASON ? `${text.slice(0, MAX_OUTCOME_REASON - 1)}…` : text
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/** The task ids the batch at `state`'s cursor was supposed to account for. */
|
|
2334
|
+
function plannedBatchIds(state) {
|
|
2335
|
+
const wave =
|
|
2336
|
+
state && Array.isArray(state.waves) && state.cursor
|
|
2337
|
+
? state.waves[state.cursor.waveIndex]
|
|
2338
|
+
: null
|
|
2339
|
+
const batch = wave && Array.isArray(wave.batches) ? wave.batches[state.cursor.batchIndex] : null
|
|
2340
|
+
if (!Array.isArray(batch)) return []
|
|
2341
|
+
return batch
|
|
2342
|
+
.flat(1)
|
|
2343
|
+
.map(t => (t && typeof t.id === 'string' ? t.id : null))
|
|
2344
|
+
.filter(Boolean)
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* The per-task outcomes one `recordBatchResult` call recorded.
|
|
2349
|
+
*
|
|
2350
|
+
* Pure, and derived: `recordBatchResult` appends to `completed` and `failures`,
|
|
2351
|
+
* so this batch's verdicts are the tails of both, and a planned task in neither
|
|
2352
|
+
* was never attempted. Nothing here reads a field the recorder stored, and
|
|
2353
|
+
* nothing here is written back onto a state.
|
|
2354
|
+
*
|
|
2355
|
+
* @param {object} before the state passed to `recordBatchResult`
|
|
2356
|
+
* @param {object} after the state it returned
|
|
2357
|
+
* @returns {Array<{id: string, outcome: string, reason?: string}>} this batch's
|
|
2358
|
+
* outcomes only — never the run's accumulated history. Ordered by the plan,
|
|
2359
|
+
* with anything recorded that the plan did not name appended after it.
|
|
2360
|
+
*/
|
|
2361
|
+
export function batchOutcomes(before, after) {
|
|
2362
|
+
const prevCompleted = before && Array.isArray(before.completed) ? before.completed.length : 0
|
|
2363
|
+
const prevFailures = before && Array.isArray(before.failures) ? before.failures.length : 0
|
|
2364
|
+
const completed = after && Array.isArray(after.completed) ? after.completed.slice(prevCompleted) : []
|
|
2365
|
+
const failures = after && Array.isArray(after.failures) ? after.failures.slice(prevFailures) : []
|
|
2366
|
+
|
|
2367
|
+
const outcomes = new Map()
|
|
2368
|
+
for (const id of completed) {
|
|
2369
|
+
if (typeof id === 'string') outcomes.set(id, { id, outcome: OUTCOME_OK })
|
|
2370
|
+
}
|
|
2371
|
+
for (const f of failures) {
|
|
2372
|
+
if (!f || typeof f.id !== 'string') continue
|
|
2373
|
+
const reason = shortReason(f.error)
|
|
2374
|
+
outcomes.set(f.id, reason ? { id: f.id, outcome: OUTCOME_FAILED, reason } : { id: f.id, outcome: OUTCOME_FAILED })
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const ordered = []
|
|
2378
|
+
for (const id of plannedBatchIds(before)) {
|
|
2379
|
+
if (ordered.some(o => o.id === id)) continue
|
|
2380
|
+
ordered.push(
|
|
2381
|
+
outcomes.get(id) || {
|
|
2382
|
+
id,
|
|
2383
|
+
outcome: OUTCOME_NOT_ATTEMPTED,
|
|
2384
|
+
reason: 'the batch result accounted for no outcome for this task'
|
|
2385
|
+
}
|
|
2386
|
+
)
|
|
2387
|
+
}
|
|
2388
|
+
for (const [id, outcome] of outcomes) {
|
|
2389
|
+
if (!ordered.some(o => o.id === id)) ordered.push(outcome)
|
|
2390
|
+
}
|
|
2391
|
+
return ordered
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function normalizeErrors(errors) {
|
|
2395
|
+
if (!Array.isArray(errors)) return []
|
|
2396
|
+
return errors.filter(e => typeof e === 'string' && e.trim())
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* Record the outcome of an inter-wave check.
|
|
2401
|
+
*
|
|
2402
|
+
* A failure buys up to `LIMITS.interWaveFixAttempts` targeted fix attempts for
|
|
2403
|
+
* that wave. When those are spent the errors are logged and the run halts if
|
|
2404
|
+
* the caller says they block the next wave, or continues with a warning if not.
|
|
2405
|
+
* Whether they block is an input, never an inference: only the caller can see
|
|
2406
|
+
* what the next wave is about to touch.
|
|
2407
|
+
*
|
|
2408
|
+
* @param {object} state
|
|
2409
|
+
* @param {{ok?: boolean, errors?: string[], blocksNextWave?: boolean,
|
|
2410
|
+
* skipped?: boolean, reason?: string, replanSuggested?: boolean}} result
|
|
2411
|
+
* @returns {object} a new state; `state` is left untouched
|
|
2412
|
+
*/
|
|
2413
|
+
export function recordVerifyResult(state, result) {
|
|
2414
|
+
const next = mutable(state)
|
|
2415
|
+
if (next.cursor.phase !== 'verify') {
|
|
2416
|
+
fail('recordVerifyResult called while the run is not waiting on a check — ask nextStep first')
|
|
2417
|
+
}
|
|
2418
|
+
if (!result || typeof result !== 'object') fail('verify result must be an object')
|
|
2419
|
+
|
|
2420
|
+
const wave = next.waves[next.cursor.waveIndex]
|
|
2421
|
+
const following = next.waves[next.cursor.waveIndex + 1] || null
|
|
2422
|
+
if (result.replanSuggested === true) next.replanPending = true
|
|
2423
|
+
|
|
2424
|
+
// A skip is a legitimate outcome — no detectable commands, pre-existing
|
|
2425
|
+
// failures, over budget — but it is never silent: the reason is the banner.
|
|
2426
|
+
if (result.skipped === true) {
|
|
2427
|
+
const reason = typeof result.reason === 'string' ? result.reason.trim() : ''
|
|
2428
|
+
if (!reason) fail('a skipped verify result must carry a non-empty "reason"')
|
|
2429
|
+
next.skippedVerifications.push({ wave: wave.group, waveKind: wave.kind, reason })
|
|
2430
|
+
return finalize(advanceWave(next))
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
if (typeof result.ok !== 'boolean') {
|
|
2434
|
+
fail('verify result must have a boolean "ok" (or skipped: true with a reason)')
|
|
2435
|
+
}
|
|
2436
|
+
if (result.ok) return finalize(advanceWave(next))
|
|
2437
|
+
|
|
2438
|
+
const errors = normalizeErrors(result.errors)
|
|
2439
|
+
next.verifyFailures += 1
|
|
2440
|
+
next.lastVerifyErrors = errors
|
|
2441
|
+
|
|
2442
|
+
// Still inside the fix budget: the next step is another targeted fix and a
|
|
2443
|
+
// re-run of the failing check.
|
|
2444
|
+
if (next.verifyFailures <= LIMITS.interWaveFixAttempts) return finalize(next)
|
|
2445
|
+
|
|
2446
|
+
const blocks = result.blocksNextWave === true
|
|
2447
|
+
next.unresolved.push({
|
|
2448
|
+
wave: wave.group,
|
|
2449
|
+
waveKind: wave.kind,
|
|
2450
|
+
attempts: LIMITS.interWaveFixAttempts,
|
|
2451
|
+
errors,
|
|
2452
|
+
blockedNextWave: blocks
|
|
2453
|
+
})
|
|
2454
|
+
|
|
2455
|
+
if (blocks) {
|
|
2456
|
+
next.halt = {
|
|
2457
|
+
kind: HALT_INTER_WAVE_VERIFY,
|
|
2458
|
+
reason:
|
|
2459
|
+
`inter-wave checks after ${label(wave)} still failing after ` +
|
|
2460
|
+
`${LIMITS.interWaveFixAttempts} fix attempts, and the caller reported the ` +
|
|
2461
|
+
`errors block ${label(following)}`
|
|
2462
|
+
}
|
|
2463
|
+
return finalize(next)
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
next.warnings.push(
|
|
2467
|
+
`${label(wave)}: ${errors.length} unresolved error(s) after ` +
|
|
2468
|
+
`${LIMITS.interWaveFixAttempts} fix attempts; continuing — reported as not blocking ` +
|
|
2469
|
+
`${label(following)}`
|
|
2470
|
+
)
|
|
2471
|
+
return finalize(advanceWave(next))
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* Revise groups that have not executed yet.
|
|
2476
|
+
*
|
|
2477
|
+
* Revising a group that has already run is a user-facing error rather than a
|
|
2478
|
+
* quietly-ignored request: the caller believes it changed the plan, and a run
|
|
2479
|
+
* that says otherwise only in a log has already misled it.
|
|
2480
|
+
*
|
|
2481
|
+
* @param {object} state
|
|
2482
|
+
* @param {Array<{group: number, tasks: Array}>} revisedGroups
|
|
2483
|
+
* an empty `tasks` array drops that group's wave
|
|
2484
|
+
* @returns {object} a new state; `state` is left untouched
|
|
2485
|
+
*/
|
|
2486
|
+
export function applyReplan(state, revisedGroups) {
|
|
2487
|
+
const next = mutable(state)
|
|
2488
|
+
if (!Array.isArray(revisedGroups) || !revisedGroups.length) {
|
|
2489
|
+
fail('applyReplan needs a non-empty array of { group, tasks } revisions')
|
|
2490
|
+
}
|
|
2491
|
+
if (next.replansUsed >= LIMITS.replansPerRun) {
|
|
2492
|
+
fail(
|
|
2493
|
+
`replan cap reached: ${LIMITS.replansPerRun} replans per run, ` +
|
|
2494
|
+
`${next.replansUsed} already used`
|
|
2495
|
+
)
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// Validate every revision before applying any of them, so a rejected replan
|
|
2499
|
+
// leaves the run exactly where it was.
|
|
2500
|
+
for (const revision of revisedGroups) {
|
|
2501
|
+
if (!revision || typeof revision !== 'object') fail('each revision must be an object')
|
|
2502
|
+
if (!Number.isInteger(revision.group)) {
|
|
2503
|
+
fail('each revision must name an integer "group"; the trailing test wave cannot be replanned')
|
|
2504
|
+
}
|
|
2505
|
+
if (next.executedGroups.includes(revision.group)) {
|
|
2506
|
+
fail(`cannot replan group ${revision.group}: it has already executed`)
|
|
2507
|
+
}
|
|
2508
|
+
validateTasks(revision.tasks, `revision for group ${revision.group}: "tasks"`)
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
for (const revision of revisedGroups) {
|
|
2512
|
+
const from = firstMutableIndex(next)
|
|
2513
|
+
const at = next.waves.findIndex(w => w.kind === 'impl' && w.group === revision.group)
|
|
2514
|
+
|
|
2515
|
+
if (at >= 0) {
|
|
2516
|
+
if (at < from) fail(`cannot replan group ${revision.group}: it has already executed`)
|
|
2517
|
+
// A group occupies more than one wave once dependency layers split it, and
|
|
2518
|
+
// those waves are contiguous. Every one of them is replaced by the single
|
|
2519
|
+
// revised wave: leaving a later layer in place would run the tasks the
|
|
2520
|
+
// revision superseded after the tasks that replaced them.
|
|
2521
|
+
const spans = next.waves.filter(w => w.kind === 'impl' && w.group === revision.group).length
|
|
2522
|
+
// Read off the wave being replaced, not recomputed: the red wave is a
|
|
2523
|
+
// decision the PLAN made, and a replan revises a group's tasks, never
|
|
2524
|
+
// which group leads with the failing suite.
|
|
2525
|
+
const wasRed = next.waves[at] && next.waves[at].red === true
|
|
2526
|
+
if (!revision.tasks.length) next.waves.splice(at, spans)
|
|
2527
|
+
else {
|
|
2528
|
+
next.waves.splice(
|
|
2529
|
+
at,
|
|
2530
|
+
spans,
|
|
2531
|
+
makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps, {
|
|
2532
|
+
red: wasRed
|
|
2533
|
+
})
|
|
2534
|
+
)
|
|
2535
|
+
}
|
|
2536
|
+
continue
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
if (!revision.tasks.length) continue // dropping a group that is not there
|
|
2540
|
+
if (from >= next.waves.length && from > 0) {
|
|
2541
|
+
fail(`cannot replan group ${revision.group}: the run has no unexecuted groups left`)
|
|
2542
|
+
}
|
|
2543
|
+
// A group the plan never had is inserted in ascending order, but never
|
|
2544
|
+
// before the cursor and never after the trailing test wave.
|
|
2545
|
+
let insertAt = next.waves.length
|
|
2546
|
+
for (let i = from; i < next.waves.length; i++) {
|
|
2547
|
+
const w = next.waves[i]
|
|
2548
|
+
if (w.kind === 'test' || w.group > revision.group) {
|
|
2549
|
+
insertAt = i
|
|
2550
|
+
break
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
next.waves.splice(insertAt, 0, makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps))
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
next.replansUsed += 1
|
|
2557
|
+
next.replanPending = false
|
|
2558
|
+
return finalize(next)
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function summarize(state) {
|
|
2562
|
+
return {
|
|
2563
|
+
waves: state.waves.length,
|
|
2564
|
+
tasksCompleted: state.completed.length,
|
|
2565
|
+
taskFailures: state.failures.length,
|
|
2566
|
+
replansUsed: state.replansUsed,
|
|
2567
|
+
unresolved: state.unresolved,
|
|
2568
|
+
skippedVerifications: state.skippedVerifications,
|
|
2569
|
+
verificationsUsed: state.verificationsUsed || 0,
|
|
2570
|
+
warnings: state.warnings
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/** Tasks across a batch of lanes — one agent per lane, N tasks inside them. */
|
|
2575
|
+
function countTasks(batch) {
|
|
2576
|
+
return (Array.isArray(batch) ? batch : []).reduce(
|
|
2577
|
+
(n, lane) => n + (Array.isArray(lane) ? lane.length : 0),
|
|
2578
|
+
0
|
|
2579
|
+
)
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function describeStep(step) {
|
|
2583
|
+
switch (step.action) {
|
|
2584
|
+
case 'run-batch':
|
|
2585
|
+
return `run wave ${step.wave} batch ${step.batchIndex + 1}/${step.batchCount} ` +
|
|
2586
|
+
`(${step.tasks.length} lane(s), ${countTasks(step.tasks)} task(s), ` +
|
|
2587
|
+
`max ${step.maxParallel} parallel)`
|
|
2588
|
+
case 'test-wave':
|
|
2589
|
+
return `run the test wave, batch ${step.batchIndex + 1}/${step.batchCount} ` +
|
|
2590
|
+
`(${step.tasks.length} lane(s), ${countTasks(step.tasks)} task(s))`
|
|
2591
|
+
case 'verify':
|
|
2592
|
+
return step.mode === 'initial'
|
|
2593
|
+
? `verify after ${step.waveKind === 'test' ? 'the test wave' : `wave ${step.wave}`}`
|
|
2594
|
+
: `fix and re-verify (attempt ${step.fixAttempt} of ${LIMITS.interWaveFixAttempts})`
|
|
2595
|
+
case 'replan':
|
|
2596
|
+
return `replan available for group(s) ${step.revisableGroups.join(', ')} ` +
|
|
2597
|
+
`(${step.replansRemaining} left)`
|
|
2598
|
+
case 'halt':
|
|
2599
|
+
return `HALTED — ${step.reason}`
|
|
2600
|
+
default:
|
|
2601
|
+
return 'done'
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/** Human-readable run state, for the skill to echo between steps. */
|
|
2606
|
+
export function formatRunState(state) {
|
|
2607
|
+
requireState(state)
|
|
2608
|
+
const step = nextStep(state)
|
|
2609
|
+
const lines = []
|
|
2610
|
+
lines.push(
|
|
2611
|
+
`run: ${state.completed.length} task(s) done, ${state.failures.length} failed ` +
|
|
2612
|
+
`(halts above ${LIMITS.taskFailureHalt}), ` +
|
|
2613
|
+
`${state.replansUsed}/${LIMITS.replansPerRun} replans used`
|
|
2614
|
+
)
|
|
2615
|
+
lines.push(` next: ${describeStep(step)}`)
|
|
2616
|
+
for (const f of state.failures) {
|
|
2617
|
+
lines.push(
|
|
2618
|
+
` failed ${f.id} in ${label({ kind: f.waveKind, group: f.wave })}` +
|
|
2619
|
+
(f.error ? `: ${f.error}` : '')
|
|
2620
|
+
)
|
|
2621
|
+
}
|
|
2622
|
+
for (const u of state.unresolved) {
|
|
2623
|
+
lines.push(
|
|
2624
|
+
` unresolved after ${label({ kind: u.waveKind, group: u.wave })}: ` +
|
|
2625
|
+
`${u.errors.length} error(s) after ${u.attempts} fix attempts` +
|
|
2626
|
+
(u.blockedNextWave ? ' (blocked the next wave)' : ' (did not block the next wave)')
|
|
2627
|
+
)
|
|
2628
|
+
}
|
|
2629
|
+
for (const s of state.skippedVerifications) {
|
|
2630
|
+
lines.push(` VERIFICATION SKIPPED: reason=${s.reason}`)
|
|
2631
|
+
}
|
|
2632
|
+
// The audit is recorded, so it has to be readable — a verdict nobody ever sees
|
|
2633
|
+
// is a verdict that may as well not exist. Printed as a tally plus a line per
|
|
2634
|
+
// packet that did NOT confirm: a run's confirmed packets are the boring case
|
|
2635
|
+
// and listing all of them would bury the two that are not.
|
|
2636
|
+
//
|
|
2637
|
+
// "recorded only" is in the banner on purpose. Anyone reading a halt beside a
|
|
2638
|
+
// column of `unconfirmed` will wonder whether the audit caused it; the line
|
|
2639
|
+
// itself answers that, at the one moment the question gets asked.
|
|
2640
|
+
const audits = Object.entries(
|
|
2641
|
+
state.evidenceAudits && typeof state.evidenceAudits === 'object' ? state.evidenceAudits : {}
|
|
2642
|
+
).filter(([, a]) => a && typeof a === 'object')
|
|
2643
|
+
if (audits.length) {
|
|
2644
|
+
const tally = { [AUDIT_CONFIRMED]: 0, [AUDIT_UNCONFIRMED]: 0, [AUDIT_NOT_AUDITED]: 0 }
|
|
2645
|
+
for (const [, a] of audits) if (a.verdict in tally) tally[a.verdict] += 1
|
|
2646
|
+
lines.push(
|
|
2647
|
+
` evidence audit: ${tally[AUDIT_CONFIRMED]} confirmed, ` +
|
|
2648
|
+
`${tally[AUDIT_UNCONFIRMED]} unconfirmed, ${tally[AUDIT_NOT_AUDITED]} not audited ` +
|
|
2649
|
+
`(recorded only — never gates the run)`
|
|
2650
|
+
)
|
|
2651
|
+
for (const [id, a] of audits) {
|
|
2652
|
+
if (a.verdict === AUDIT_CONFIRMED) continue
|
|
2653
|
+
lines.push(
|
|
2654
|
+
` ${a.verdict} ${id}${a.source ? ` [${a.source}]` : ''}: ${a.reason}`
|
|
2655
|
+
)
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
for (const w of state.warnings) lines.push(` warning: ${w}`)
|
|
2659
|
+
if (state.halt) lines.push(`HALTED: ${state.halt.reason}`)
|
|
2660
|
+
return lines.join('\n') + '\n'
|
|
2661
|
+
}
|