immune-brain 3.6.6 → 3.6.7

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 (28) hide show
  1. package/README.md +24 -3
  2. package/README.zh-CN.md +23 -3
  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 +29 -19
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
  9. package/plugins/immune-brain/dist/imm-loop.md +22 -0
  10. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  12. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  13. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  14. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  15. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
  16. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  17. package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
  18. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  19. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  20. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  21. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  22. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  23. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  24. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  25. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  26. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  27. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  28. package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
@@ -0,0 +1,1224 @@
1
+ // Serial batch run driver for unattended Initiative batch runs.
2
+ // Every batch and child state transition lives here and in
3
+ // runtime/kernel/batch_authority.ts; Host adapters are callers only and this
4
+ // module imports no Host adapter (Invariant H-1).
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import {
9
+ BatchAuthorizationExpiryError,
10
+ type BatchAuthorityRegistry,
11
+ type ValidatedBatchAuthorization,
12
+ computeBatchPlanDigest,
13
+ } from "../kernel/batch_authority";
14
+ import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
15
+ import type { BatchPlanChild } from "./types";
16
+ import {
17
+ type BatchRunnerGitPort,
18
+ type BatchGitPreflightResult,
19
+ runBatchGitPreflight,
20
+ commitBatchChild,
21
+ lookupBatchCommit,
22
+ } from "./batch_git";
23
+ import {
24
+ type BatchRunStateRecord,
25
+ type BatchChildRun,
26
+ type BatchRunReport,
27
+ isTerminalBatchState,
28
+ prepareBatchRunState,
29
+ readBatchRunState,
30
+ writeBatchRunState,
31
+ writeBatchRunReport,
32
+ } from "./batch_state";
33
+
34
+ /** Kernel-facing ports the driver needs. Host adapters supply these. */
35
+ export interface BatchRunnerKernelPort {
36
+ /** Enroll one child through the batch-derived capability. */
37
+ enrollTask(input: {
38
+ root: string;
39
+ task_id: string;
40
+ batch: {
41
+ registry: BatchAuthorityRegistry;
42
+ capability: object;
43
+ binding: { batch_id: string; expected_head: string };
44
+ };
45
+ }): Promise<{ record_revision: string }>;
46
+ /** Drive one enrolled child toward its own Kernel terminal settlement. */
47
+ advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
48
+ /** Scope-bound commit after Kernel reports a child done. */
49
+ commitChild?(
50
+ root: string,
51
+ taskId: string,
52
+ batchId: string,
53
+ head: string,
54
+ branch?: string,
55
+ intentPath?: string,
56
+ ): Promise<{ commit: string }>;
57
+ /** review-3(5th round): read the already-created batch commit for a child,
58
+ * or null when none exists. Lets crash recovery adopt an existing commit
59
+ * instead of replaying the commitChild mutation. */
60
+ lookupBatchCommit?(
61
+ root: string,
62
+ taskId: string,
63
+ batchId: string,
64
+ expectedHead?: string,
65
+ branch?: string,
66
+ ): Promise<{ commit: string } | null>;
67
+ /** Optional Git preflight check. */
68
+ gitPreflight?(input: {
69
+ root: string;
70
+ initiative_slug: string;
71
+ base_head: string;
72
+ }): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
73
+ /** review-2(5th round): read-only claim projection for enrollment
74
+ * reconciliation after an interruption between enrollTask and its state
75
+ * persistence. Uses the real AssuranceProjectionResult contract; batch
76
+ * ownership of the claim is verified separately through the authoritative
77
+ * batch registry, because the Kernel claim carries no batch id. */
78
+ projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
79
+ /** Authoritative batch ownership check: true when the task's Kernel claim
80
+ * is held under this batch's derived capability (consumed child slot). */
81
+ ownsTaskClaim(taskId: string): boolean;
82
+ /** review-8(4th rework): Kernel-side proof that a replacement parked-batch
83
+ * authorization is genuine, unexpired, and bound to this exact batch_id,
84
+ * plan_digest, and base_head, validated against the Kernel's authoritative
85
+ * binding state. Throws on fabrication, mismatch, or expiry; the driver
86
+ * must call this before accepting a parked-batch resume. */
87
+ validateBatchAuthorization(input: {
88
+ registry: BatchAuthorityRegistry;
89
+ capability: object;
90
+ binding: Pick<ValidatedBatchAuthorization,
91
+ "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget" | "expires_at">;
92
+ }): ValidatedBatchAuthorization;
93
+ }
94
+
95
+ export type BatchChildAdvanceResult =
96
+ | { state: "completed" }
97
+ | { state: "stopped" }
98
+ | { state: "failed"; reason: string }
99
+ | { state: "rework"; operation: "qa" | "review"; summary: string }
100
+ | { state: "blocked"; reason: string }
101
+ | { state: "review_ready"; operation_id: string };
102
+
103
+ export interface StartBatchInput {
104
+ root: string;
105
+ batch_id: string;
106
+ initiative_slug: string;
107
+ registry: BatchAuthorityRegistry;
108
+ capability: object;
109
+ children: BatchPlanChild[];
110
+ plan_digest: string;
111
+ base_head: string;
112
+ confirmation_time: string;
113
+ authorization_expires_at: string;
114
+ budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
115
+ now: string;
116
+ kernel: BatchRunnerKernelPort;
117
+ git?: BatchRunnerGitPort;
118
+ }
119
+
120
+ export type { BatchRunReport } from "./batch_state";
121
+
122
+ function dependentsOf(record: BatchRunStateRecord, taskId: string): BatchChildRun[] {
123
+ return record.children.filter((child) => child.blocked_by.includes(taskId));
124
+ }
125
+
126
+ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: string): void {
127
+ // review-1(5th round): traverse the full transitive dependency closure —
128
+ // parking A must skip B *and* C in A -> B -> C, not only direct dependents.
129
+ const skip = new Set<string>([taskId]);
130
+ const queue = [taskId];
131
+ while (queue.length > 0) {
132
+ const current = queue.shift()!;
133
+ for (const dependent of dependentsOf(record, current)) {
134
+ if (skip.has(dependent.task_id)) continue;
135
+ skip.add(dependent.task_id);
136
+ queue.push(dependent.task_id);
137
+ }
138
+ }
139
+ record.children = record.children.map((child) =>
140
+ skip.has(child.task_id) && child.state === "pending"
141
+ ? { ...child, state: "skipped_blocked", reason } : child,
142
+ );
143
+ }
144
+
145
+ function budgetStopReason(record: BatchRunStateRecord, now: number): string | null {
146
+ // review-3: budget counts enrollments consumed (children that left
147
+ // pending), not commits, so parked children still consume authorization.
148
+ const enrolledCount = record.children.filter(
149
+ (child) => child.state !== "pending" && child.state !== "skipped_blocked",
150
+ ).length;
151
+ if (enrolledCount >= record.budget.max_children)
152
+ return `max_children budget exhausted (${record.budget.max_children})`;
153
+ const deadline = Date.parse(record.budget.deadline_at);
154
+ if (!Number.isNaN(deadline) && now >= deadline)
155
+ return `deadline_at reached (${record.budget.deadline_at})`;
156
+ return null;
157
+ }
158
+
159
+ /** review-7(3rd rework): the typed expiry marker now lives at the Kernel
160
+ * boundary (kernel/batch_authority.ts) so a real enrollment-time expiry is
161
+ * structurally classifiable as an intentional budget stop; re-exported here
162
+ * for driver API compatibility. */
163
+ export { BatchAuthorizationExpiryError };
164
+
165
+ function isAuthorizationExpiryError(error: unknown): boolean {
166
+ return error instanceof BatchAuthorizationExpiryError;
167
+ }
168
+
169
+ function requireFreshProjection(
170
+ result: AssuranceProjectionResult,
171
+ taskId: string,
172
+ ): AssuranceProjectionResult & { error: null } {
173
+ if (result.error !== null)
174
+ throw new Error(`cannot reconcile Kernel projection for ${taskId}: ${result.error}`);
175
+ return result as AssuranceProjectionResult & { error: null };
176
+ }
177
+
178
+ /** Next pending child whose direct dependents are all committed. */
179
+ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null {
180
+ return (
181
+ record.children.find(
182
+ (child) =>
183
+ child.state === "pending" &&
184
+ record.children.every(
185
+ (other) => !child.blocked_by.includes(other.task_id) || other.state === "committed",
186
+ ),
187
+ ) ?? null
188
+ );
189
+ }
190
+
191
+ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
192
+ completed: "The batch settled every enrollable child; review the commits and the tracker.",
193
+ budget_stopped: "Budget, deadline, or authorization expiry stopped new enrollments; re-confirm to continue under a new authorization.",
194
+ failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
195
+ rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
196
+ needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
197
+ running: "The batch is still running; no terminal report is due yet.",
198
+ prepared: "The batch is prepared but not started.",
199
+ };
200
+
201
+ function reportFor(
202
+ record: BatchRunStateRecord,
203
+ reason: string | null,
204
+ nextAction: string,
205
+ ): BatchRunReport {
206
+ return {
207
+ contract: "assurance_kernel/batch_run_report/v1",
208
+ batch_id: record.batch_id,
209
+ initiative_slug: record.initiative_slug,
210
+ batch_state: record.batch_state,
211
+ children: record.children,
212
+ commits: record.commits,
213
+ reason,
214
+ next_action: nextAction || (TERMINAL_NEXT_ACTIONS[record.batch_state] ?? "Inspect the batch run state."),
215
+ created_at: record.updated_at,
216
+ };
217
+ }
218
+
219
+ /** review-3(6th round): persist the single run report when the batch
220
+ * stops — including a needs_human park, which ends the current run even
221
+ * though the batch remains resumable after a human decision. */
222
+ function finalize(
223
+ root: string,
224
+ record: BatchRunStateRecord,
225
+ reason: string | null,
226
+ nextAction: string,
227
+ ): BatchRunReport {
228
+ const report = reportFor(record, reason, nextAction);
229
+ if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human") {
230
+ writeBatchRunReport(root, report);
231
+ }
232
+ return report;
233
+ }
234
+
235
+ /** review round 8: only lineage breaks (external branch switch / HEAD
236
+ * regression) on a persisted record fail the batch with a persisted report;
237
+ * fabricated records keep throwing with zero writes. */
238
+ function isLineageBreakError(message: string): boolean {
239
+ return message.includes("batch_head_lineage_broken");
240
+ }
241
+
242
+ /** review round 9: a lineage-broken persisted batch may hold in-flight
243
+ * children; failed batches reject enrolled/settled children, so transition
244
+ * them to needs_human and skip their pending dependents before persisting. */
245
+ function failPersistedLineage(
246
+ root: string,
247
+ existing: BatchRunStateRecord,
248
+ message: string,
249
+ ): BatchRunStateRecord {
250
+ const children = existing.children.map((c) =>
251
+ c.state === "enrolled" || c.state === "settled"
252
+ ? { ...c, state: "needs_human" as const, reason: message }
253
+ : c,
254
+ );
255
+ const record: BatchRunStateRecord = { ...existing, batch_state: "failed", children };
256
+ for (const child of record.children) {
257
+ if (child.state === "needs_human") {
258
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
259
+ }
260
+ }
261
+ return writeBatchRunState(root, record);
262
+ }
263
+
264
+ /** review round 11: detect external HEAD movement or branch switch against a
265
+ * persisted record's expected lineage; returns the failure message or null. */
266
+ function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): string | null {
267
+ if (!existsSync(join(root, ".git"))) return null;
268
+ const head = record.commits.length
269
+ ? record.commits[record.commits.length - 1]!
270
+ : record.base_head;
271
+ const headCheck = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
272
+ encoding: "utf8",
273
+ stdio: ["ignore", "pipe", "pipe"],
274
+ });
275
+ if (headCheck.status === 0 && headCheck.stdout.trim() && headCheck.stdout.trim() !== head) {
276
+ return `batch_head_lineage_broken: current HEAD ${headCheck.stdout.trim()} does not match expected batch head ${head}`;
277
+ }
278
+ const branchCheck = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
279
+ encoding: "utf8",
280
+ stdio: ["ignore", "pipe", "pipe"],
281
+ });
282
+ const currentBranch = branchCheck.stdout.trim();
283
+ if (branchCheck.status !== 0 || currentBranch !== record.branch) {
284
+ return `batch_head_lineage_broken: current branch ${currentBranch} does not match expected batch branch ${record.branch}`;
285
+ }
286
+ return null;
287
+ }
288
+
289
+ async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
290
+ const plan = input.registry.children(input.capability);
291
+ if (record.plan_digest !== computeBatchPlanDigest(plan) ||
292
+ record.children.length !== plan.length || record.children.some((child, index) => {
293
+ const expected = plan[index]!;
294
+ return child.task_id !== expected.task_id ||
295
+ JSON.stringify(child.blocked_by) !== JSON.stringify(expected.blocked_by);
296
+ })) throw new Error("plan_digest mismatch: persisted children do not match the authorized plan");
297
+ const commits: string[] = [];
298
+ for (const child of record.children) {
299
+ if (child.state !== "committed") {
300
+ if (child.commit !== null) throw new Error("uncommitted batch child has a commit");
301
+ continue;
302
+ }
303
+ const evidence = input.git?.lookupBatchCommit
304
+ ? await input.git.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
305
+ : input.kernel.lookupBatchCommit
306
+ ? await input.kernel.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
307
+ : await lookupBatchCommit({
308
+ root: input.root,
309
+ taskId: child.task_id,
310
+ batchId: record.batch_id,
311
+ branch: record.branch,
312
+ });
313
+ if (!evidence || evidence.commit !== child.commit) {
314
+ // Distinguish an unreachable recorded commit (external HEAD regression)
315
+ // from a fabricated record: reachability is only checkable in a real repo.
316
+ if (
317
+ evidence === null && typeof child.commit === "string" && child.commit.length > 0 &&
318
+ existsSync(join(input.root, ".git"))
319
+ ) {
320
+ const reach = spawnSync(
321
+ "git",
322
+ ["-C", input.root, "merge-base", "--is-ancestor", child.commit, "HEAD"],
323
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
324
+ );
325
+ if (reach.status !== 0) {
326
+ throw new Error(
327
+ `batch_head_lineage_broken: recorded commit ${child.commit} for ${child.task_id} is no longer reachable from HEAD`,
328
+ );
329
+ }
330
+ }
331
+ throw new Error(`persisted batch commit lacks evidence for ${child.task_id}`);
332
+ }
333
+ commits.push(evidence.commit);
334
+ }
335
+ if (JSON.stringify([...record.commits].sort()) !== JSON.stringify(commits.sort()))
336
+ throw new Error("persisted batch commit list does not match child evidence");
337
+ }
338
+
339
+ /** review-6: restate a terminal record and ensure its report exists. */
340
+ function replayTerminal(
341
+ root: string,
342
+ existing: BatchRunStateRecord,
343
+ ): BatchRunReport {
344
+ return finalize(
345
+ root,
346
+ existing,
347
+ `terminal state already reached: ${existing.batch_state}`,
348
+ "",
349
+ );
350
+ }
351
+
352
+ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): void {
353
+ const authorized = input.kernel.validateBatchAuthorization({
354
+ registry: input.registry,
355
+ capability: input.capability,
356
+ binding: {
357
+ batch_id: input.batch_id,
358
+ plan_digest: existing?.plan_digest ?? input.plan_digest,
359
+ base_head: existing?.base_head ?? input.base_head,
360
+ initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
361
+ budget: input.budget,
362
+ expires_at: input.authorization_expires_at,
363
+ },
364
+ });
365
+ if (authorized.issued_at !== input.confirmation_time ||
366
+ (existing && Date.parse(authorized.issued_at) <= Date.parse(existing.confirmation_time)))
367
+ throw new Error("the parked batch requires a fresh literal-user confirmation");
368
+ const children = input.children.map((child) => {
369
+ const { intent_path, intent_revision, intent_content_hash } = child;
370
+ if (intent_path === null || intent_revision === null || intent_content_hash === null)
371
+ throw new Error(`batch child ${child.task_id} has no complete intent identity`);
372
+ return { ...child, intent_path, intent_revision, intent_content_hash };
373
+ });
374
+ if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
375
+ input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
376
+ throw new Error("batch run input does not match the authorized plan or base_head");
377
+ }
378
+
379
+ export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
380
+ // Validation failure before the first enrollment: zero writes, rejected.
381
+ // reportFor only builds the report object; finalize would persist it and
382
+ // the spec forbids any write on a pre-enrollment rejection.
383
+ if (!input.children.length) {
384
+ const rejected = prepareBatchRunState({ ...input, children: [], now: input.now });
385
+ return reportFor(
386
+ { ...rejected, batch_state: "rejected" },
387
+ "batch plan is empty",
388
+ "Provide a non-empty enrollable child plan.",
389
+ );
390
+ }
391
+
392
+ let existing = readBatchRunState(input.root, input.batch_id);
393
+ if (existing) {
394
+ try {
395
+ await validatePersistedRun(input, existing);
396
+ } catch (error) {
397
+ const message = error instanceof Error ? error.message : String(error);
398
+ if (isLineageBreakError(message)) {
399
+ if (isTerminalBatchState(existing.batch_state)) return replayTerminal(input.root, existing);
400
+ const failed = failPersistedLineage(input.root, existing, message);
401
+ return finalize(input.root, failed, message, "");
402
+ }
403
+ throw error;
404
+ }
405
+ }
406
+ if (existing && isTerminalBatchState(existing.batch_state)) {
407
+ // Idempotent terminal replay: no state mutation, ensure the report.
408
+ return replayTerminal(input.root, existing);
409
+ }
410
+
411
+ if (existing?.batch_state === "needs_human") {
412
+ const priorConfirmation = Date.parse(existing.confirmation_time);
413
+ const nextConfirmation = Date.parse(input.confirmation_time);
414
+ const nextExpiry = Date.parse(input.authorization_expires_at);
415
+ const freshAuthorization =
416
+ Number.isFinite(nextConfirmation) &&
417
+ nextConfirmation > priorConfirmation &&
418
+ Number.isFinite(nextExpiry) &&
419
+ nextExpiry > Date.now();
420
+ if (!freshAuthorization) {
421
+ return finalize(
422
+ input.root,
423
+ existing,
424
+ "the parked batch requires a fresh literal-user confirmation",
425
+ "Resolve the parked child, then re-confirm the batch to continue.",
426
+ );
427
+ }
428
+
429
+ validateRunAuthorization(input, existing);
430
+ prepareBatchRunState({ ...input, now: input.now });
431
+ const remapped = await Promise.all(
432
+ existing.children.map(async (child) => {
433
+ if (child.state !== "needs_human") return child;
434
+ const fresh = requireFreshProjection(
435
+ await input.kernel.projectTask(input.root, child.task_id),
436
+ child.task_id,
437
+ );
438
+ if (
439
+ fresh.projection.lifecycle === "done" &&
440
+ fresh.projection.completion_ready
441
+ )
442
+ return { ...child, state: "settled" as const, reason: null };
443
+ if (fresh.error === null && fresh.claim?.task_id === child.task_id) {
444
+ return input.kernel.ownsTaskClaim(child.task_id)
445
+ ? { ...child, state: "enrolled" as const, reason: null }
446
+ : child;
447
+ }
448
+ return { ...child, state: "pending" as const, reason: null };
449
+ }),
450
+ );
451
+ // A parked child whose Kernel claim was re-bound to a foreign batch
452
+ // keeps the batch parked: resuming would select an independent sibling
453
+ // while that claim is still open.
454
+ if (remapped.some((child) => child.state === "needs_human")) {
455
+ // review-1(7th round): a re-parked foreign-claim child re-marks its
456
+ // transitive dependents skipped_blocked at re-park time, healing a
457
+ // prior park site that missed the marking. Only pending dependents
458
+ // are touched; already-skipped and terminal children stay as-is.
459
+ const reparked: BatchRunStateRecord = { ...existing, children: remapped };
460
+ for (const parked of remapped) {
461
+ if (parked.state === "needs_human")
462
+ skipDependents(
463
+ reparked,
464
+ parked.task_id,
465
+ `dependency ${parked.task_id} parked`,
466
+ );
467
+ }
468
+ existing = writeBatchRunState(input.root, {
469
+ ...reparked,
470
+ confirmation_time: input.confirmation_time,
471
+ authorization_expires_at: input.authorization_expires_at,
472
+ budget: input.budget,
473
+ batch_state: "needs_human",
474
+ });
475
+ return finalize(
476
+ input.root,
477
+ existing,
478
+ "a parked child's Kernel claim is held by a foreign batch",
479
+ "Resolve the foreign claim, then re-confirm the batch to continue.",
480
+ );
481
+ }
482
+ const resumedChildren = remapped.map((child) =>
483
+ child.state === "skipped_blocked"
484
+ ? { ...child, state: "pending" as const, reason: null }
485
+ : child,
486
+ );
487
+ existing = writeBatchRunState(input.root, {
488
+ ...existing,
489
+ confirmation_time: input.confirmation_time,
490
+ authorization_expires_at: input.authorization_expires_at,
491
+ budget: input.budget,
492
+ batch_state: "running",
493
+ consecutive_qa_failures: 0,
494
+ children: resumedChildren,
495
+ });
496
+ }
497
+
498
+ // A running batch can outlive its authorization while it waits on a child's
499
+ // reserved foreground Review. The driver owns every batch state transition,
500
+ // so the renewal the Host bound into the capability must be adopted here;
501
+ // otherwise the record keeps the expired stamp and the next child enrollment
502
+ // stops the batch as budget_stopped even though the literal user just
503
+ // re-confirmed it. Renewal requires the same proof the parked path requires:
504
+ // a strictly newer literal-user confirmation and a later, still-valid expiry.
505
+ if (existing?.batch_state === "running") {
506
+ const nextConfirmation = Date.parse(input.confirmation_time);
507
+ const nextExpiry = Date.parse(input.authorization_expires_at);
508
+ const priorConfirmation = Date.parse(existing.confirmation_time);
509
+ const persistedExpiry = Date.parse(existing.authorization_expires_at);
510
+ const renewedAuthorization =
511
+ Number.isFinite(nextConfirmation) &&
512
+ nextConfirmation > priorConfirmation &&
513
+ Number.isFinite(nextExpiry) &&
514
+ nextExpiry > Date.now() &&
515
+ nextExpiry > persistedExpiry;
516
+ if (renewedAuthorization) {
517
+ validateRunAuthorization(input, existing);
518
+ existing = writeBatchRunState(input.root, {
519
+ ...existing,
520
+ confirmation_time: input.confirmation_time,
521
+ authorization_expires_at: input.authorization_expires_at,
522
+ budget: input.budget,
523
+ });
524
+ }
525
+ }
526
+
527
+ // review-2(6th round): an enrolled or settled child from an interrupted
528
+ // run must be driven to its own terminal settlement and commit before any
529
+ // new child is selected, or startBatch would double-claim the task or
530
+ // falsely mark the batch completed while a Kernel claim is still open.
531
+ // resumeBatch drives that child to a terminal transition and then falls
532
+ // through to this function's normal loop, so the delegation terminates.
533
+ if (existing) {
534
+ const interruptedChild = existing.children.find(
535
+ (child) => child.state === "enrolled" || child.state === "settled",
536
+ );
537
+ if (interruptedChild) {
538
+ return await resumeBatch(input, (root, taskId) =>
539
+ input.kernel.projectTask(root, taskId),
540
+ );
541
+ }
542
+ }
543
+
544
+ if (!existing) {
545
+ try {
546
+ validateRunAuthorization(input, null);
547
+ } catch (error) {
548
+ return reportFor(
549
+ { ...prepareBatchRunState(input), batch_state: "rejected" },
550
+ error instanceof Error ? error.message : String(error),
551
+ "Correct the authorization or plan, then re-confirm the batch.",
552
+ );
553
+ }
554
+
555
+ // Mandatory batch branch preflight: run before any child is enrolled.
556
+ const preflightResult = input.git?.preflight
557
+ ? await input.git.preflight({
558
+ root: input.root,
559
+ initiative_slug: input.initiative_slug,
560
+ base_head: input.base_head,
561
+ })
562
+ : input.kernel.gitPreflight
563
+ ? await input.kernel.gitPreflight({
564
+ root: input.root,
565
+ initiative_slug: input.initiative_slug,
566
+ base_head: input.base_head,
567
+ })
568
+ : runBatchGitPreflight({
569
+ root: input.root,
570
+ initiative_slug: input.initiative_slug,
571
+ base_head: input.base_head,
572
+ });
573
+
574
+ if (!preflightResult.ok) {
575
+ const rejected = prepareBatchRunState({ ...input, now: input.now });
576
+ return reportFor(
577
+ { ...rejected, batch_state: "rejected" },
578
+ preflightResult.reason,
579
+ preflightResult.message || "Correct the preflight condition and re-confirm.",
580
+ );
581
+ }
582
+ }
583
+ let record: BatchRunStateRecord =
584
+ existing ??
585
+ prepareBatchRunState({
586
+ batch_id: input.batch_id,
587
+ initiative_slug: input.initiative_slug,
588
+ children: input.children,
589
+ plan_digest: input.plan_digest,
590
+ base_head: input.base_head,
591
+ confirmation_time: input.confirmation_time,
592
+ authorization_expires_at: input.authorization_expires_at,
593
+ budget: input.budget,
594
+ now: input.now,
595
+ });
596
+
597
+ const persist = (): void => {
598
+ record = writeBatchRunState(input.root, record);
599
+ };
600
+
601
+ if (record.batch_state === "prepared") {
602
+ record.batch_state = "running";
603
+ persist();
604
+ }
605
+
606
+ let head = record.commits.length
607
+ ? record.commits[record.commits.length - 1]!
608
+ : record.base_head;
609
+
610
+ // Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
611
+ const driftMessage = externalHeadDriftMessage(input.root, record);
612
+ if (driftMessage) {
613
+ record.batch_state = "failed";
614
+ persist();
615
+ return finalize(input.root, record, driftMessage, "");
616
+ }
617
+
618
+ while (record.batch_state === "running") {
619
+ const child = nextEnrollableChild(record);
620
+ if (!child) {
621
+ record.batch_state = record.children.some(
622
+ (c) => c.state === "needs_human" || c.state === "skipped_blocked",
623
+ )
624
+ ? "needs_human"
625
+ : "completed";
626
+ persist();
627
+ break;
628
+ }
629
+
630
+ // Enroll through the batch-derived capability.
631
+ // review-1(2nd round): the child is marked enrolled only after
632
+ // enrollTask succeeds, so an interruption before enrollment leaves the
633
+ // child pending and resume never fabricates a Kernel claim.
634
+ // review-2(5th round): a crash between durable enrollTask and the
635
+ // enrolled-persist leaves the child persisted as pending. Re-enrolling
636
+ // would double-claim, so reconcile against a fresh Kernel projection
637
+ // first: a pending child that already holds this batch's claim is
638
+ // adopted as enrolled without a second enrollment mutation.
639
+ // review-2(5th round): a crash between durable enrollTask and the
640
+ // enrolled-persist leaves the child persisted as pending. Re-enrolling
641
+ // would double-claim, so reconcile against a fresh Kernel projection
642
+ // first: a pending child that already holds this batch's claim is
643
+ // adopted as enrolled without a second enrollment mutation.
644
+ // review-1(6th round): a pending child whose claim is held by another
645
+ // batch must not be adopted (claim theft) and must not be re-enrolled
646
+ // (claim fight). Park it for human resolution instead.
647
+ let adoptedClaim = false;
648
+ let claimState: "none" | "ours" | "foreign" = "none";
649
+ const fresh = requireFreshProjection(
650
+ await input.kernel.projectTask(input.root, child.task_id),
651
+ child.task_id,
652
+ );
653
+ if (fresh.claim !== null && fresh.claim.task_id === child.task_id) {
654
+ claimState = input.kernel.ownsTaskClaim(child.task_id) ? "ours" : "foreign";
655
+ }
656
+ if (claimState === "ours") {
657
+ adoptedClaim = true;
658
+ } else if (claimState === "foreign") {
659
+ record.children = record.children.map((c) =>
660
+ c.task_id === child.task_id
661
+ ? { ...c, state: "needs_human", reason: "claim held by another batch" }
662
+ : c,
663
+ );
664
+ // review-7: a park takes the parked child's whole dependent subtree
665
+ // out of the run, including on this foreign-claim path.
666
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
667
+ record.batch_state = "needs_human";
668
+ persist();
669
+ return finalize(input.root, record, "claim held by another batch", "needs-human-attention");
670
+ }
671
+ if (adoptedClaim) {
672
+ record.children = record.children.map((c) =>
673
+ c.task_id === child.task_id
674
+ ? { ...c, state: "enrolled", reason: "adopted existing batch claim after interruption" }
675
+ : c,
676
+ );
677
+ persist();
678
+ } else {
679
+ // Re-read the clock after projection; an adopted claim is not a new enrollment.
680
+ const now = Date.now();
681
+ if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
682
+ record.batch_state = record.children.some(
683
+ (c) => c.state === "needs_human" || c.state === "skipped_blocked",
684
+ ) ? "needs_human" : "budget_stopped";
685
+ persist();
686
+ break;
687
+ }
688
+ try {
689
+ // A new capability has no in-memory consumption history. Restore only
690
+ // slots backed by this batch's independently queried commit evidence.
691
+ await validatePersistedRun(input, record);
692
+ const { issued_at: _issuedAt, ...binding } = input.kernel.validateBatchAuthorization({
693
+ registry: input.registry,
694
+ capability: input.capability,
695
+ binding: {
696
+ batch_id: record.batch_id, plan_digest: record.plan_digest,
697
+ base_head: record.base_head, initiative_slug: record.initiative_slug,
698
+ budget: record.budget, expires_at: record.authorization_expires_at,
699
+ },
700
+ });
701
+ for (const committed of record.children) {
702
+ if (committed.state === "committed" && !input.registry.isChildConsumed(input.capability, committed.task_id))
703
+ input.registry.consumeChild(input.capability, binding, committed.task_id);
704
+ }
705
+ await input.kernel.enrollTask({
706
+ root: input.root,
707
+ task_id: child.task_id,
708
+ batch: {
709
+ registry: input.registry,
710
+ capability: input.capability,
711
+ binding: { batch_id: input.batch_id, expected_head: head },
712
+ },
713
+ });
714
+ record.children = record.children.map((c) =>
715
+ c.task_id === child.task_id ? { ...c, state: "enrolled" } : c,
716
+ );
717
+ persist();
718
+ } catch (error) {
719
+ const message = error instanceof Error ? error.message : String(error);
720
+ // Only a typed BatchAuthorizationExpiryError is an intentional
721
+ // budget stop; free-form message matching misclassified
722
+ // infrastructure and validation failures as intentional stops.
723
+ if (isAuthorizationExpiryError(error)) {
724
+ record.children = record.children.map((c) =>
725
+ c.task_id === child.task_id ? { ...c, state: "pending", reason: message } : c,
726
+ );
727
+ record.batch_state = "budget_stopped";
728
+ persist();
729
+ break;
730
+ }
731
+ // review round 10: external HEAD movement after the pre-loop check
732
+ // (including between children) fails the batch, it never parks.
733
+ if (isLineageBreakError(message)) {
734
+ record.children = record.children.map((c) =>
735
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
736
+ );
737
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
738
+ record.batch_state = "failed";
739
+ persist();
740
+ break;
741
+ }
742
+ record.children = record.children.map((c) =>
743
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
744
+ );
745
+ // A park ends the run immediately: the parked child may still hold
746
+ // the sole Kernel claim, so selecting an independent sibling would
747
+ // fail its claim projection.
748
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
749
+ record.batch_state = "needs_human";
750
+ persist();
751
+ break;
752
+ }
753
+ }
754
+ // Drive the child through the Kernel obligation surface only; rework
755
+ // below the limit retries the same child in this inner loop.
756
+ let childTerminal = false;
757
+ while (!childTerminal) {
758
+ const terminal = await input.kernel.advanceTask(input.root, child.task_id);
759
+ if (terminal.state === "completed") {
760
+ childTerminal = true;
761
+ // review-6: a successful settlement resets the consecutive-QA-failure
762
+ // counter so separated failures do not park later children.
763
+ record.consecutive_qa_failures = 0;
764
+ record.children = record.children.map((c) =>
765
+ c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c,
766
+ );
767
+ persist();
768
+ // Scope-bound commit; a lineage failure fails the whole batch.
769
+ try {
770
+ const planChild = input.children.find((c) => c.task_id === child.task_id);
771
+ const intentPath = planChild?.intent_path ?? undefined;
772
+ const { commit } = input.git?.commitChild
773
+ ? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
774
+ : input.kernel.commitChild
775
+ ? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
776
+ : await commitBatchChild({
777
+ root: input.root,
778
+ taskId: child.task_id,
779
+ batchId: input.batch_id,
780
+ expectedHead: head,
781
+ branch: record.branch,
782
+ intentPath,
783
+ });
784
+ record.children = record.children.map((c) =>
785
+ c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
786
+ );
787
+ record.commits.push(commit);
788
+ head = commit;
789
+ persist();
790
+ } catch (error) {
791
+ const message = error instanceof Error ? error.message : String(error);
792
+ record.children = record.children.map((c) =>
793
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
794
+ );
795
+ // review-4: a commit failure is terminal; dependents must be
796
+ // skipped_blocked, not left pending.
797
+ skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
798
+ record.batch_state = "failed";
799
+ persist();
800
+ break;
801
+ }
802
+ } else if (terminal.state === "stopped") {
803
+ record.children = record.children.map((c) =>
804
+ c.task_id === child.task_id
805
+ ? { ...c, state: "needs_human", reason: "Kernel reported the child stopped" }
806
+ : c,
807
+ );
808
+ childTerminal = true;
809
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
810
+ record.batch_state = "needs_human";
811
+ persist();
812
+ } else if (terminal.state === "rework" && terminal.operation === "qa") {
813
+ record.consecutive_qa_failures += 1;
814
+ if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
815
+ record.children = record.children.map((c) =>
816
+ c.task_id === child.task_id
817
+ ? { ...c, state: "needs_human", reason: `QA failure limit reached: ${terminal.summary}` }
818
+ : c,
819
+ );
820
+ record.batch_state = "needs_human";
821
+ childTerminal = true;
822
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
823
+ persist();
824
+ } else {
825
+ // Below the limit: the inner loop re-drives the same child.
826
+ persist();
827
+ }
828
+ } else if (terminal.state === "failed" || terminal.state === "blocked" || terminal.state === "rework") {
829
+ const reason = terminal.state === "rework" ? terminal.summary : terminal.reason;
830
+ childTerminal = true;
831
+ record.children = record.children.map((c) =>
832
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
833
+ );
834
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
835
+ record.batch_state = "needs_human";
836
+ persist();
837
+ } else if (terminal.state === "review_ready") {
838
+ // A foreground Review reservation is owned by the calling host
839
+ // turn; the batch pauses here without parking the child.
840
+ record.children = record.children.map((c) =>
841
+ c.task_id === child.task_id
842
+ ? { ...c, state: "enrolled", reason: `review reservation ${terminal.operation_id} open` }
843
+ : c,
844
+ );
845
+ persist();
846
+ return reportFor(
847
+ record,
848
+ `child ${child.task_id} holds an open Review reservation`,
849
+ "Submit the reserved foreground Review verdict, then call startBatch again to continue.",
850
+ );
851
+ }
852
+ }
853
+ }
854
+
855
+ return finalize(input.root, record, stopReasonFor(record), "");
856
+ }
857
+
858
+ function stopReasonFor(record: BatchRunStateRecord): string | null {
859
+ switch (record.batch_state) {
860
+ case "budget_stopped":
861
+ return (
862
+ budgetStopReason(record, Date.now()) ??
863
+ "budget, deadline, or authorization expiry stopped new enrollments"
864
+ );
865
+ case "failed": {
866
+ const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
867
+ return failedChild?.reason ?? "a commit or lineage failure stopped the batch";
868
+ }
869
+ case "needs_human":
870
+ return "a parked child needs a human decision";
871
+ case "completed":
872
+ return "all enrollable children committed";
873
+ default:
874
+ return null;
875
+ }
876
+ }
877
+
878
+ /**
879
+ * review-1: resume the interrupted child first. A persisted enrolled child
880
+ * is driven through the Kernel obligation surface to its own terminal
881
+ * settlement and commit before any new enrollment or expiry check, so an
882
+ * interrupted run can never falsely complete or enroll a sibling while a
883
+ * child claim is still open. Never replays a committed mutation; refuses an
884
+ * expired authorization by requiring a new literal-user confirmation.
885
+ */
886
+ export async function resumeBatch(
887
+ input: StartBatchInput,
888
+ projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
889
+ ): Promise<BatchRunReport> {
890
+ let existing = readBatchRunState(input.root, input.batch_id);
891
+ if (!existing) return startBatch(input);
892
+ try {
893
+ await validatePersistedRun(input, existing);
894
+ } catch (error) {
895
+ const message = error instanceof Error ? error.message : String(error);
896
+ if (isLineageBreakError(message)) {
897
+ if (isTerminalBatchState(existing.batch_state)) return replayTerminal(input.root, existing);
898
+ const failed = failPersistedLineage(input.root, existing, message);
899
+ return finalize(input.root, failed, message, "");
900
+ }
901
+ throw error;
902
+ }
903
+ if (isTerminalBatchState(existing.batch_state))
904
+ return replayTerminal(input.root, existing);
905
+ if (existing.batch_state === "needs_human") return startBatch(input);
906
+
907
+ // An enrolled-but-unsettled child must reach its own Kernel terminal
908
+ // settlement before anything else, even under an expired authorization:
909
+ // its claim is already held and the Kernel owns its remaining obligations.
910
+ // review-1(2nd round): an `enrolled` child is verified against a fresh
911
+ // Kernel projection; a projection that finds no active claim means the
912
+ // persisted `enrolled` flag predates a successful enrollment, so the child
913
+ // returns to pending instead of being driven without a claim.
914
+ // Pending children are reconciled by the normal loop using its dependency-aware
915
+ // selection rule. Only a persisted in-flight child needs this recovery path.
916
+ const driven = existing.children.find(
917
+ (child) => child.state === "enrolled" || child.state === "settled",
918
+ );
919
+ if (driven) {
920
+ if (driven.state === "enrolled") {
921
+ // review round 11: verify external HEAD/branch lineage before Kernel
922
+ // advancement of an enrolled child. review round 12: a settled child
923
+ // defers to driveInterruptedChild's lookupBatchCommit verification,
924
+ // which adopts a verified own commit (crash before committed-persist)
925
+ // instead of misjudging it as external drift.
926
+ const drivenDrift = externalHeadDriftMessage(input.root, existing);
927
+ if (drivenDrift) {
928
+ const failed = failPersistedLineage(input.root, existing, drivenDrift);
929
+ return finalize(input.root, failed, drivenDrift, "");
930
+ }
931
+ const fresh = requireFreshProjection(
932
+ await input.kernel.projectTask(input.root, driven.task_id),
933
+ driven.task_id,
934
+ );
935
+ const holdsClaim =
936
+ fresh.claim !== null &&
937
+ fresh.claim.task_id === driven.task_id &&
938
+ input.kernel.ownsTaskClaim(driven.task_id);
939
+ if (holdsClaim === false && fresh.claim !== null && fresh.claim.task_id === driven.task_id && !input.kernel.ownsTaskClaim(driven.task_id)) {
940
+ // review-2(6th round): the claim on this child is held by
941
+ // another batch. Never advance a foreign claim; park for human.
942
+ // review-7: skip the parked child's dependent subtree too.
943
+ skipDependents(existing, driven.task_id, `dependency ${driven.task_id} parked`);
944
+ existing = writeBatchRunState(input.root, {
945
+ ...existing,
946
+ batch_state: "needs_human",
947
+ children: existing.children.map((c) =>
948
+ c.task_id === driven.task_id
949
+ ? { ...c, state: "needs_human", reason: "claim held by another batch" }
950
+ : c,
951
+ ),
952
+ });
953
+ return finalize(input.root, existing, "claim held by another batch", "needs-human-attention");
954
+ }
955
+ if (!holdsClaim) {
956
+ // review-1(4th round): a claimless projection still carries a
957
+ // projection body when the task is a terminal owner. If the fresh
958
+ // projection shows the child already reached its Kernel terminal
959
+ // settlement (lifecycle done), the persisted `enrolled` flag
960
+ // predates a crash between advanceTask and state persistence:
961
+ // settle the child here so resume continues at the commit
962
+ // obligation instead of re-enrolling completed work.
963
+ const settledRemotely =
964
+ fresh.projection.lifecycle === "done" &&
965
+ fresh.projection.completion_ready === true;
966
+ if (settledRemotely) {
967
+ existing = writeBatchRunState(input.root, {
968
+ ...existing,
969
+ children: existing.children.map((c) =>
970
+ c.task_id === driven.task_id
971
+ ? { ...c, state: "settled", reason: "crash after settlement; resuming at commit" }
972
+ : c,
973
+ ),
974
+ });
975
+ } else {
976
+ // No Kernel claim and no terminal settlement: the enrollment
977
+ // never completed. Re-mark the child pending so the normal
978
+ // loop re-enrolls it through the batch-derived capability.
979
+ existing = writeBatchRunState(input.root, {
980
+ ...existing,
981
+ children: existing.children.map((c) =>
982
+ c.task_id === driven.task_id
983
+ ? { ...c, state: "pending", reason: "enrollment did not complete; re-enrolling" }
984
+ : c,
985
+ ),
986
+ });
987
+ }
988
+ }
989
+ }
990
+ // review-2(3rd round): re-read the child from the persisted record so
991
+ // the re-marked pending child is never driven with the stale enrolled
992
+ // object captured before the state write.
993
+ const current =
994
+ existing.children.find((c) => c.task_id === driven.task_id) ?? driven;
995
+ if (current.state === "settled" || current.state === "enrolled") {
996
+ try {
997
+ const driven = await driveInterruptedChild(input, current);
998
+ if (driven) return driven;
999
+ } catch (error) {
1000
+ if (error instanceof BatchCommitAbortError) {
1001
+ const failed = readBatchRunState(input.root, input.batch_id)!;
1002
+ return finalize(input.root, failed, error.message, "");
1003
+ }
1004
+ throw error;
1005
+ }
1006
+ }
1007
+ // review-3(2nd round): driveInterruptedChild persisted its own
1008
+ // transitions; reload the record so the final state below reflects
1009
+ // the persisted child states, not the stale pre-recovery snapshot.
1010
+ existing = readBatchRunState(input.root, input.batch_id)!;
1011
+ if (isTerminalBatchState(existing.batch_state))
1012
+ return replayTerminal(input.root, existing);
1013
+ }
1014
+
1015
+ // No interrupted child remains: continue with the normal serial loop.
1016
+ return startBatch(input);
1017
+ }
1018
+
1019
+ /**
1020
+ * Drive one interrupted child (enrolled or settled) to its own terminal
1021
+ * settlement and commit. Returns a report when the child reached a state the
1022
+ * driver must stop on (review_ready, failure, park), or null to continue the
1023
+ * outer run afterwards.
1024
+ */
1025
+ class BatchCommitAbortError extends Error {}
1026
+
1027
+ async function driveInterruptedChild(
1028
+ input: StartBatchInput,
1029
+ child: BatchChildRun,
1030
+ ): Promise<BatchRunReport | null> {
1031
+ let record = readBatchRunState(input.root, input.batch_id)!;
1032
+ const persist = (): void => {
1033
+ record = writeBatchRunState(input.root, record);
1034
+ };
1035
+ if (record.batch_state === "prepared") {
1036
+ record.batch_state = "running";
1037
+ persist();
1038
+ }
1039
+ let head = record.commits.length
1040
+ ? record.commits[record.commits.length - 1]!
1041
+ : record.base_head;
1042
+ while (child.state === "enrolled" || child.state === "settled") {
1043
+ if (child.state === "settled") {
1044
+ // review-2(2nd round): a persisted settled child must not replay
1045
+ // Kernel advancement; proceed directly to its scope-bound commit.
1046
+ // review-3(5th round): a crash after commitChild created the commit
1047
+ // but before committed-persist replays commitChild on resume. Adopt
1048
+ // the existing batch commit idempotently instead of mutating again.
1049
+ let existing: { commit: string } | null = null;
1050
+ // review-1(6th round): a lookup failure must fail closed, not fall
1051
+ // through to commitChild, which could replay an existing commit.
1052
+ try {
1053
+ existing = input.git?.lookupBatchCommit
1054
+ ? await input.git.lookupBatchCommit(input.root, child.task_id, input.batch_id, head, record.branch)
1055
+ : input.kernel.lookupBatchCommit
1056
+ ? await input.kernel.lookupBatchCommit(
1057
+ input.root,
1058
+ child.task_id,
1059
+ input.batch_id,
1060
+ head,
1061
+ record.branch,
1062
+ )
1063
+ : await lookupBatchCommit({
1064
+ root: input.root,
1065
+ taskId: child.task_id,
1066
+ batchId: input.batch_id,
1067
+ expectedHead: head,
1068
+ branch: record.branch,
1069
+ });
1070
+ } catch (error: unknown) {
1071
+ const message =
1072
+ error instanceof Error ? error.message : String(error);
1073
+ record.children = record.children.map((c) =>
1074
+ c.task_id === child.task_id
1075
+ ? { ...c, state: "needs_human", reason: `commit lookup failed: ${message}` }
1076
+ : c,
1077
+ );
1078
+ // review-1(7th round): the commit-lookup park is a park site too;
1079
+ // its transitive dependents become skipped_blocked like every
1080
+ // other park, never left pending.
1081
+ skipDependents(
1082
+ record,
1083
+ child.task_id,
1084
+ `dependency ${child.task_id} failed to commit`,
1085
+ );
1086
+ const isLineageError =
1087
+ message.includes("batch_head_lineage_broken") || message.includes("lineage");
1088
+ record.batch_state = isLineageError ? "failed" : "needs_human";
1089
+ persist();
1090
+ throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
1091
+ }
1092
+ const planChild = input.children.find((c) => c.task_id === child.task_id);
1093
+ const intentPath = planChild?.intent_path ?? undefined;
1094
+ const doCommit = async () => {
1095
+ if (input.git?.commitChild) {
1096
+ return input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
1097
+ }
1098
+ if (input.kernel.commitChild) {
1099
+ return input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
1100
+ }
1101
+ return commitBatchChild({
1102
+ root: input.root,
1103
+ taskId: child.task_id,
1104
+ batchId: input.batch_id,
1105
+ expectedHead: head,
1106
+ branch: record.branch,
1107
+ intentPath,
1108
+ });
1109
+ };
1110
+ const adopted =
1111
+ existing ??
1112
+ (await doCommit().catch((error: unknown) => {
1113
+ const message = error instanceof Error ? error.message : String(error);
1114
+ record.children = record.children.map((c) =>
1115
+ c.task_id === child.task_id
1116
+ ? { ...c, state: "needs_human", reason: message }
1117
+ : c,
1118
+ );
1119
+ // review-4: dependents are skipped_blocked, not left pending.
1120
+ skipDependents(
1121
+ record,
1122
+ child.task_id,
1123
+ `dependency ${child.task_id} failed to commit`,
1124
+ );
1125
+ record.batch_state = "failed";
1126
+ persist();
1127
+ throw new BatchCommitAbortError(message);
1128
+ }));
1129
+ const { commit } = adopted;
1130
+ record.children = record.children.map((c) =>
1131
+ c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
1132
+ );
1133
+ record.commits.push(commit);
1134
+ head = commit;
1135
+ persist();
1136
+ child = { ...child, state: "committed", commit };
1137
+ continue;
1138
+ }
1139
+ const terminal = await input.kernel.advanceTask(input.root, child.task_id);
1140
+ if (terminal.state === "completed") {
1141
+ record.children = record.children.map((c) =>
1142
+ c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c,
1143
+ );
1144
+ record.consecutive_qa_failures = 0;
1145
+ persist();
1146
+ try {
1147
+ const planChild = input.children.find((c) => c.task_id === child.task_id);
1148
+ const intentPath = planChild?.intent_path ?? undefined;
1149
+ const { commit } = input.git?.commitChild
1150
+ ? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
1151
+ : input.kernel.commitChild
1152
+ ? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
1153
+ : await commitBatchChild({
1154
+ root: input.root,
1155
+ taskId: child.task_id,
1156
+ batchId: input.batch_id,
1157
+ expectedHead: head,
1158
+ branch: record.branch,
1159
+ intentPath,
1160
+ });
1161
+ record.children = record.children.map((c) =>
1162
+ c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
1163
+ );
1164
+ record.commits.push(commit);
1165
+ head = commit;
1166
+ persist();
1167
+ child = { ...child, state: "committed", commit };
1168
+ } catch (error) {
1169
+ const message = error instanceof Error ? error.message : String(error);
1170
+ record.children = record.children.map((c) =>
1171
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
1172
+ );
1173
+ // review-4: dependents are skipped_blocked, not left pending.
1174
+ skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
1175
+ record.batch_state = "failed";
1176
+ persist();
1177
+ return finalize(input.root, record, message, "");
1178
+ }
1179
+ } else if (terminal.state === "review_ready") {
1180
+ record.children = record.children.map((c) =>
1181
+ c.task_id === child.task_id
1182
+ ? { ...c, reason: `review reservation ${terminal.operation_id} open` }
1183
+ : c,
1184
+ );
1185
+ persist();
1186
+ // A foreground Review reservation stays with the calling host turn.
1187
+ return reportFor(
1188
+ record,
1189
+ `child ${child.task_id} holds an open Review reservation`,
1190
+ "Submit the reserved foreground Review verdict, then call startBatch again to continue.",
1191
+ );
1192
+ } else if (terminal.state === "rework" && terminal.operation === "qa") {
1193
+ // review-3(3rd round): interrupted-child rework applies the same
1194
+ // consecutive-failure limit as startBatch instead of parking on the
1195
+ // first rework; below the limit the loop re-drives the same child.
1196
+ record.consecutive_qa_failures += 1;
1197
+ if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
1198
+ const reason = `QA failure limit reached: ${terminal.summary}`;
1199
+ record.children = record.children.map((c) =>
1200
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
1201
+ );
1202
+ record.batch_state = "needs_human";
1203
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
1204
+ persist();
1205
+ return finalize(input.root, record, reason, "");
1206
+ }
1207
+ persist();
1208
+ } else {
1209
+ // stopped | failed | blocked: park and stop.
1210
+ const reason =
1211
+ terminal.state === "stopped" ? "Kernel reported the child stopped"
1212
+ : terminal.state === "rework" ? terminal.summary : terminal.reason;
1213
+ record.children = record.children.map((c) =>
1214
+ c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
1215
+ );
1216
+ record.batch_state = "needs_human";
1217
+ skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
1218
+ persist();
1219
+ return finalize(input.root, record, reason, "");
1220
+ }
1221
+ }
1222
+ // Child committed; continue the outer run afterwards.
1223
+ return null;
1224
+ }