immune-brain 5.0.0 → 5.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.
Files changed (49) hide show
  1. package/README.md +223 -12
  2. package/README.zh-CN.md +228 -11
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +18 -5
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +189 -3
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1885 -149
  10. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  12. package/plugins/immune-brain/dist/imm-planner.md +4 -1
  13. package/plugins/immune-brain/dist/imm-run.md +198 -3
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
  15. package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
  17. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  18. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  19. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -0
  20. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +105 -58
  21. package/plugins/immune-brain/runtime/claude/mcp_server.ts +89 -16
  22. package/plugins/immune-brain/runtime/claude/role_agents.ts +4 -3
  23. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  24. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  25. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  26. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  27. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  28. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  29. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  30. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  31. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  32. package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
  33. package/plugins/immune-brain/runtime/loop_contract.ts +55 -2
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +10 -0
  36. package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
  37. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +16 -3
  38. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +2 -2
  41. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +946 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
  43. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +48 -33
  44. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
  45. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +22 -1
  46. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_state.ts +398 -12
  48. package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
  49. package/plugins/immune-brain/skills/imm-run/SKILL.md +4 -0
@@ -0,0 +1,946 @@
1
+ // Lane-mode batch driver. With `max_parallel` given, an unattended batch runs
2
+ // each child in a Lane: a separate worktree on its own branch, supplied by
3
+ // someone else, with its own Authority Store. This module admits offered Lanes,
4
+ // enrolls children into them, commits their settled delivery on the lane branch
5
+ // and integrates it into the batch branch. It never creates, switches or
6
+ // deletes a Git worktree and never names a workspace tool.
7
+ // Defined by docs/specs/parallel-batch-lanes.spec.md and ADR 0013.
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync, realpathSync } from "node:fs";
10
+ import { isAbsolute, join } from "node:path";
11
+ import { computeBatchPlanDigest } from "../kernel/batch_authority";
12
+ import { readTaskIntent } from "../kernel/intent";
13
+ import type { StartBatchInput } from "./batch_runner";
14
+ import {
15
+ BatchIntegrationError,
16
+ findIntegratedCandidate,
17
+ integrateGuardedLaneCommit,
18
+ type IntegrationCheckChild,
19
+ } from "./batch_integration";
20
+ import { classifyBatchLineage, expectedBatchHead, findExistingActiveBatch, readActiveClaimTaskId } from "./batch_preflight";
21
+ import { startableChildren } from "./batch_schedule";
22
+ import {
23
+ type AnyBatchRunStateRecord,
24
+ type BatchLaneChildRun,
25
+ type BatchLaneHandoff,
26
+ type BatchLaneRunReport,
27
+ type BatchLaneRunStateRecord,
28
+ isLaneBatchRecord,
29
+ isTerminalBatchState,
30
+ prepareBatchLaneRunState,
31
+ writeBatchLaneRunState,
32
+ writeBatchRunReport,
33
+ } from "./batch_state";
34
+ import { createDefaultBatchGitPort, type BatchRunnerGitPort } from "./batch_git";
35
+ import { LANE_EXECUTOR_HOSTS } from "../role_prompt_bridge";
36
+
37
+ export { LANE_EXECUTOR_HOSTS };
38
+
39
+ export type LaneAdmissionReason =
40
+ | "batch_lane_foreign_repository"
41
+ | "batch_lane_is_coordinator"
42
+ | "batch_lane_branch_mismatch"
43
+ | "batch_lane_base_mismatch"
44
+ | "batch_lane_dirty"
45
+ | "batch_lane_occupied"
46
+ | "batch_lane_unknown_child";
47
+
48
+ export const PARALLEL_MISMATCH = "batch_parallel_mismatch";
49
+
50
+ export interface LaneOffer {
51
+ task_id: string;
52
+ path: string;
53
+ }
54
+
55
+ const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
56
+ const MAX_LANE_OFFERS = 64;
57
+ const MAX_PATH_LENGTH = 4096;
58
+
59
+ /** A positive safe integer, or undefined when the parameter is absent. */
60
+ /**
61
+ * `lane_offers` without `max_parallel` is a resume of a recorded lane batch: the
62
+ * recorded `max_parallel` applies. Without a recorded active lane batch there is
63
+ * nothing to resume, so the call is refused before any gate opens.
64
+ */
65
+ export function resolveLaneParallel(
66
+ root: string,
67
+ initiativeSlug: string,
68
+ maxParallel: number | undefined,
69
+ laneOffers: unknown,
70
+ ): number | undefined {
71
+ if (maxParallel !== undefined || laneOffers === undefined) return maxParallel;
72
+ const lookup = findExistingActiveBatch(root, initiativeSlug);
73
+ if (lookup === null || lookup.corrupt || !isLaneBatchRecord(lookup.record))
74
+ throw new Error("lane_offers requires max_parallel unless a recorded lane batch is being resumed");
75
+ return lookup.record.max_parallel;
76
+ }
77
+
78
+ export function parseMaxParallel(value: unknown): number | undefined {
79
+ if (value === undefined || value === null) return undefined;
80
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
81
+ throw new Error("invalid max_parallel: expected a positive integer");
82
+ return value;
83
+ }
84
+
85
+ /** Offers are untrusted input: shape-checked here, path-checked at admission. */
86
+ export function parseLaneOffers(value: unknown): LaneOffer[] | undefined {
87
+ if (value === undefined || value === null) return undefined;
88
+ if (!Array.isArray(value)) throw new Error("invalid lane_offers: expected an array");
89
+ if (value.length > MAX_LANE_OFFERS) throw new Error(`invalid lane_offers: at most ${MAX_LANE_OFFERS} offers`);
90
+ const seen = new Set<string>();
91
+ return value.map((entry) => {
92
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
93
+ throw new Error("invalid lane_offers: each offer must be an object");
94
+ const { task_id: taskId, path, ...rest } = entry as Record<string, unknown>;
95
+ if (Object.keys(rest).length > 0) throw new Error("invalid lane_offers: unknown offer field");
96
+ if (typeof taskId !== "string" || !TASK_ID_PATTERN.test(taskId))
97
+ throw new Error("invalid lane_offers: task_id is not a valid task id");
98
+ if (
99
+ typeof path !== "string" ||
100
+ !path ||
101
+ path.length > MAX_PATH_LENGTH ||
102
+ path.includes("\0") ||
103
+ !isAbsolute(path)
104
+ )
105
+ throw new Error("invalid lane_offers: path must be an absolute path");
106
+ if (seen.has(taskId)) throw new Error(`invalid lane_offers: duplicate offer for ${taskId}`);
107
+ seen.add(taskId);
108
+ return { task_id: taskId, path };
109
+ });
110
+ }
111
+
112
+ /** The branch the Lane for a child must sit on. */
113
+ export function laneBranchName(initiativeSlug: string, taskId: string): string {
114
+ return `imm-lane/${initiativeSlug}/${taskId}`;
115
+ }
116
+
117
+ export interface LaneFacts {
118
+ /** The offered path resolves to an existing directory. */
119
+ exists: boolean;
120
+ real_path: string | null;
121
+ /** The path is a worktree of the coordinator's repository (shared Git common directory). */
122
+ same_repository: boolean;
123
+ /** The path is the top level of its worktree, not a subdirectory. */
124
+ is_worktree_root: boolean;
125
+ branch: string | null;
126
+ head: string | null;
127
+ clean: boolean;
128
+ active_claim_task_id: string | null;
129
+ }
130
+
131
+ /** Git operations lane mode needs beyond the serial port. */
132
+ export interface BatchLaneGitPort {
133
+ inspectLane(coordinatorRoot: string, lanePath: string): LaneFacts;
134
+ /** Coordinator root resolved the same way a Lane path is, for the identity comparison. */
135
+ resolveRoot(root: string): string;
136
+ integrate(input: {
137
+ root: string;
138
+ branch: string;
139
+ batch_head: string;
140
+ lane_base: string;
141
+ lane_commit: string;
142
+ child: IntegrationCheckChild;
143
+ siblings: Array<IntegrationCheckChild & { commit: string | null }>;
144
+ }): Promise<{ commit: string }> | { commit: string };
145
+ findIntegrated(input: {
146
+ root: string;
147
+ task_id: string;
148
+ batch_id: string;
149
+ from_head: string;
150
+ lane_base: string;
151
+ lane_commit: string;
152
+ }): string | null;
153
+ /**
154
+ * The child's `.imm/audit/<task_id>/` terminal evidence pair is reachable
155
+ * from `head`. A Lane store is lost on release, so release is offered only
156
+ * when the tracked pair already survives on the batch branch.
157
+ */
158
+ auditReachable(input: { root: string; head: string; task_id: string }): boolean;
159
+ }
160
+
161
+ function gitRead(root: string, args: string[]): { status: number | null; stdout: string } {
162
+ const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
163
+ return { status: result.status, stdout: (result.stdout ?? "").trim() };
164
+ }
165
+
166
+ function commonDir(root: string): string | null {
167
+ const result = gitRead(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
168
+ if (result.status !== 0 || !result.stdout) return null;
169
+ try {
170
+ return realpathSync(result.stdout);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function resolveRealPath(path: string): string | null {
177
+ try {
178
+ return realpathSync(path);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ export function createDefaultLaneGitPort(): BatchLaneGitPort {
185
+ return {
186
+ resolveRoot: (root) => resolveRealPath(root) ?? root,
187
+ inspectLane(coordinatorRoot, lanePath) {
188
+ const real = existsSync(lanePath) ? resolveRealPath(lanePath) : null;
189
+ const absent: LaneFacts = {
190
+ exists: real !== null,
191
+ real_path: real,
192
+ same_repository: false,
193
+ is_worktree_root: false,
194
+ branch: null,
195
+ head: null,
196
+ clean: false,
197
+ active_claim_task_id: null,
198
+ };
199
+ if (!real) return absent;
200
+ const laneCommon = commonDir(real);
201
+ const coordinatorCommon = commonDir(coordinatorRoot);
202
+ if (!laneCommon || !coordinatorCommon || laneCommon !== coordinatorCommon) return absent;
203
+ const top = gitRead(real, ["rev-parse", "--show-toplevel"]);
204
+ const topReal = top.status === 0 ? resolveRealPath(top.stdout) : null;
205
+ const branch = gitRead(real, ["symbolic-ref", "--short", "HEAD"]);
206
+ const head = gitRead(real, ["rev-parse", "HEAD"]);
207
+ const status = gitRead(real, ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"]);
208
+ let activeClaim: string | null = null;
209
+ try {
210
+ activeClaim = readActiveClaimTaskId(real);
211
+ } catch {
212
+ // An unreadable Lane store cannot be proved unoccupied.
213
+ activeClaim = "unreadable";
214
+ }
215
+ return {
216
+ exists: true,
217
+ real_path: real,
218
+ same_repository: true,
219
+ is_worktree_root: topReal === real,
220
+ branch: branch.status === 0 ? branch.stdout : null,
221
+ head: head.status === 0 ? head.stdout : null,
222
+ clean: status.status === 0 && status.stdout === "",
223
+ active_claim_task_id: activeClaim,
224
+ };
225
+ },
226
+ integrate: integrateGuardedLaneCommit,
227
+ findIntegrated: findIntegratedCandidate,
228
+ auditReachable({ root, head, task_id }) {
229
+ const listing = gitRead(root, ["ls-tree", "-r", "--name-only", head, "--", `.imm/audit/${task_id}`]);
230
+ if (listing.status !== 0) return false;
231
+ const files = listing.stdout.split("\n");
232
+ const runDirs = new Set(files.map((file) => file.slice(0, file.lastIndexOf("/"))));
233
+ return [...runDirs].some(
234
+ (dir) => files.includes(`${dir}/task-record.json`) && files.includes(`${dir}/terminal-proof.json`),
235
+ );
236
+ },
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Decide one offer from the facts read about its path. Pure: a refusal reads
242
+ * nothing more and writes nothing.
243
+ */
244
+ export function decideLaneAdmission(input: {
245
+ facts: LaneFacts;
246
+ coordinator_real_path: string;
247
+ expected_branch: string;
248
+ batch_head: string;
249
+ bound_paths: readonly string[];
250
+ }): LaneAdmissionReason | null {
251
+ const { facts } = input;
252
+ if (!facts.exists || !facts.real_path || !facts.same_repository || !facts.is_worktree_root)
253
+ return "batch_lane_foreign_repository";
254
+ if (facts.real_path === input.coordinator_real_path) return "batch_lane_is_coordinator";
255
+ if (facts.branch !== input.expected_branch) return "batch_lane_branch_mismatch";
256
+ if (facts.head !== input.batch_head) return "batch_lane_base_mismatch";
257
+ if (!facts.clean) return "batch_lane_dirty";
258
+ if (facts.active_claim_task_id !== null || input.bound_paths.includes(facts.real_path))
259
+ return "batch_lane_occupied";
260
+ return null;
261
+ }
262
+
263
+ function laneGitOf(input: StartBatchInput): BatchLaneGitPort {
264
+ return (input.git as (BatchRunnerGitPort & { lane?: BatchLaneGitPort }) | undefined)?.lane ?? createDefaultLaneGitPort();
265
+ }
266
+
267
+ function serialGitOf(input: StartBatchInput): Pick<BatchRunnerGitPort, "preflight" | "commitChild" | "lookupBatchCommit"> {
268
+ return input.git ?? defaultSerialGit();
269
+ }
270
+
271
+ /**
272
+ * Release handoffs for integrated Lanes that are safe to remove: the Lane is
273
+ * still present on its own branch, clean, and its audit pair already sits on
274
+ * the batch branch. A parked, failed, dirty or unintegrated Lane never appears.
275
+ * Read-only: the runtime asks the steward to remove a Lane and never does it.
276
+ */
277
+ function releaseHandoffs(
278
+ input: StartBatchInput,
279
+ record: BatchLaneRunStateRecord,
280
+ lanes: BatchLaneGitPort,
281
+ ): BatchLaneHandoff[] {
282
+ const lineage = classifyBatchLineage({ root: input.root, branch: record.branch ?? "",
283
+ expectedHead: expectedBatchHead(record), childCommits: record.commits, batchId: record.batch_id });
284
+ if (lineage.kind === "broken") return [];
285
+ const handoffs: BatchLaneHandoff[] = [];
286
+ for (const child of record.children) {
287
+ if (child.state !== "integrated" || !child.lane) continue;
288
+ const facts = lanes.inspectLane(input.root, child.lane.path);
289
+ if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || !facts.clean || facts.branch !== child.lane.branch || facts.active_claim_task_id !== null) continue;
290
+ if (!lanes.auditReachable({ root: input.root, head: lineage.head, task_id: child.task_id })) continue;
291
+ handoffs.push({ role: "lane-steward", action: "release", task_id: child.task_id, lane_branch: child.lane.branch });
292
+ }
293
+ return handoffs;
294
+ }
295
+
296
+ function defaultSerialGit(): BatchRunnerGitPort {
297
+ return createDefaultBatchGitPort();
298
+ }
299
+
300
+ const IN_FLIGHT = new Set(["lane_admitted", "enrolled", "settled", "lane_committed"]);
301
+
302
+ function dependentsOf(record: BatchLaneRunStateRecord, taskId: string): BatchLaneChildRun[] {
303
+ return record.children.filter((child) => child.blocked_by.includes(taskId));
304
+ }
305
+
306
+ function skipDependents(record: BatchLaneRunStateRecord, taskId: string, reason: string): void {
307
+ const skip = new Set<string>([taskId]);
308
+ const queue = [taskId];
309
+ while (queue.length > 0) {
310
+ const current = queue.shift()!;
311
+ for (const dependent of dependentsOf(record, current)) {
312
+ if (skip.has(dependent.task_id)) continue;
313
+ skip.add(dependent.task_id);
314
+ queue.push(dependent.task_id);
315
+ }
316
+ }
317
+ record.children = record.children.map((child) =>
318
+ skip.has(child.task_id) && child.state === "pending" ? { ...child, state: "skipped_blocked", reason } : child,
319
+ );
320
+ }
321
+
322
+ function consumedSlots(record: BatchLaneRunStateRecord): number {
323
+ return record.children.filter((child) => child.state !== "pending" && child.state !== "skipped_blocked").length;
324
+ }
325
+
326
+ /**
327
+ * The scope a child's delivery may touch, read from its published TaskIntent at
328
+ * the coordinator. A child whose scope cannot be read is treated as overlapping
329
+ * everything, so an unreadable intent serializes instead of running in parallel.
330
+ */
331
+ function scopeOfChild(root: string, child: { task_id: string; intent_path: string | null }): string[] {
332
+ try {
333
+ return [...readTaskIntent(root, child.task_id, child.intent_path ?? undefined).intent.scope_hint];
334
+ } catch {
335
+ return ["**"];
336
+ }
337
+ }
338
+
339
+ function scheduleView(input: StartBatchInput, record: BatchLaneRunStateRecord) {
340
+ return record.children.map((child) => ({
341
+ task_id: child.task_id,
342
+ state: child.state,
343
+ blocked_by: child.blocked_by,
344
+ scope_hint: scopeOfChild(input.root, input.children.find((c) => c.task_id === child.task_id) ?? { task_id: child.task_id, intent_path: null }),
345
+ }));
346
+ }
347
+
348
+ /** Recovery preserves the parked run and requires fresh literal-user authorization. */
349
+ const LANE_NEEDS_HUMAN_NEXT_ACTION =
350
+ "The lane batch has stopped on a parked child and its Lane is kept. Resolve the child by hand; continuing needs a new Batch Authorization.";
351
+
352
+ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
353
+ completed: "The batch integrated every enrollable child; review the commits and the tracker.",
354
+ budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
355
+ failed: "A lineage failure stopped the batch; inspect the failing child and the branch state.",
356
+ rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
357
+ superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
358
+ needs_human: LANE_NEEDS_HUMAN_NEXT_ACTION,
359
+ running: "The batch is still running; no terminal report is due yet.",
360
+ prepared: "The batch is prepared but not started.",
361
+ };
362
+
363
+ function laneReport(
364
+ record: BatchLaneRunStateRecord,
365
+ reason: string | null,
366
+ nextAction: string,
367
+ extra: { handoffs?: BatchLaneHandoff[]; refusals?: BatchLaneRunReport["lane_refusals"] } = {},
368
+ ): BatchLaneRunReport {
369
+ return {
370
+ contract: "assurance_kernel/batch_run_report/v1",
371
+ batch_id: record.batch_id,
372
+ initiative_slug: record.initiative_slug,
373
+ batch_state: record.batch_state,
374
+ max_parallel: record.max_parallel,
375
+ children: record.children,
376
+ commits: record.commits,
377
+ reason,
378
+ handoffs: extra.handoffs ?? [],
379
+ ...(extra.refusals?.length ? { lane_refusals: extra.refusals } : {}),
380
+ next_action: nextAction || (TERMINAL_NEXT_ACTIONS[record.batch_state] ?? "Inspect the batch run state."),
381
+ created_at: record.updated_at,
382
+ };
383
+ }
384
+
385
+ /** A store-condition rejection of a persisted lane batch: its persisted lane children, commits and parallelism, never an empty serial plan. */
386
+ export function laneRejectionReport(input: StartBatchInput, record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
387
+ // A rejected tick grants no execution or admission authority. Only already
388
+ // integrated, unoccupied Lanes with surviving audit evidence may be released.
389
+ const handoffs = releaseHandoffs(input, record, laneGitOf(input));
390
+ return laneReport({ ...record, batch_state: "rejected" }, reason,
391
+ handoffs.length ? `${nextAction}. Check each release handoff with the Lane Steward.` : nextAction,
392
+ { handoffs });
393
+ }
394
+
395
+ function finalizeLane(
396
+ root: string,
397
+ record: BatchLaneRunStateRecord,
398
+ reason: string | null,
399
+ nextAction: string,
400
+ extra: Parameters<typeof laneReport>[3] = {},
401
+ ): BatchLaneRunReport {
402
+ const report = laneReport(record, reason, nextAction, extra);
403
+ if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human")
404
+ writeBatchRunReport(root, report);
405
+ return report;
406
+ }
407
+
408
+ /** A refusal before any write: the persisted record (or the prepared plan) reported as rejected. */
409
+ function refuse(
410
+ input: StartBatchInput,
411
+ persisted: AnyBatchRunStateRecord | null,
412
+ code: string,
413
+ detail: string,
414
+ ): BatchLaneRunReport {
415
+ const base =
416
+ persisted && isLaneBatchRecord(persisted)
417
+ ? persisted
418
+ : prepareBatchLaneRunState({
419
+ batch_id: input.batch_id,
420
+ initiative_slug: input.initiative_slug,
421
+ children: persisted ? [] : input.children,
422
+ plan_digest: input.plan_digest,
423
+ base_head: input.base_head,
424
+ confirmation_time: input.confirmation_time,
425
+ budget: input.budget,
426
+ max_parallel: input.max_parallel ?? 1,
427
+ now: input.now,
428
+ });
429
+ return laneReport({ ...base, batch_state: "rejected" }, `${code}: ${detail}`, "Correct the lane-mode parameters and call start_unattended_batch again.");
430
+ }
431
+
432
+ function assertPlanMatches(input: StartBatchInput, record: BatchLaneRunStateRecord): void {
433
+ const plan = input.registry.children(input.capability);
434
+ if (
435
+ record.plan_digest !== computeBatchPlanDigest(plan) ||
436
+ record.children.length !== plan.length ||
437
+ record.children.some((child, index) => {
438
+ const expected = plan[index]!;
439
+ return child.task_id !== expected.task_id || JSON.stringify(child.blocked_by) !== JSON.stringify(expected.blocked_by);
440
+ })
441
+ )
442
+ throw new Error("plan_digest mismatch: persisted children do not match the authorized plan");
443
+ }
444
+
445
+ function validateNewAuthorization(input: StartBatchInput): string | null {
446
+ try {
447
+ const authorized = input.kernel.validateBatchAuthorization({
448
+ registry: input.registry,
449
+ capability: input.capability,
450
+ binding: {
451
+ batch_id: input.batch_id,
452
+ plan_digest: input.plan_digest,
453
+ base_head: input.base_head,
454
+ initiative_slug: input.initiative_slug,
455
+ budget: input.budget,
456
+ },
457
+ });
458
+ if (authorized.issued_at !== input.confirmation_time)
459
+ return "the parked batch requires a fresh literal-user confirmation";
460
+ const children = input.children.map((child) => {
461
+ const { intent_path, intent_revision, intent_content_hash } = child;
462
+ if (intent_path === null || intent_revision === null || intent_content_hash === null)
463
+ throw new Error(`batch child ${child.task_id} has no complete intent identity`);
464
+ return { ...child, intent_path, intent_revision, intent_content_hash };
465
+ });
466
+ if (
467
+ computeBatchPlanDigest(children) !== authorized.plan_digest ||
468
+ input.plan_digest !== authorized.plan_digest ||
469
+ input.base_head !== authorized.base_head
470
+ )
471
+ return "batch run input does not match the authorized plan or base_head";
472
+ return null;
473
+ } catch (error) {
474
+ return error instanceof Error ? error.message : String(error);
475
+ }
476
+ }
477
+
478
+ /** Slots held by integrated and in-flight children are re-consumed on a fresh capability. */
479
+ function rehydrateConsumption(input: StartBatchInput, record: BatchLaneRunStateRecord): void {
480
+ const { issued_at: _issuedAt, ...binding } = input.kernel.validateBatchAuthorization({
481
+ registry: input.registry,
482
+ capability: input.capability,
483
+ binding: {
484
+ batch_id: record.batch_id,
485
+ plan_digest: record.plan_digest,
486
+ base_head: record.base_head,
487
+ initiative_slug: record.initiative_slug,
488
+ budget: record.budget,
489
+ },
490
+ });
491
+ for (const child of record.children) {
492
+ if (child.state === "pending" || child.state === "skipped_blocked" || child.state === "lane_admitted") continue;
493
+ if (!input.registry.isChildConsumed(input.capability, child.task_id))
494
+ input.registry.consumeChild(input.capability, binding, child.task_id);
495
+ }
496
+ }
497
+
498
+ function failLineage(root: string, record: BatchLaneRunStateRecord, message: string): BatchLaneRunReport {
499
+ const next: BatchLaneRunStateRecord = {
500
+ ...record,
501
+ batch_state: "failed",
502
+ children: record.children.map((child) =>
503
+ IN_FLIGHT.has(child.state) ? { ...child, state: "needs_human" as const, reason: message } : child,
504
+ ),
505
+ };
506
+ for (const child of next.children) if (child.state === "needs_human") skipDependents(next, child.task_id, `dependency ${child.task_id} parked`);
507
+ return finalizeLane(root, writeBatchLaneRunState(root, next), message, "");
508
+ }
509
+
510
+ function park(record: BatchLaneRunStateRecord, taskId: string, reason: string): void {
511
+ record.children = record.children.map((child) =>
512
+ child.task_id === taskId ? { ...child, state: "needs_human", reason } : child,
513
+ );
514
+ skipDependents(record, taskId, `dependency ${taskId} parked`);
515
+ }
516
+
517
+ function isLineageBreak(message: string): boolean {
518
+ return message.includes("batch_head_lineage_broken");
519
+ }
520
+
521
+ /**
522
+ * One lane-mode tick for a batch whose record is `persisted` (or new). Under
523
+ * the existing batch lock; every step is idempotent against durable facts.
524
+ */
525
+ export async function runLaneBatch(
526
+ input: StartBatchInput,
527
+ persisted: AnyBatchRunStateRecord | null,
528
+ ): Promise<BatchLaneRunReport> {
529
+ if (persisted && !isLaneBatchRecord(persisted))
530
+ return refuse(input, persisted, PARALLEL_MISMATCH, "the recorded batch runs serially; max_parallel cannot be added on a resume");
531
+ if (persisted && input.max_parallel !== undefined && input.max_parallel !== persisted.max_parallel)
532
+ return refuse(input, persisted, PARALLEL_MISMATCH, `the recorded batch runs with max_parallel ${persisted.max_parallel}`);
533
+ const limit = persisted?.max_parallel ?? input.max_parallel ?? 1;
534
+ if (!input.children.length) return refuse(input, persisted, "batch_plan_empty", "batch plan is empty");
535
+
536
+ const git = serialGitOf(input);
537
+ const lanes = laneGitOf(input);
538
+
539
+ let record: BatchLaneRunStateRecord;
540
+ if (persisted) {
541
+ record = persisted;
542
+ assertPlanMatches(input, record);
543
+ if (isTerminalBatchState(record.batch_state))
544
+ return finalizeLane(input.root, record, `terminal state already reached: ${record.batch_state}`, "", {
545
+ handoffs: record.batch_state === "completed" ? releaseHandoffs(input, record, lanes) : [],
546
+ });
547
+ if (record.batch_state === "needs_human") {
548
+ const invalid = validateNewAuthorization(input);
549
+ if (invalid || Date.parse(input.confirmation_time) <= Date.parse(record.confirmation_time))
550
+ return laneReport(record, invalid ?? "the parked batch requires a fresh literal-user confirmation", LANE_NEEDS_HUMAN_NEXT_ACTION);
551
+ const resumed: BatchLaneChildRun[] = [];
552
+ for (const child of record.children) {
553
+ if (child.state !== "needs_human") { resumed.push(child); continue; }
554
+ if (!child.lane?.run_id)
555
+ return laneReport(record, "parked Lane has no recorded run identity; inspect it without re-enrolling", LANE_NEEDS_HUMAN_NEXT_ACTION);
556
+ const facts = lanes.inspectLane(input.root, child.lane.path);
557
+ const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
558
+ if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || facts.branch !== child.lane.branch ||
559
+ fresh.error !== null || fresh.projection.run_id !== child.lane.run_id ||
560
+ fresh.projection.open_user_decision_count > 0 || fresh.projection.replan_required_ids.length > 0)
561
+ return laneReport(record, "parked Lane identity or decision is unresolved", LANE_NEEDS_HUMAN_NEXT_ACTION);
562
+ const state = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready ?
563
+ (child.lane.lane_commit ? "lane_committed" : "settled") :
564
+ fresh.projection.lifecycle === "active" && fresh.claim?.task_id === child.task_id &&
565
+ (facts.active_claim_task_id === null || facts.active_claim_task_id === child.task_id) ? "enrolled" : null;
566
+ if (!state) return laneReport(record, "parked child has no recoverable owned run", LANE_NEEDS_HUMAN_NEXT_ACTION);
567
+ resumed.push({ ...child, state, reason: null, qa_failures: 0 });
568
+ }
569
+ record = { ...record, batch_state: "running", confirmation_time: input.confirmation_time,
570
+ children: resumed.map(child => child.state === "skipped_blocked" ? { ...child, state: "pending", reason: null } : child) };
571
+ }
572
+ } else {
573
+ const invalid = validateNewAuthorization(input);
574
+ if (invalid) return refuse(input, null, "batch_authorization_invalid", invalid);
575
+ const preflight = await git.preflight({
576
+ root: input.root,
577
+ initiative_slug: input.initiative_slug,
578
+ base_head: input.base_head,
579
+ });
580
+ if (!preflight.ok) return refuse(input, null, preflight.reason, preflight.message || "Correct the preflight condition and re-confirm.");
581
+ record = prepareBatchLaneRunState({
582
+ batch_id: input.batch_id,
583
+ initiative_slug: input.initiative_slug,
584
+ children: input.children,
585
+ plan_digest: input.plan_digest,
586
+ base_head: input.base_head,
587
+ confirmation_time: input.confirmation_time,
588
+ budget: input.budget,
589
+ max_parallel: limit,
590
+ now: input.now,
591
+ });
592
+ }
593
+
594
+ const persist = (): void => {
595
+ record = writeBatchLaneRunState(input.root, record);
596
+ };
597
+ // A resumed park is persisted only after the existing lineage validation below.
598
+ const resumedPark = persisted?.batch_state === "needs_human" && record.batch_state === "running";
599
+ if (record.batch_state === "prepared") {
600
+ record.batch_state = "running";
601
+ persist();
602
+ }
603
+
604
+ // Adopt an integration the previous tick completed but did not record.
605
+ for (const child of record.children) {
606
+ if (child.state !== "lane_committed" || !child.lane?.lane_commit) continue;
607
+ const found = lanes.findIntegrated({
608
+ root: input.root,
609
+ task_id: child.task_id,
610
+ batch_id: record.batch_id,
611
+ from_head: expectedBatchHead(record),
612
+ lane_base: child.lane.base_head,
613
+ lane_commit: child.lane.lane_commit,
614
+ });
615
+ if (!found) continue;
616
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit: found } : c));
617
+ record.commits = [...record.commits, found];
618
+ persist();
619
+ }
620
+
621
+ // Record a release once the Lane path is observed gone. The commit and the
622
+ // Lane binding stay; an integrated child whose Lane is still present is left
623
+ // integrated, and a terminal record is never rewritten.
624
+ for (const child of record.children) {
625
+ if (child.state !== "integrated" || !child.lane) continue;
626
+ if (lanes.inspectLane(input.root, child.lane.path).exists) continue;
627
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "released" } : c));
628
+ persist();
629
+ }
630
+
631
+ const lineage = existsSync(join(input.root, ".git"))
632
+ ? classifyBatchLineage({
633
+ root: input.root,
634
+ branch: record.branch ?? "",
635
+ expectedHead: expectedBatchHead(record),
636
+ childCommits: record.commits,
637
+ batchId: record.batch_id,
638
+ })
639
+ : ({ kind: "equal", head: expectedBatchHead(record) } as const);
640
+ if (lineage.kind === "broken") return failLineage(input.root, record, lineage.message);
641
+ if (lineage.kind === "fast_forward") {
642
+ record = writeBatchLaneRunState(input.root, {
643
+ ...record,
644
+ adopted_heads: [...(record.adopted_heads ?? []), { from: expectedBatchHead(record), to: lineage.head }],
645
+ });
646
+ }
647
+
648
+ try {
649
+ rehydrateConsumption(input, record);
650
+ } catch (error) {
651
+ return failLineage(input.root, record, error instanceof Error ? error.message : String(error));
652
+ }
653
+
654
+ if (resumedPark) persist();
655
+ const refusals: NonNullable<BatchLaneRunReport["lane_refusals"]> = [];
656
+ // A parked child ends only itself and its dependents; the batch keeps moving
657
+ // for every scope-disjoint sibling and settles needs_human at the end of the
658
+ // tick, when nothing is in flight and nothing can start.
659
+ let parkedMessage: string | null = null;
660
+ const reviewOpen: string[] = [];
661
+ const parkChild = (taskId: string, reason: string): void => {
662
+ park(record, taskId, reason);
663
+ parkedMessage ??= reason;
664
+ persist();
665
+ };
666
+ const enrollInLane = async (taskId: string): Promise<BatchLaneRunReport | null> => {
667
+ const lane = record.children.find((c) => c.task_id === taskId)!.lane!;
668
+ try {
669
+ await input.kernel.enrollTask({
670
+ root: lane.path,
671
+ task_id: taskId,
672
+ batch: { registry: input.registry, capability: input.capability, binding: { batch_id: record.batch_id, expected_head: lane.base_head } },
673
+ });
674
+ const fresh = await input.kernel.projectTask(lane.path, taskId);
675
+ if (fresh.error !== null || !fresh.projection.run_id || fresh.claim?.task_id !== taskId)
676
+ throw new Error("enrolled Lane has no observable owned run");
677
+ record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null, lane: { ...lane, run_id: fresh.projection.run_id } } : c));
678
+ persist();
679
+ return null;
680
+ } catch (error) {
681
+ const message = error instanceof Error ? error.message : String(error);
682
+ parkChild(taskId, message);
683
+ if (!isLineageBreak(message)) return null;
684
+ // A broken lineage cannot be trusted for any other child either.
685
+ record.batch_state = "failed";
686
+ persist();
687
+ return finalizeLane(input.root, record, message, "", { refusals });
688
+ }
689
+ };
690
+
691
+ // 1. Observe every in-flight Lane and drive each frozen child's QA and Review.
692
+ // Nothing is inferred from terminal text; only Kernel facts move a child.
693
+ for (const child of [...record.children]) {
694
+ if ((child.state !== "lane_admitted" && child.state !== "enrolled") || !child.lane) continue;
695
+ let fresh: Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
696
+ try {
697
+ fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
698
+ } catch {
699
+ fresh = { error: "unreadable" } as unknown as typeof fresh;
700
+ }
701
+ if (fresh.error !== null) {
702
+ parkChild(child.task_id, "batch_lane_lost");
703
+ continue;
704
+ }
705
+ if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
706
+ parkChild(child.task_id, "batch_lane_run_mismatch");
707
+ continue;
708
+ }
709
+ const holdsClaim = fresh.claim !== null && fresh.claim.task_id === child.task_id;
710
+ if (child.state === "lane_admitted") {
711
+ // A crash between admission and enrollment adopts an existing claim or enrolls now.
712
+ if (holdsClaim) {
713
+ record.children = record.children.map((c) =>
714
+ c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption", lane: { ...child.lane!, run_id: fresh.projection.run_id } } : c,
715
+ );
716
+ persist();
717
+ } else {
718
+ const failed = await enrollInLane(child.task_id);
719
+ if (failed) return failed;
720
+ }
721
+ continue;
722
+ }
723
+ if (fresh.projection.lifecycle === "done" && fresh.projection.completion_ready) {
724
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c));
725
+ persist();
726
+ continue;
727
+ }
728
+ if (!holdsClaim) {
729
+ parkChild(child.task_id, "batch_lane_lost");
730
+ continue;
731
+ }
732
+ if (!input.kernel.ownsTaskClaim(child.task_id)) {
733
+ parkChild(child.task_id, "claim held by another batch");
734
+ continue;
735
+ }
736
+ // An unfrozen child, or one with QA pending the Executor's own attempt,
737
+ // is the Executor's to move; the batch never reruns that attempt.
738
+ if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen") continue;
739
+ const terminal = await input.kernel.advanceTask(child.lane.path, child.task_id);
740
+ if (terminal.state === "completed") {
741
+ record.children = record.children.map((c) =>
742
+ c.task_id === child.task_id ? { ...c, state: "settled", reason: null, qa_failures: 0 } : c,
743
+ );
744
+ persist();
745
+ } else if (terminal.state === "review_ready") {
746
+ // The reservation lives in this Lane's own session store; the other Lanes'
747
+ // children keep moving while it stays open.
748
+ record.children = record.children.map((c) =>
749
+ c.task_id === child.task_id ? { ...c, reason: `review reservation ${terminal.operation_id} open` } : c,
750
+ );
751
+ persist();
752
+ reviewOpen.push(child.task_id);
753
+ } else if (terminal.state === "rework" && terminal.operation === "qa") {
754
+ const failures = child.qa_failures + 1;
755
+ if (failures >= record.budget.qa_failure_limit) {
756
+ const reason = "QA failure limit reached";
757
+ record.children = record.children.map((c) =>
758
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason, qa_failures: failures } : c,
759
+ );
760
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
761
+ parkedMessage ??= reason;
762
+ persist();
763
+ continue;
764
+ }
765
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, qa_failures: failures } : c));
766
+ persist();
767
+ } else if (
768
+ terminal.state === "rework" ||
769
+ terminal.environment_failure ||
770
+ terminal.state === "review_preparation_failed" ||
771
+ terminal.recovery?.category === "repair"
772
+ ) {
773
+ // Ordinary own-claim repair stays with the Executor; the handoff below names it.
774
+ continue;
775
+ } else {
776
+ const reason = terminal.state === "stopped" ? "Kernel reported the child stopped" : terminal.reason;
777
+ parkChild(child.task_id, reason);
778
+ }
779
+ }
780
+
781
+ // 2. Commit each settled child on its lane branch, then integrate it.
782
+ for (const child of [...record.children]) {
783
+ if (child.state !== "settled" || !child.lane) continue;
784
+ const planChild = input.children.find((c) => c.task_id === child.task_id);
785
+ let laneCommit: string;
786
+ try {
787
+ const existing = await git.lookupBatchCommit(child.lane.path, child.task_id, record.batch_id, child.lane.base_head, child.lane.branch);
788
+ laneCommit =
789
+ existing?.commit ??
790
+ (
791
+ await git.commitChild(
792
+ child.lane.path,
793
+ child.task_id,
794
+ record.batch_id,
795
+ child.lane.base_head,
796
+ child.lane.branch,
797
+ planChild?.intent_path ?? undefined,
798
+ )
799
+ ).commit;
800
+ } catch (error) {
801
+ const message = error instanceof Error ? error.message : String(error);
802
+ parkChild(child.task_id, message);
803
+ if (!isLineageBreak(message)) continue;
804
+ record.batch_state = "failed";
805
+ persist();
806
+ return finalizeLane(input.root, record, message, "");
807
+ }
808
+ record.children = record.children.map((c) =>
809
+ c.task_id === child.task_id && c.lane ? { ...c, state: "lane_committed", lane: { ...c.lane, lane_commit: laneCommit } } : c,
810
+ );
811
+ persist();
812
+ }
813
+ for (const child of [...record.children]) {
814
+ if (child.state !== "lane_committed" || !child.lane?.lane_commit) continue;
815
+ const intentPathOf = (taskId: string) => input.children.find((c) => c.task_id === taskId)?.intent_path ?? null;
816
+ try {
817
+ const { commit } = await lanes.integrate({
818
+ root: input.root,
819
+ branch: record.branch ?? "",
820
+ batch_head: expectedBatchHead(record),
821
+ lane_base: child.lane.base_head,
822
+ lane_commit: child.lane.lane_commit,
823
+ child: { task_id: child.task_id, intent_path: intentPathOf(child.task_id) },
824
+ siblings: record.children
825
+ .filter((c) => c.state === "integrated" && c.task_id !== child.task_id)
826
+ .map((c) => ({ task_id: c.task_id, intent_path: intentPathOf(c.task_id), commit: c.commit })),
827
+ });
828
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit, reason: null } : c));
829
+ record.commits = [...record.commits, commit];
830
+ persist();
831
+ } catch (error) {
832
+ const message = error instanceof Error ? error.message : String(error);
833
+ const reason = error instanceof BatchIntegrationError ? error.reason : "batch_integration_conflict";
834
+ parkChild(child.task_id, isLineageBreak(message) || message.startsWith(`${reason}:`) ? message : `${reason}: ${message}`);
835
+ if (isLineageBreak(message)) {
836
+ // A broken lineage cannot be trusted for any later integration.
837
+ record.batch_state = "failed";
838
+ persist();
839
+ return finalizeLane(input.root, record, message, "");
840
+ }
841
+ // The batch branch is unmoved; a disjoint sibling may still integrate.
842
+ }
843
+ }
844
+
845
+ // 3. Admit offered Lanes, then enroll each admitted child into its Lane.
846
+ const coordinatorReal = lanes.resolveRoot(input.root);
847
+ for (const offer of input.lane_offers ?? []) {
848
+ const startable = new Set(startableChildren(scheduleView(input, record), limit));
849
+ const child = record.children.find((c) => c.task_id === offer.task_id);
850
+ if (!child || child.state !== "pending" || !startable.has(child.task_id)) {
851
+ refusals.push({ ...offer, reason: "batch_lane_unknown_child" });
852
+ continue;
853
+ }
854
+ const head = expectedBatchHead(record);
855
+ const facts = lanes.inspectLane(input.root, offer.path);
856
+ const reason = decideLaneAdmission({
857
+ facts,
858
+ coordinator_real_path: coordinatorReal,
859
+ expected_branch: laneBranchName(record.initiative_slug, child.task_id),
860
+ batch_head: head,
861
+ bound_paths: record.children.flatMap((c) => (c.lane && c.state !== "released" ? [c.lane.path] : [])),
862
+ });
863
+ if (reason) {
864
+ refusals.push({ ...offer, reason });
865
+ continue;
866
+ }
867
+ // The child budget stops new admissions only; in-flight Lanes finish.
868
+ if (consumedSlots(record) >= record.budget.max_children) break;
869
+ const lane = { path: facts.real_path!, branch: laneBranchName(record.initiative_slug, child.task_id), base_head: head, lane_commit: null };
870
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "lane_admitted", lane } : c));
871
+ persist();
872
+ const failed = await enrollInLane(child.task_id);
873
+ if (failed) return failed;
874
+ }
875
+
876
+ // 4. Schedule and report. Handoffs are observations, never readiness.
877
+ if (record.children.every((c) => c.state === "integrated" || c.state === "released")) {
878
+ record.batch_state = "completed";
879
+ persist();
880
+ return finalizeLane(input.root, record, "all enrollable children integrated", "", {
881
+ handoffs: releaseHandoffs(input, record, lanes),
882
+ refusals,
883
+ });
884
+ }
885
+ const handoffs: BatchLaneHandoff[] = [];
886
+ for (const child of record.children) {
887
+ if (child.state !== "enrolled" || !child.lane) continue;
888
+ const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
889
+ if (fresh.error !== null) continue;
890
+ handoffs.push({
891
+ role: "executor",
892
+ task_id: child.task_id,
893
+ run_id: fresh.projection.run_id ?? null,
894
+ record_revision: fresh.projection.record_revision,
895
+ next_obligation: fresh.projection.next_obligation,
896
+ lane_branch: child.lane.branch,
897
+ lane_path: child.lane.path,
898
+ });
899
+ }
900
+ handoffs.push(...releaseHandoffs(input, record, lanes));
901
+ const inFlight = record.children.filter((c) => IN_FLIGHT.has(c.state));
902
+ const startable = startableChildren(scheduleView(input, record), limit);
903
+ const overBudget = consumedSlots(record) >= record.budget.max_children;
904
+ if (!inFlight.length && (!startable.length || overBudget)) {
905
+ const parked = record.children.some((c) => c.state === "needs_human" || c.state === "skipped_blocked");
906
+ record.batch_state = parked ? "needs_human" : overBudget ? "budget_stopped" : "needs_human";
907
+ persist();
908
+ return finalizeLane(
909
+ input.root,
910
+ record,
911
+ parkedMessage ?? (parked ? "a parked child needs a human decision" : overBudget ? `max_children budget exhausted (${record.budget.max_children})` : "no child can start"),
912
+ "",
913
+ { refusals },
914
+ );
915
+ }
916
+ if (!overBudget) {
917
+ for (const taskId of startable) {
918
+ handoffs.push({
919
+ role: "lane-steward",
920
+ action: "provision",
921
+ task_id: taskId,
922
+ lane_branch: laneBranchName(record.initiative_slug, taskId),
923
+ base_head: expectedBatchHead(record),
924
+ executor_hosts: LANE_EXECUTOR_HOSTS,
925
+ });
926
+ }
927
+ }
928
+ if (reviewOpen.length > 0) {
929
+ return laneReport(
930
+ record,
931
+ reviewOpen.length === 1
932
+ ? `child ${reviewOpen[0]} holds an open Review reservation`
933
+ : `children ${reviewOpen.join(", ")} hold open Review reservations`,
934
+ "Submit each reserved foreground Review verdict in its own Lane, then call start_unattended_batch again to continue.",
935
+ { handoffs, refusals },
936
+ );
937
+ }
938
+ return laneReport(
939
+ record,
940
+ null,
941
+ handoffs.some((h) => h.role === "lane-steward" && h.action === "provision")
942
+ ? "Provide a Lane for each provision handoff, then call start_unattended_batch again with lane_offers."
943
+ : "Run each executor handoff in its Lane. When a Lane finishes, call start_unattended_batch again with the same Initiative.",
944
+ { handoffs, refusals },
945
+ );
946
+ }