@renzrollon/interlock 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +8 -0
- package/bin/interlock +4 -16
- package/lib/limits.mjs +6 -6
- package/lib/plan-fingerprint.mjs +8 -7
- package/lib/run.mjs +16 -1
- package/lib/waves.mjs +248 -83
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.1",
|
|
5
5
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Carl Rollon",
|
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,14 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
34
34
|
|
|
35
35
|
See [when it stops](docs/04-when-it-stops.md#two-different-caches-and-only-one-of-them-is-on-this-page), which now separates the two things this repository had been calling "cache" — the Workflow **replay** cache (keyed on exact prompt text, what makes a resumed run skip completed agents) from the Anthropic **prompt** cache (keyed on the request prefix, expiring on idleness) — and [why it works §4.4](docs/06-why-it-works.md), where the token-economy argument now reaches prefix *lifetime* and not only prefix *size*.
|
|
36
36
|
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **A lane's model follows whether it can run in parallel.** A lane of two or more tasks — a path collision, a cohesion pack, or a chain — dispatches on opus. A lane of one task keeps that task's clamped model: haiku or sonnet below tier 5, opus only on a tier-5 task. The task's recorded model is not rewritten to match the dispatch, and effort stays derived from the hardest task's tier, so an opus lane of tier-1 or tier-2 work still runs at low effort. A host that maps slugs (`INTERLOCK_MODEL_MAP`) has to map `opus` as well as `sonnet`, because a multi-task lane now asks for it.
|
|
40
|
+
|
|
41
|
+
**Chain lanes.** After cohesion and the singleton fold, consecutive batches of one wave that each hold a single lane are fused into one agent, in that batch order, under the same `LANE_CAPS.byTier` entry as the chain's hardest tier. A batch of two or more lanes is left parallel, and a chain never crosses a wave — including the red/next boundary and the implementation/test boundary. Solo plans are not fused. The "effectively serial" warning still counts lanes before fusion; the projected agent bill counts the lanes that dispatch after it. The plan format is bumped to `interlock.ship-plan/4`: a stored `/3` plan is rebuilt, because reusing one would dispatch the old shape.
|
|
42
|
+
|
|
43
|
+
**One lost agent is one failure.** A multi-task lane whose agent returns no result marks the first task failed and the rest not-attempted, so the failure budget charges one failure for one lost agent. An omitted per-task outcome still fails every task in the lane.
|
|
44
|
+
|
|
37
45
|
### Fixed
|
|
38
46
|
|
|
39
47
|
- **A failed write to the run trajectory now halts the run it was recording, instead of warning and shipping anyway.** `.claude/ship/runs` has been `fatal: true` in `interlock doctor` since it existed, and `wave-state` and `verify judge` have always exited non-zero when their append did not land. The live `interlock run` path was the one writer that did not: it called `ctx.warn` and walked on — into the task tick, and into the commit. The outcome was precisely the artifact the corpus exists to prevent: a run that ticked its boxes, committed, and cannot be replayed. It now halts at every site that owes the trajectory a line — `run-start`, `wave-state create` / `record-batch` / `record-verify` / `replan`, `agent-spawn` (implementer lanes included, which go through the same writer `wave-state next` uses) and `cli-exit` — **before** any tick and before any commit, with the reason `trajectory append failed: <site>: <cause>`. The cause is the filesystem's own, threaded out of the writer rather than left on a stderr the Workflow host does not relay; a full disk now says so by name. See [when it stops](docs/04-when-it-stops.md#the-loud-halts).
|
package/bin/interlock
CHANGED
|
@@ -21,6 +21,8 @@ import { performance } from 'node:perf_hooks'
|
|
|
21
21
|
import {
|
|
22
22
|
planWaves,
|
|
23
23
|
formatPlan,
|
|
24
|
+
laneLabel,
|
|
25
|
+
laneModel,
|
|
24
26
|
createRunState,
|
|
25
27
|
nextStep,
|
|
26
28
|
recordBatchResult,
|
|
@@ -955,20 +957,6 @@ function spawnLanesOf(step) {
|
|
|
955
957
|
.filter(lane => lane.length)
|
|
956
958
|
}
|
|
957
959
|
|
|
958
|
-
// The label the workflow gives the same agent (`workflows/ship.js` laneLabel), so
|
|
959
|
-
// a trajectory line and the agent it describes carry one name rather than two.
|
|
960
|
-
function laneLabelOf(lane) {
|
|
961
|
-
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// The model the lane runs on: its hardest task's. A lane is one agent and that
|
|
965
|
-
// agent has to be capable of everything in it.
|
|
966
|
-
function laneModelOf(lane) {
|
|
967
|
-
const tier = lane.reduce((m, t) => (Number.isInteger(t.tier) && t.tier > m ? t.tier : m), 0)
|
|
968
|
-
const hardest = lane.find(t => t.tier === tier) || lane[0]
|
|
969
|
-
return hardest && typeof hardest.model === 'string' ? hardest.model : undefined
|
|
970
|
-
}
|
|
971
|
-
|
|
972
960
|
// Takes the run's `state` rather than a runId, so the change name is resolved
|
|
973
961
|
// from it the same way `logWaveMutation` does instead of being threaded in by a
|
|
974
962
|
// caller who may not have been given one.
|
|
@@ -983,8 +971,8 @@ function logAgentSpawns(root, { state, change, step, source }) {
|
|
|
983
971
|
runId,
|
|
984
972
|
change: changeName,
|
|
985
973
|
type: 'agent-spawn',
|
|
986
|
-
label:
|
|
987
|
-
model:
|
|
974
|
+
label: laneLabel(lane),
|
|
975
|
+
model: laneModel(lane),
|
|
988
976
|
kind: 'implementer',
|
|
989
977
|
// The lane's anchor task. A multi-task lane's later ids are recoverable
|
|
990
978
|
// from the plan and from its per-task record-batch results; what this line
|
package/lib/limits.mjs
CHANGED
|
@@ -356,12 +356,12 @@ export const EFFORT = {
|
|
|
356
356
|
* agent may hold, and how much judgement-heavy work it may hold. Those have
|
|
357
357
|
* different answers, so this is a table.
|
|
358
358
|
*
|
|
359
|
-
* `byTier` is keyed by the lane's HARDEST task tier (`laneTier`)
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
359
|
+
* `byTier` is keyed by the lane's HARDEST task tier (`laneTier`). An untiered
|
|
360
|
+
* lane (tier 0) uses the tier-1 entry. Tier 4 keeps the old scalar's 4 —
|
|
361
|
+
* cross-file pattern-following is where a fresh context per task still pays.
|
|
362
|
+
* Tier 5 stays at 8. Chain lanes are bounded by this same table: the cap is the
|
|
363
|
+
* entry for the chain's hardest tier, packed next-fit. The values were not
|
|
364
|
+
* retuned when lanes of two or more tasks began dispatching on opus.
|
|
365
365
|
*
|
|
366
366
|
* `cohesionMaxTier` is the ceiling for COHESION: path-disjoint components in one
|
|
367
367
|
* dependency layer whose hardest tier is at or below it may be packed into one
|
package/lib/plan-fingerprint.mjs
CHANGED
|
@@ -43,15 +43,16 @@ import { LIMITS, LANE_CAPS, SOLO } from './limits.mjs'
|
|
|
43
43
|
* Separate from the content hash on purpose: a plan can be perfectly current
|
|
44
44
|
* and still be shaped for a reader that no longer exists. `/1` was the plan
|
|
45
45
|
* whose batches held tasks; `/2` is the plan whose batches hold lanes; `/3` is
|
|
46
|
-
* the plan that carries a mode and whose lanes may be cohesion folds
|
|
47
|
-
*
|
|
46
|
+
* the plan that carries a mode and whose lanes may be cohesion folds; `/4`
|
|
47
|
+
* fuses serial single-lane batches into chain lanes. A stored plan carrying
|
|
48
|
+
* another version is not reused however well its inputs match.
|
|
48
49
|
*
|
|
49
|
-
* The bump is what forces a `/
|
|
50
|
-
* plan's inputs can match perfectly and it will still hold one agent per
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* The bump is what forces a `/3` plan to be rebuilt rather than reused. A `/3`
|
|
51
|
+
* plan's inputs can match perfectly and it will still hold one agent per serial
|
|
52
|
+
* batch — reusing it would silently keep the old shape while the run believed
|
|
53
|
+
* it had chain lanes.
|
|
53
54
|
*/
|
|
54
|
-
export const PLAN_FORMAT = 'interlock.ship-plan/
|
|
55
|
+
export const PLAN_FORMAT = 'interlock.ship-plan/4'
|
|
55
56
|
|
|
56
57
|
/** Where the plan and its fingerprint live — machine-local and gitignored. */
|
|
57
58
|
export const PLAN_PATH = '.claude/ship/plan.json'
|
package/lib/run.mjs
CHANGED
|
@@ -1641,10 +1641,25 @@ export function claimDerivedBanner() {
|
|
|
1641
1641
|
* three tasks has told us nothing about the third — and "nothing" read as
|
|
1642
1642
|
* success is how a task silently ships unimplemented. `not-attempted` is the one
|
|
1643
1643
|
* outcome that is neither: it is reported, ticked nowhere, and counted nowhere.
|
|
1644
|
+
*
|
|
1645
|
+
* A result that is absent entirely is a lost agent — a crash, a timeout, or a
|
|
1646
|
+
* briefing the agent never acknowledged — not an agent that filed a bad report.
|
|
1647
|
+
* On a multi-task lane that costs one failure, charged to the first task, and
|
|
1648
|
+
* the rest are `not-attempted`. Charging every task would halt a chain on one
|
|
1649
|
+
* miss. A single-task lane is one failure either way, and stays that.
|
|
1644
1650
|
*/
|
|
1645
1651
|
export function laneOutcomes(lane, result) {
|
|
1646
1652
|
const failAll = error => lane.map(t => ({ id: t.id, outcome: 'failed', error, handoff: null }))
|
|
1647
|
-
if (!result)
|
|
1653
|
+
if (!result) {
|
|
1654
|
+
if (lane.length >= 2) {
|
|
1655
|
+
return lane.map((t, i) =>
|
|
1656
|
+
i === 0
|
|
1657
|
+
? { id: t.id, outcome: 'failed', error: 'agent returned no result', handoff: null }
|
|
1658
|
+
: { id: t.id, outcome: 'not-attempted' }
|
|
1659
|
+
)
|
|
1660
|
+
}
|
|
1661
|
+
return failAll('agent returned no result')
|
|
1662
|
+
}
|
|
1648
1663
|
|
|
1649
1664
|
if (lane.length === 1) {
|
|
1650
1665
|
const [only] = lane
|
package/lib/waves.mjs
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
// Two rules exist specifically to stop the classifier from harming the run:
|
|
9
9
|
//
|
|
10
10
|
// 1. Model clamp. Classifiers reliably over-assign `opus` to anything that
|
|
11
|
-
// touches several files.
|
|
12
|
-
//
|
|
13
|
-
// cannot escalate itself
|
|
11
|
+
// touches several files. The clamp keeps a task's recorded model at haiku
|
|
12
|
+
// or sonnet below tier 5, and opus only on a true tier-5 task, so the
|
|
13
|
+
// model cannot escalate itself. That clamped model is what a single-task
|
|
14
|
+
// lane dispatches on. A lane of two or more tasks dispatches on opus
|
|
15
|
+
// (rule 8) without rewriting any task's recorded model. Every clamp is
|
|
16
|
+
// reported.
|
|
14
17
|
//
|
|
15
18
|
// 2. Fan-out cap. A wave is a set of independent tasks, so the temptation is
|
|
16
19
|
// to spawn all of them at once. The original implementation did exactly
|
|
@@ -44,6 +47,8 @@
|
|
|
44
47
|
// colliding tasks become one lane. Lanes in a batch are path-disjoint and
|
|
45
48
|
// run in parallel; `LANE_CAPS.byTier` bounds how much one interruption can
|
|
46
49
|
// discard, and a uniform override of 1 reproduces one agent per task.
|
|
50
|
+
// Inside a layer a lane is a collision or a cohesion fold. A chain (rule 8)
|
|
51
|
+
// is formed later, from batches, and does not change how those two are built.
|
|
47
52
|
//
|
|
48
53
|
// 6. Cohesion. Collision alone made every path-disjoint task its own agent,
|
|
49
54
|
// however small: seven tasks each writing one `evals/*/case.yaml` were
|
|
@@ -53,6 +58,7 @@
|
|
|
53
58
|
// hardest component first, bounded by that lane's per-tier cap. Tier 4 and
|
|
54
59
|
// 5 work is excluded on purpose: cross-file pattern-following is where a
|
|
55
60
|
// fresh context per task still pays for itself. Every fold is reported.
|
|
61
|
+
// Cohesion stays inside one dependency layer of one section.
|
|
56
62
|
//
|
|
57
63
|
// 7. Solo. A small change may be planned as ONE lane — every implementation
|
|
58
64
|
// task in section, layer and id order, then every test task — run by one
|
|
@@ -61,6 +67,16 @@
|
|
|
61
67
|
// classifier's recommendation if the change fits, then waves. The mode,
|
|
62
68
|
// its source and its reason are on the plan, because a run that silently
|
|
63
69
|
// changed its own shape is the failure the plan preview exists to remove.
|
|
70
|
+
// A solo plan is not chain-fused. One agent for the whole change is already
|
|
71
|
+
// what solo is.
|
|
72
|
+
//
|
|
73
|
+
// 8. Chain fusion. After the singleton fold, and inside the trailing test
|
|
74
|
+
// wave, every run of consecutive batches that each hold exactly one lane
|
|
75
|
+
// becomes one chain lane, packed next-fit under the same per-tier cap. A
|
|
76
|
+
// batch of two or more lanes is left alone, so work that was going to run
|
|
77
|
+
// concurrently still does. Fusion never crosses a wave. A chain runs in
|
|
78
|
+
// the batch order it was fused from, never re-sorted by task id, and it
|
|
79
|
+
// dispatches on opus because it holds more than one task.
|
|
64
80
|
//
|
|
65
81
|
// The second half of this file *executes* that plan — as a state machine, not a
|
|
66
82
|
// runner. The orchestration is moving into a dynamic-workflow script, which can
|
|
@@ -111,6 +127,8 @@ export const LANE_COLLISION = 'collision'
|
|
|
111
127
|
export const LANE_COHESION = 'cohesion'
|
|
112
128
|
/** The single lane of a solo plan: the whole change, in order, on one agent. */
|
|
113
129
|
export const LANE_SOLO = 'solo'
|
|
130
|
+
/** A lane fused from consecutive single-lane batches of one wave. */
|
|
131
|
+
export const LANE_CHAIN = 'chain'
|
|
114
132
|
|
|
115
133
|
/** The two plan shapes. `auto` is not one — it is the absence of an override. */
|
|
116
134
|
export const MODE_WAVES = 'waves'
|
|
@@ -487,13 +505,6 @@ function capForTier(caps, tier) {
|
|
|
487
505
|
return Number.isInteger(cap) && cap > 0 ? cap : 1
|
|
488
506
|
}
|
|
489
507
|
|
|
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
508
|
/**
|
|
498
509
|
* The label a lane runs under — the name its agent, its trajectory line and its
|
|
499
510
|
* briefing file all carry.
|
|
@@ -507,10 +518,18 @@ export function laneLabel(lane) {
|
|
|
507
518
|
return lane.length === 1 ? lane[0].id : `${lane[0].id}+${lane.length - 1}`
|
|
508
519
|
}
|
|
509
520
|
|
|
521
|
+
/**
|
|
522
|
+
* The model a lane dispatches on, derived from the lane's shape.
|
|
523
|
+
*
|
|
524
|
+
* A lane of two or more tasks is the work agent the planner chose not to split,
|
|
525
|
+
* so it dispatches on opus. A lane of one task dispatches on that task's clamped
|
|
526
|
+
* model — haiku or sonnet, or opus for a tier-5 task or a solo promotion. Nothing
|
|
527
|
+
* here rewrites `task.model`: a narrowed lane of one returns to the task's model.
|
|
528
|
+
*/
|
|
510
529
|
export function laneModel(lane) {
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
return (
|
|
530
|
+
if (lane.length >= 2) return 'opus'
|
|
531
|
+
const only = lane[0]
|
|
532
|
+
return (only && only.model) || 'sonnet'
|
|
514
533
|
}
|
|
515
534
|
|
|
516
535
|
/**
|
|
@@ -522,8 +541,9 @@ export function laneModel(lane) {
|
|
|
522
541
|
*
|
|
523
542
|
* Returns `null` (inherit the session default — do not force) for tiers 3–4 by
|
|
524
543
|
* policy and for an untiered lane (`laneTier` → 0) by fallback; the plan's
|
|
525
|
-
* effort report distinguishes the two. Effort is
|
|
526
|
-
*
|
|
544
|
+
* effort report distinguishes the two. Effort is derived from tier alone. The
|
|
545
|
+
* model a lane dispatches on follows the lane's shape, so an opus lane of
|
|
546
|
+
* tier-1 or tier-2 tasks still runs at `low`.
|
|
527
547
|
*/
|
|
528
548
|
export function laneEffort(lane) {
|
|
529
549
|
const tier = laneTier(lane)
|
|
@@ -817,6 +837,67 @@ function foldSingletonWaves(planned) {
|
|
|
817
837
|
return { waves, folded }
|
|
818
838
|
}
|
|
819
839
|
|
|
840
|
+
/**
|
|
841
|
+
* Fuse consecutive single-lane batches of one wave into chain lanes.
|
|
842
|
+
*
|
|
843
|
+
* A batch of one lane buys no parallelism, so next-fit appends it to the open
|
|
844
|
+
* chain while the chain stays within the cap for the hardest tier across the
|
|
845
|
+
* chain and the lane being appended. A batch of two or more lanes closes the
|
|
846
|
+
* open chain and is emitted untouched. A chain drawn from a single batch keeps
|
|
847
|
+
* that batch's array identity; a chain drawn from two or more is one new lane,
|
|
848
|
+
* in batch order, never re-sorted by task id.
|
|
849
|
+
*
|
|
850
|
+
* @param {Array<Array<Array>>} batches
|
|
851
|
+
* @param {Record<number, number>} laneCaps
|
|
852
|
+
* @returns {{batches: Array, chains: Array}}
|
|
853
|
+
*/
|
|
854
|
+
function fuseSerialBatches(batches, laneCaps) {
|
|
855
|
+
const out = []
|
|
856
|
+
const chains = []
|
|
857
|
+
let open = null
|
|
858
|
+
|
|
859
|
+
const emitOpen = () => {
|
|
860
|
+
if (!open) return
|
|
861
|
+
if (open.sources === 1) {
|
|
862
|
+
out.push(open.batch)
|
|
863
|
+
} else {
|
|
864
|
+
const lane = open.tasks
|
|
865
|
+
out.push([lane])
|
|
866
|
+
chains.push({
|
|
867
|
+
tasks: lane,
|
|
868
|
+
ids: lane.map(t => t.id),
|
|
869
|
+
fusedBatches: open.sources,
|
|
870
|
+
tier: open.tier,
|
|
871
|
+
cap: capForTier(laneCaps, open.tier)
|
|
872
|
+
})
|
|
873
|
+
}
|
|
874
|
+
open = null
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
for (const batch of batches) {
|
|
878
|
+
if (!Array.isArray(batch) || batch.length !== 1) {
|
|
879
|
+
emitOpen()
|
|
880
|
+
out.push(batch)
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
883
|
+
const lane = batch[0]
|
|
884
|
+
const tier = laneTier(lane)
|
|
885
|
+
if (open) {
|
|
886
|
+
const hardest = Math.max(open.tier, tier)
|
|
887
|
+
if (open.tasks.length + lane.length <= capForTier(laneCaps, hardest)) {
|
|
888
|
+
open.tasks.push(...lane)
|
|
889
|
+
open.tier = hardest
|
|
890
|
+
open.sources += 1
|
|
891
|
+
continue
|
|
892
|
+
}
|
|
893
|
+
emitOpen()
|
|
894
|
+
}
|
|
895
|
+
open = { tasks: lane.slice(), tier, sources: 1, batch }
|
|
896
|
+
}
|
|
897
|
+
emitOpen()
|
|
898
|
+
return { batches: out, chains }
|
|
899
|
+
}
|
|
900
|
+
|
|
820
901
|
/**
|
|
821
902
|
* Every claimed path on every task is documentation.
|
|
822
903
|
*
|
|
@@ -1067,11 +1148,15 @@ export function planWaves(input, opts = {}) {
|
|
|
1067
1148
|
const deferred = []
|
|
1068
1149
|
const laneEntries = []
|
|
1069
1150
|
const promoted = []
|
|
1151
|
+
const chains = []
|
|
1070
1152
|
let planned = []
|
|
1071
1153
|
let waves = []
|
|
1072
1154
|
let folded = []
|
|
1073
1155
|
let testWave = null
|
|
1074
1156
|
let widest = 0
|
|
1157
|
+
// Post-fold, pre-fusion. Solo never fuses, and its lane count is 1, so the
|
|
1158
|
+
// serial warning's threshold is the same either way.
|
|
1159
|
+
let preFusionLaneCount = 0
|
|
1075
1160
|
|
|
1076
1161
|
if (solo) {
|
|
1077
1162
|
// Every task on one agent, so there is nothing to pack: `foldSingletonWaves`,
|
|
@@ -1183,31 +1268,78 @@ export function planWaves(input, opts = {}) {
|
|
|
1183
1268
|
}
|
|
1184
1269
|
testWave = { taskCount: testTasks.length, batches }
|
|
1185
1270
|
}
|
|
1271
|
+
|
|
1272
|
+
// Measured before fusion. A chain the planner is about to fuse is not a
|
|
1273
|
+
// shape the operator still has to fix, so the serial warning keeps the
|
|
1274
|
+
// post-fold count it had before this step existed.
|
|
1275
|
+
preFusionLaneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
|
|
1276
|
+
const fusedWaves = []
|
|
1277
|
+
for (const wave of waves) {
|
|
1278
|
+
const fused = fuseSerialBatches(wave.batches, laneCaps)
|
|
1279
|
+
for (const chain of fused.chains) chains.push({ group: wave.group, ...chain })
|
|
1280
|
+
fusedWaves.push({ ...wave, batches: fused.batches })
|
|
1281
|
+
}
|
|
1282
|
+
waves = fusedWaves
|
|
1283
|
+
if (testWave) {
|
|
1284
|
+
const fused = fuseSerialBatches(testWave.batches, laneCaps)
|
|
1285
|
+
for (const chain of fused.chains) chains.push({ group: null, ...chain })
|
|
1286
|
+
testWave = { ...testWave, batches: fused.batches }
|
|
1287
|
+
}
|
|
1186
1288
|
}
|
|
1187
1289
|
|
|
1188
1290
|
const implCount = implTasks.length
|
|
1189
1291
|
|
|
1190
|
-
// Every lane holding more than one task, so a fold is visible
|
|
1191
|
-
// inferred from a smaller agent bill. A lane of one is not a fold
|
|
1192
|
-
//
|
|
1292
|
+
// Every dispatched lane holding more than one task, so a fold is visible
|
|
1293
|
+
// rather than inferred from a smaller agent bill. A lane of one is not a fold
|
|
1294
|
+
// and is not listed.
|
|
1193
1295
|
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1296
|
+
// Solo reads the build-time entries: there is one lane and its kind is a fact
|
|
1297
|
+
// about how it was built. Waves mode reads the lanes that will dispatch, after
|
|
1298
|
+
// fusion. A chain's kind cannot be recovered from its tasks, so it is taken
|
|
1299
|
+
// from the fusion record; a lane fusion left untouched keeps the array the
|
|
1300
|
+
// packer built, and its kind comes from that entry. The test wave's lanes are
|
|
1301
|
+
// listed too (with a null group).
|
|
1302
|
+
const listedLane = (group, tasks, kind, cap, extra) => ({
|
|
1303
|
+
group,
|
|
1304
|
+
kind,
|
|
1305
|
+
ids: tasks.map(t => t.id),
|
|
1306
|
+
tier: laneTier(tasks),
|
|
1307
|
+
cap,
|
|
1308
|
+
model: laneModel(tasks),
|
|
1309
|
+
effort: laneEffort(tasks),
|
|
1310
|
+
...extra
|
|
1311
|
+
})
|
|
1199
1312
|
const lanes = []
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
group,
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1313
|
+
if (solo) {
|
|
1314
|
+
for (const { group, entry } of laneEntries) {
|
|
1315
|
+
if (entry.tasks.length < 2) continue
|
|
1316
|
+
lanes.push(listedLane(group, entry.tasks, entry.kind, entry.cap))
|
|
1317
|
+
}
|
|
1318
|
+
} else {
|
|
1319
|
+
const chainByTasks = new Map(chains.map(c => [c.tasks, c]))
|
|
1320
|
+
const entryByTasks = new Map(laneEntries.map(({ entry }) => [entry.tasks, entry]))
|
|
1321
|
+
const consider = (group, wave) => {
|
|
1322
|
+
if (!wave) return
|
|
1323
|
+
for (const lane of waveLanes(wave)) {
|
|
1324
|
+
if (lane.length < 2) continue
|
|
1325
|
+
const chain = chainByTasks.get(lane)
|
|
1326
|
+
if (chain) {
|
|
1327
|
+
lanes.push(listedLane(group, lane, LANE_CHAIN, chain.cap, { fusedBatches: chain.fusedBatches }))
|
|
1328
|
+
continue
|
|
1329
|
+
}
|
|
1330
|
+
const entry = entryByTasks.get(lane)
|
|
1331
|
+
lanes.push(
|
|
1332
|
+
listedLane(
|
|
1333
|
+
group,
|
|
1334
|
+
lane,
|
|
1335
|
+
entry ? entry.kind : LANE_COLLISION,
|
|
1336
|
+
entry ? entry.cap : capForTier(laneCaps, laneTier(lane))
|
|
1337
|
+
)
|
|
1338
|
+
)
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
for (const wave of waves) consider(wave.group, wave)
|
|
1342
|
+
if (testWave) consider(null, testWave)
|
|
1211
1343
|
}
|
|
1212
1344
|
const laneCount = waves.reduce((n, w) => n + waveLanes(w).length, 0)
|
|
1213
1345
|
|
|
@@ -1243,36 +1375,47 @@ export function planWaves(input, opts = {}) {
|
|
|
1243
1375
|
`widest wave has ${widest} tasks; splitting into batches of ${maxParallel}`
|
|
1244
1376
|
)
|
|
1245
1377
|
}
|
|
1246
|
-
// Measured on the
|
|
1247
|
-
// a
|
|
1248
|
-
//
|
|
1249
|
-
if (
|
|
1378
|
+
// Measured on the post-fold, pre-fusion lane count. A chain the planner has
|
|
1379
|
+
// already fused is not a shape the operator still has to fix, and neither is
|
|
1380
|
+
// a staircase the singleton fold already removed.
|
|
1381
|
+
if (preFusionLaneCount >= 2 && waves.length > preFusionLaneCount * 0.5) {
|
|
1250
1382
|
warnings.push(
|
|
1251
|
-
`effectively serial: ${waves.length} waves for ${
|
|
1383
|
+
`effectively serial: ${waves.length} waves for ${preFusionLaneCount} implementation lane(s)`
|
|
1252
1384
|
)
|
|
1253
1385
|
}
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
for
|
|
1258
|
-
|
|
1386
|
+
// Build-time warnings, phrased by KIND, from the entries the packer produced.
|
|
1387
|
+
// A cohesion fold that later became part of a chain is still named here. A
|
|
1388
|
+
// cohesion fold is not a collision: an operator reading "share a file" about
|
|
1389
|
+
// seven path-disjoint tasks would go looking for a file they do not share.
|
|
1390
|
+
for (const { group, entry } of laneEntries) {
|
|
1391
|
+
if (entry.tasks.length < 2) continue
|
|
1392
|
+
const ids = entry.tasks.map(t => t.id)
|
|
1393
|
+
const tier = laneTier(entry.tasks)
|
|
1394
|
+
if (entry.kind === LANE_SOLO) {
|
|
1259
1395
|
warnings.push(
|
|
1260
|
-
`solo lane: tasks ${
|
|
1396
|
+
`solo lane: tasks ${ids.join(', ')} run in that order on one opus agent — ` +
|
|
1261
1397
|
`every section barrier and dependency edge is honoured by position in the lane`
|
|
1262
1398
|
)
|
|
1263
|
-
} else if (
|
|
1399
|
+
} else if (entry.kind === LANE_COHESION) {
|
|
1264
1400
|
warnings.push(
|
|
1265
|
-
`tasks ${
|
|
1266
|
-
`${laneWhere(
|
|
1267
|
-
`order (tier-${
|
|
1401
|
+
`tasks ${ids.join(', ')} are path-disjoint tier-${tier} work in ` +
|
|
1402
|
+
`${laneWhere(group)}; packed into one cohesion lane run by a single agent in that ` +
|
|
1403
|
+
`order (tier-${tier} lane cap ${entry.cap})`
|
|
1268
1404
|
)
|
|
1269
1405
|
} else {
|
|
1270
1406
|
warnings.push(
|
|
1271
|
-
`tasks ${
|
|
1272
|
-
`lane run by a single tier-${
|
|
1407
|
+
`tasks ${ids.join(', ')} share a file in ${laneWhere(group)}; folded into one ` +
|
|
1408
|
+
`lane run by a single tier-${tier} agent in that order`
|
|
1273
1409
|
)
|
|
1274
1410
|
}
|
|
1275
1411
|
}
|
|
1412
|
+
for (const chain of chains) {
|
|
1413
|
+
warnings.push(
|
|
1414
|
+
`tasks ${chain.ids.join(', ')} were ${chain.fusedBatches} consecutive single-lane batches in ` +
|
|
1415
|
+
`${laneWhere(chain.group)}; fused into one chain lane run by one opus agent in that order ` +
|
|
1416
|
+
`(tier-${chain.tier} lane cap ${chain.cap})`
|
|
1417
|
+
)
|
|
1418
|
+
}
|
|
1276
1419
|
for (const f of folded) {
|
|
1277
1420
|
warnings.push(
|
|
1278
1421
|
`task ${f.id} was a 1-task wave in group ${f.from}; folded into wave ${f.to} as a later ` +
|
|
@@ -1311,7 +1454,7 @@ export function planWaves(input, opts = {}) {
|
|
|
1311
1454
|
? `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them inside the ` +
|
|
1312
1455
|
`solo lane rather than beside them`
|
|
1313
1456
|
: `task ${d.id} declares dependsOn ${d.after.join(', ')}; it runs after them in ` +
|
|
1314
|
-
`${laneWhere(d.group)}, in a later batch or
|
|
1457
|
+
`${laneWhere(d.group)}, in a later batch, wave or lane position rather than beside them`
|
|
1315
1458
|
)
|
|
1316
1459
|
}
|
|
1317
1460
|
|
|
@@ -1434,6 +1577,12 @@ export function formatPlan(plan) {
|
|
|
1434
1577
|
` cohesion ${lane.ids.join(' → ')}: ${lane.ids.length} path-disjoint tasks in ` +
|
|
1435
1578
|
`${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent (cap ${lane.cap})`
|
|
1436
1579
|
)
|
|
1580
|
+
} else if (lane.kind === LANE_CHAIN) {
|
|
1581
|
+
lines.push(
|
|
1582
|
+
` chain ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ` +
|
|
1583
|
+
`${laneWhere(lane.group)} on one ${lane.model}/T${lane.tier} agent ` +
|
|
1584
|
+
`(${lane.fusedBatches} batches, cap ${lane.cap})`
|
|
1585
|
+
)
|
|
1437
1586
|
} else {
|
|
1438
1587
|
lines.push(
|
|
1439
1588
|
` lane ${lane.ids.join(' → ')}: ${lane.ids.length} tasks in ${laneWhere(lane.group)} on ` +
|
|
@@ -1782,30 +1931,38 @@ function makeWave(kind, group, tasks, maxParallel, laneCaps, opts = {}) {
|
|
|
1782
1931
|
// outcome-audit fields already make: a run resumed across an upgrade must
|
|
1783
1932
|
// still be able to replan.
|
|
1784
1933
|
const caps = laneCaps && typeof laneCaps === 'object' ? laneCaps : LANE_CAPS.byTier
|
|
1934
|
+
// Cloned, then clamped, so a replanned task obeys the same model clamp the
|
|
1935
|
+
// planner applied at plan time. The caller's objects are left untouched.
|
|
1936
|
+
const clamped = []
|
|
1937
|
+
const cloned = tasks.map(t => {
|
|
1938
|
+
const copy = { ...t }
|
|
1939
|
+
const record = clampModel(copy)
|
|
1940
|
+
if (record) clamped.push(record)
|
|
1941
|
+
return copy
|
|
1942
|
+
})
|
|
1943
|
+
// Layered by `dependsOn` the way initial planning is, so a revision that
|
|
1944
|
+
// carries edges keeps their order. The layers become later BATCHES here
|
|
1945
|
+
// rather than separate waves, because a replan revises one group into one
|
|
1946
|
+
// wave — and those single-lane batches then fuse under the caps the run was
|
|
1947
|
+
// planned with, the same way planWaves fuses after the fold.
|
|
1948
|
+
const built = layerByDependencies(cloned).flatMap(
|
|
1949
|
+
layer => collisionAwareChunk(layer, maxParallel, serialized, group, caps).batches
|
|
1950
|
+
)
|
|
1951
|
+
const fused = fuseSerialBatches(built, caps)
|
|
1785
1952
|
return {
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
-
)
|
|
1953
|
+
clamped,
|
|
1954
|
+
wave: {
|
|
1955
|
+
kind,
|
|
1956
|
+
group,
|
|
1957
|
+
// Preserved across a replan. The marker is what stops the red wave's
|
|
1958
|
+
// failing suite being treated as a defect, so a revision that rebuilt the
|
|
1959
|
+
// group without it would restore exactly the halt this wave exists to
|
|
1960
|
+
// avoid — and would do it only on replanned runs, which is the hardest
|
|
1961
|
+
// version of that bug to find.
|
|
1962
|
+
...(opts.red === true ? { red: true } : {}),
|
|
1963
|
+
taskCount: cloned.length,
|
|
1964
|
+
batches: fused.batches
|
|
1965
|
+
}
|
|
1809
1966
|
}
|
|
1810
1967
|
}
|
|
1811
1968
|
|
|
@@ -2525,13 +2682,15 @@ export function applyReplan(state, revisedGroups) {
|
|
|
2525
2682
|
const wasRed = next.waves[at] && next.waves[at].red === true
|
|
2526
2683
|
if (!revision.tasks.length) next.waves.splice(at, spans)
|
|
2527
2684
|
else {
|
|
2528
|
-
next.
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2685
|
+
const made = makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps, {
|
|
2686
|
+
red: wasRed
|
|
2687
|
+
})
|
|
2688
|
+
next.waves.splice(at, spans, made.wave)
|
|
2689
|
+
for (const c of made.clamped) {
|
|
2690
|
+
next.warnings.push(
|
|
2691
|
+
`task ${c.id} clamped ${c.from} → ${c.to} (tier ${c.tier}) on replan of group ${revision.group}`
|
|
2692
|
+
)
|
|
2693
|
+
}
|
|
2535
2694
|
}
|
|
2536
2695
|
continue
|
|
2537
2696
|
}
|
|
@@ -2550,7 +2709,13 @@ export function applyReplan(state, revisedGroups) {
|
|
|
2550
2709
|
break
|
|
2551
2710
|
}
|
|
2552
2711
|
}
|
|
2553
|
-
|
|
2712
|
+
const made = makeWave('impl', revision.group, revision.tasks, next.maxParallel, next.laneCaps)
|
|
2713
|
+
next.waves.splice(insertAt, 0, made.wave)
|
|
2714
|
+
for (const c of made.clamped) {
|
|
2715
|
+
next.warnings.push(
|
|
2716
|
+
`task ${c.id} clamped ${c.from} → ${c.to} (tier ${c.tier}) on replan of group ${revision.group}`
|
|
2717
|
+
)
|
|
2718
|
+
}
|
|
2554
2719
|
}
|
|
2555
2720
|
|
|
2556
2721
|
next.replansUsed += 1
|