immune-brain 4.4.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
  8. package/plugins/immune-brain/dist/BASELINE.md +18 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
  10. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  11. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  12. package/plugins/immune-brain/dist/imm-loop.md +52 -6
  13. package/plugins/immune-brain/dist/imm-planner.md +102 -13
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  15. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  17. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  18. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  19. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  20. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  21. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  22. package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
  23. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  24. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  25. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  26. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  27. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  28. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  29. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  30. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  31. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  32. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  33. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  36. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  37. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  38. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  39. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
  40. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  41. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
  43. package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
  44. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  45. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  46. package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
  47. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  48. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  49. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
  51. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -6,7 +6,6 @@ import { spawnSync } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import {
9
- BatchAuthorizationExpiryError,
10
9
  type BatchAuthorityRegistry,
11
10
  type ValidatedBatchAuthorization,
12
11
  computeBatchPlanDigest,
@@ -23,6 +22,7 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
23
22
  recovery_findings: record.findings.filter(f => f.status === "open").map(f => ({ id: f.id, kind: f.kind, status: f.status, acceptance_id: f.acceptance_id })) };
24
23
  }
25
24
  import type { BatchPlanChild } from "./types";
25
+ import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
26
26
  import {
27
27
  type BatchRunnerGitPort,
28
28
  type BatchGitPreflightResult,
@@ -30,6 +30,7 @@ import {
30
30
  commitBatchChild,
31
31
  lookupBatchCommit,
32
32
  } from "./batch_git";
33
+ import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
33
34
  import {
34
35
  type BatchRunStateRecord,
35
36
  type BatchChildRun,
@@ -38,6 +39,7 @@ import {
38
39
  prepareBatchRunState,
39
40
  readBatchRunState,
40
41
  writeBatchRunState,
42
+ replaceBatchRunState,
41
43
  writeBatchRunReport,
42
44
  } from "./batch_state";
43
45
 
@@ -90,15 +92,15 @@ export interface BatchRunnerKernelPort {
90
92
  * is held under this batch's derived capability (consumed child slot). */
91
93
  ownsTaskClaim(taskId: string): boolean;
92
94
  /** review-8(4th rework): Kernel-side proof that a replacement parked-batch
93
- * authorization is genuine, unexpired, and bound to this exact batch_id,
95
+ * authorization is genuine and bound to this exact batch_id,
94
96
  * plan_digest, and base_head, validated against the Kernel's authoritative
95
- * binding state. Throws on fabrication, mismatch, or expiry; the driver
97
+ * binding state. Throws on fabrication or mismatch; the driver
96
98
  * must call this before accepting a parked-batch resume. */
97
99
  validateBatchAuthorization(input: {
98
100
  registry: BatchAuthorityRegistry;
99
101
  capability: object;
100
102
  binding: Pick<ValidatedBatchAuthorization,
101
- "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget" | "expires_at">;
103
+ "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget">;
102
104
  }): ValidatedBatchAuthorization;
103
105
  }
104
106
 
@@ -121,8 +123,7 @@ export interface StartBatchInput {
121
123
  plan_digest: string;
122
124
  base_head: string;
123
125
  confirmation_time: string;
124
- authorization_expires_at: string;
125
- budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
126
+ budget: { max_children: number; qa_failure_limit: number };
126
127
  now: string;
127
128
  kernel: BatchRunnerKernelPort;
128
129
  git?: BatchRunnerGitPort;
@@ -153,7 +154,7 @@ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: str
153
154
  );
154
155
  }
155
156
 
156
- function budgetStopReason(record: BatchRunStateRecord, now: number): string | null {
157
+ function budgetStopReason(record: BatchRunStateRecord): string | null {
157
158
  // review-3: budget counts enrollments consumed (children that left
158
159
  // pending), not commits, so parked children still consume authorization.
159
160
  const enrolledCount = record.children.filter(
@@ -161,22 +162,9 @@ function budgetStopReason(record: BatchRunStateRecord, now: number): string | nu
161
162
  ).length;
162
163
  if (enrolledCount >= record.budget.max_children)
163
164
  return `max_children budget exhausted (${record.budget.max_children})`;
164
- const deadline = Date.parse(record.budget.deadline_at);
165
- if (!Number.isNaN(deadline) && now >= deadline)
166
- return `deadline_at reached (${record.budget.deadline_at})`;
167
165
  return null;
168
166
  }
169
167
 
170
- /** review-7(3rd rework): the typed expiry marker now lives at the Kernel
171
- * boundary (kernel/batch_authority.ts) so a real enrollment-time expiry is
172
- * structurally classifiable as an intentional budget stop; re-exported here
173
- * for driver API compatibility. */
174
- export { BatchAuthorizationExpiryError };
175
-
176
- function isAuthorizationExpiryError(error: unknown): boolean {
177
- return error instanceof BatchAuthorizationExpiryError;
178
- }
179
-
180
168
  function requireFreshProjection<T extends AssuranceProjectionResult>(
181
169
  result: T,
182
170
  taskId: string,
@@ -201,7 +189,7 @@ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null
201
189
 
202
190
  const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
203
191
  completed: "The batch settled every enrollable child; review the commits and the tracker.",
204
- budget_stopped: "Budget, deadline, or authorization expiry stopped new enrollments; re-confirm to continue under a new authorization.",
192
+ budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
205
193
  failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
206
194
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
207
195
  needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
@@ -296,32 +284,49 @@ function failPersistedLineage(
296
284
  return writeBatchRunState(root, record);
297
285
  }
298
286
 
299
- /** review round 11: detect external HEAD movement or branch switch against a
300
- * persisted record's expected lineage; returns the failure message or null. */
301
- function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): string | null {
302
- if (!existsSync(join(root, ".git"))) return null;
303
- const head = record.commits.length
304
- ? record.commits[record.commits.length - 1]!
305
- : record.base_head;
306
- const headCheck = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
307
- encoding: "utf8",
308
- stdio: ["ignore", "pipe", "pipe"],
309
- });
310
- if (headCheck.status === 0 && headCheck.stdout.trim() && headCheck.stdout.trim() !== head) {
311
- return `batch_head_lineage_broken: current HEAD ${headCheck.stdout.trim()} does not match expected batch head ${head}`;
312
- }
313
- const branchCheck = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
314
- encoding: "utf8",
315
- stdio: ["ignore", "pipe", "pipe"],
287
+ /** A HEAD that fast-forwards on the batch branch is the user's own work: adopt
288
+ * it as the new expected head and record it. Any other movement is the fatal
289
+ * lineage break, returned as the failure message with the record unchanged. */
290
+ function reconcileLineage(
291
+ root: string,
292
+ record: BatchRunStateRecord,
293
+ ): { record: BatchRunStateRecord; failure: string | null } {
294
+ if (!existsSync(join(root, ".git"))) return { record, failure: null };
295
+ const expected = expectedBatchHead(record);
296
+ const lineage = classifyBatchLineage({
297
+ root,
298
+ branch: record.branch ?? "",
299
+ expectedHead: expected,
300
+ childCommits: record.commits,
301
+ batchId: record.batch_id,
316
302
  });
317
- const currentBranch = branchCheck.stdout.trim();
318
- if (branchCheck.status !== 0 || currentBranch !== record.branch) {
319
- return `batch_head_lineage_broken: current branch ${currentBranch} does not match expected batch branch ${record.branch}`;
320
- }
321
- return null;
303
+ if (lineage.kind === "broken") return { record, failure: lineage.message };
304
+ if (lineage.kind === "equal") return { record, failure: null };
305
+ return {
306
+ record: writeBatchRunState(root, {
307
+ ...record,
308
+ adopted_heads: [...(record.adopted_heads ?? []), { from: expected, to: lineage.head }],
309
+ }),
310
+ failure: null,
311
+ };
322
312
  }
323
313
 
324
314
  async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
315
+ if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
316
+ const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
317
+ const live = head.stdout.trim();
318
+ const expected = expectedBatchHead(record);
319
+ const adoptable = () =>
320
+ classifyBatchLineage({
321
+ root: input.root,
322
+ branch: record.branch ?? "",
323
+ expectedHead: expected,
324
+ childCommits: record.commits,
325
+ batchId: record.batch_id,
326
+ }).kind === "fast_forward";
327
+ if (head.status !== 0 || (live !== expected && !ownUnpersistedBatchHead(input.root, record, live) && !adoptable()))
328
+ throw new Error("first unpersisted batch commit provenance is invalid");
329
+ }
325
330
  const plan = input.registry.children(input.capability);
326
331
  if (record.plan_digest !== computeBatchPlanDigest(plan) ||
327
332
  record.children.length !== plan.length || record.children.some((child, index) => {
@@ -384,7 +389,7 @@ function replayTerminal(
384
389
  );
385
390
  }
386
391
 
387
- function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): void {
392
+ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): ValidatedBatchAuthorization {
388
393
  const authorized = input.kernel.validateBatchAuthorization({
389
394
  registry: input.registry,
390
395
  capability: input.capability,
@@ -394,7 +399,6 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
394
399
  base_head: existing?.base_head ?? input.base_head,
395
400
  initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
396
401
  budget: input.budget,
397
- expires_at: input.authorization_expires_at,
398
402
  },
399
403
  });
400
404
  if (authorized.issued_at !== input.confirmation_time ||
@@ -409,6 +413,32 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
409
413
  if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
410
414
  input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
411
415
  throw new Error("batch run input does not match the authorized plan or base_head");
416
+ return authorized;
417
+ }
418
+
419
+ function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
420
+ if (existing.plan_digest === input.plan_digest) return existing;
421
+ const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
422
+ budget: input.budget };
423
+ if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
424
+ input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
425
+ throw new Error("plan_digest mismatch: reconfirmation binding changed");
426
+ const validate = () => {
427
+ const plan = input.registry.children(input.capability);
428
+ if (existing.children.length !== plan.length || plan.some((child, index) => {
429
+ const old = existing.children[index]!, nextChild = input.children[index];
430
+ return !nextChild || old.task_id !== child.task_id || old.slice_id !== nextChild.slice_id ||
431
+ JSON.stringify(old.blocked_by) !== JSON.stringify(child.blocked_by) || JSON.stringify(nextChild.blocked_by) !== JSON.stringify(child.blocked_by);
432
+ })) throw new Error("plan_digest mismatch: reconfirmation topology changed");
433
+ const { issued_at: _issuedAt, ...binding } = validateRunAuthorization(input, { ...existing, plan_digest: input.plan_digest });
434
+ if (binding.branch !== existing.branch) throw new Error("plan_digest mismatch: reconfirmation branch changed");
435
+ prepareBatchRunState(input);
436
+ return input.registry.inspect(input.capability, binding);
437
+ };
438
+ const authority = validate();
439
+ const captured = takeReconfirmation(authority.nonce);
440
+ if (captured.root !== input.root || captured.plan_digest !== input.plan_digest) throw new Error("plan_digest mismatch: captured reconfirmation does not match run input");
441
+ return replaceBatchRunState(input.root, captured.stateBytes, next, () => { captured.assertUnchanged(); validate(); });
412
442
  }
413
443
 
414
444
  export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
@@ -460,7 +490,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
460
490
  }
461
491
 
462
492
  let existing = readBatchRunState(input.root, input.batch_id);
493
+ const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
463
494
  if (existing) {
495
+ existing = applyPlanReconfirmation(input, existing);
464
496
  try {
465
497
  await validatePersistedRun(input, existing);
466
498
  } catch (error) {
@@ -481,13 +513,10 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
481
513
  if (existing?.batch_state === "needs_human") {
482
514
  const priorConfirmation = Date.parse(existing.confirmation_time);
483
515
  const nextConfirmation = Date.parse(input.confirmation_time);
484
- const nextExpiry = Date.parse(input.authorization_expires_at);
485
516
  const freshAuthorization =
486
517
  Number.isFinite(nextConfirmation) &&
487
- nextConfirmation > priorConfirmation &&
488
- Number.isFinite(nextExpiry) &&
489
- nextExpiry > Date.now();
490
- if (!freshAuthorization) {
518
+ nextConfirmation > priorConfirmation;
519
+ if (!freshAuthorization && !reconfirmed) {
491
520
  return finalize(
492
521
  input.root,
493
522
  existing,
@@ -496,7 +525,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
496
525
  );
497
526
  }
498
527
 
499
- validateRunAuthorization(input, existing);
528
+ if (!reconfirmed) validateRunAuthorization(input, existing);
500
529
  prepareBatchRunState({ ...input, now: input.now });
501
530
  const remapped = await Promise.all(
502
531
  existing.children.map(async (child) => {
@@ -538,7 +567,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
538
567
  existing = writeBatchRunState(input.root, {
539
568
  ...reparked,
540
569
  confirmation_time: input.confirmation_time,
541
- authorization_expires_at: input.authorization_expires_at,
542
570
  budget: input.budget,
543
571
  batch_state: "needs_human",
544
572
  });
@@ -557,42 +585,12 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
557
585
  existing = writeBatchRunState(input.root, {
558
586
  ...existing,
559
587
  confirmation_time: input.confirmation_time,
560
- authorization_expires_at: input.authorization_expires_at,
561
588
  budget: input.budget,
562
589
  batch_state: "running",
563
590
  children: resumedChildren,
564
591
  });
565
592
  }
566
593
 
567
- // A running batch can outlive its authorization while it waits on a child's
568
- // reserved foreground Review. The driver owns every batch state transition,
569
- // so the renewal the Host bound into the capability must be adopted here;
570
- // otherwise the record keeps the expired stamp and the next child enrollment
571
- // stops the batch as budget_stopped even though the literal user just
572
- // re-confirmed it. Renewal requires the same proof the parked path requires:
573
- // a strictly newer literal-user confirmation and a later, still-valid expiry.
574
- if (existing?.batch_state === "running") {
575
- const nextConfirmation = Date.parse(input.confirmation_time);
576
- const nextExpiry = Date.parse(input.authorization_expires_at);
577
- const priorConfirmation = Date.parse(existing.confirmation_time);
578
- const persistedExpiry = Date.parse(existing.authorization_expires_at);
579
- const renewedAuthorization =
580
- Number.isFinite(nextConfirmation) &&
581
- nextConfirmation > priorConfirmation &&
582
- Number.isFinite(nextExpiry) &&
583
- nextExpiry > Date.now() &&
584
- nextExpiry > persistedExpiry;
585
- if (renewedAuthorization) {
586
- validateRunAuthorization(input, existing);
587
- existing = writeBatchRunState(input.root, {
588
- ...existing,
589
- confirmation_time: input.confirmation_time,
590
- authorization_expires_at: input.authorization_expires_at,
591
- budget: input.budget,
592
- });
593
- }
594
- }
595
-
596
594
  // review-2(6th round): an enrolled or settled child from an interrupted
597
595
  // run must be driven to its own terminal settlement and commit before any
598
596
  // new child is selected, or startBatch would double-claim the task or
@@ -658,7 +656,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
658
656
  plan_digest: input.plan_digest,
659
657
  base_head: input.base_head,
660
658
  confirmation_time: input.confirmation_time,
661
- authorization_expires_at: input.authorization_expires_at,
662
659
  budget: input.budget,
663
660
  now: input.now,
664
661
  });
@@ -672,17 +669,15 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
672
669
  persist();
673
670
  }
674
671
 
675
- let head = record.commits.length
676
- ? record.commits[record.commits.length - 1]!
677
- : record.base_head;
678
-
679
672
  // Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
680
- const driftMessage = externalHeadDriftMessage(input.root, record);
681
- if (driftMessage) {
673
+ const lineage = reconcileLineage(input.root, record);
674
+ record = lineage.record;
675
+ if (lineage.failure) {
682
676
  record.batch_state = "failed";
683
677
  persist();
684
- return finalize(input.root, record, driftMessage, "");
678
+ return finalize(input.root, record, lineage.failure, "");
685
679
  }
680
+ let head = expectedBatchHead(record);
686
681
 
687
682
  while (record.batch_state === "running") {
688
683
  const child = nextEnrollableChild(record);
@@ -745,9 +740,8 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
745
740
  );
746
741
  persist();
747
742
  } else {
748
- // Re-read the clock after projection; an adopted claim is not a new enrollment.
749
- const now = Date.now();
750
- if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
743
+ // An adopted claim is not a new enrollment.
744
+ if (budgetStopReason(record)) {
751
745
  record.batch_state = record.children.some(
752
746
  (c) => c.state === "needs_human" || c.state === "skipped_blocked",
753
747
  ) ? "needs_human" : "budget_stopped";
@@ -764,7 +758,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
764
758
  binding: {
765
759
  batch_id: record.batch_id, plan_digest: record.plan_digest,
766
760
  base_head: record.base_head, initiative_slug: record.initiative_slug,
767
- budget: record.budget, expires_at: record.authorization_expires_at,
761
+ budget: record.budget,
768
762
  },
769
763
  });
770
764
  for (const committed of record.children) {
@@ -786,17 +780,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
786
780
  persist();
787
781
  } catch (error) {
788
782
  const message = error instanceof Error ? error.message : String(error);
789
- // Only a typed BatchAuthorizationExpiryError is an intentional
790
- // budget stop; free-form message matching misclassified
791
- // infrastructure and validation failures as intentional stops.
792
- if (isAuthorizationExpiryError(error)) {
793
- record.children = record.children.map((c) =>
794
- c.task_id === child.task_id ? { ...c, state: "pending", reason: message } : c,
795
- );
796
- record.batch_state = "budget_stopped";
797
- persist();
798
- break;
799
- }
800
783
  // review round 10: external HEAD movement after the pre-loop check
801
784
  // (including between children) fails the batch, it never parks.
802
785
  if (isLineageBreakError(message)) {
@@ -834,8 +817,8 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
834
817
  switch (record.batch_state) {
835
818
  case "budget_stopped":
836
819
  return (
837
- budgetStopReason(record, Date.now()) ??
838
- "budget, deadline, or authorization expiry stopped new enrollments"
820
+ budgetStopReason(record) ??
821
+ "the child budget stopped new enrollments"
839
822
  );
840
823
  case "failed": {
841
824
  const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
@@ -853,17 +836,17 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
853
836
  /**
854
837
  * review-1: resume the interrupted child first. A persisted enrolled child
855
838
  * is driven through the Kernel obligation surface to its own terminal
856
- * settlement and commit before any new enrollment or expiry check, so an
857
- * interrupted run can never falsely complete or enroll a sibling while a
858
- * child claim is still open. Never replays a committed mutation; refuses an
859
- * expired authorization by requiring a new literal-user confirmation.
839
+ * settlement and commit before any new enrollment, so an interrupted run can
840
+ * never falsely complete or enroll a sibling while a child claim is still
841
+ * open. Never replays a committed mutation.
860
842
  */
861
843
  export async function resumeBatch(
862
844
  input: StartBatchInput,
863
845
  projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
864
846
  ): Promise<BatchRunReport> {
865
847
  let existing = readBatchRunState(input.root, input.batch_id);
866
- if (!existing) return startBatch(input);
848
+ if (!existing || existing.batch_state === "needs_human") return startBatch(input);
849
+ existing = applyPlanReconfirmation(input, existing);
867
850
  try {
868
851
  await validatePersistedRun(input, existing);
869
852
  } catch (error) {
@@ -877,11 +860,10 @@ export async function resumeBatch(
877
860
  }
878
861
  if (isTerminalBatchState(existing.batch_state))
879
862
  return replayTerminal(input.root, existing);
880
- if (existing.batch_state === "needs_human") return startBatch(input);
881
863
 
882
864
  // An enrolled-but-unsettled child must reach its own Kernel terminal
883
- // settlement before anything else, even under an expired authorization:
884
- // its claim is already held and the Kernel owns its remaining obligations.
865
+ // settlement before anything else: its claim is already held and the
866
+ // Kernel owns its remaining obligations.
885
867
  // review-1(2nd round): an `enrolled` child is verified against a fresh
886
868
  // Kernel projection; a projection that finds no active claim means the
887
869
  // persisted `enrolled` flag predates a successful enrollment, so the child
@@ -898,10 +880,11 @@ export async function resumeBatch(
898
880
  // defers to driveInterruptedChild's lookupBatchCommit verification,
899
881
  // which adopts a verified own commit (crash before committed-persist)
900
882
  // instead of misjudging it as external drift.
901
- const drivenDrift = externalHeadDriftMessage(input.root, existing);
902
- if (drivenDrift) {
903
- const failed = failPersistedLineage(input.root, existing, drivenDrift);
904
- return finalize(input.root, failed, drivenDrift, "");
883
+ const drivenLineage = reconcileLineage(input.root, existing);
884
+ existing = drivenLineage.record;
885
+ if (drivenLineage.failure) {
886
+ const failed = failPersistedLineage(input.root, existing, drivenLineage.failure);
887
+ return finalize(input.root, failed, drivenLineage.failure, "");
905
888
  }
906
889
  const fresh = requireFreshProjection(
907
890
  await input.kernel.projectTask(input.root, driven.task_id),
@@ -1032,9 +1015,7 @@ async function driveInterruptedChild(
1032
1015
  record.batch_state = "running";
1033
1016
  persist();
1034
1017
  }
1035
- let head = record.commits.length
1036
- ? record.commits[record.commits.length - 1]!
1037
- : record.base_head;
1018
+ let head = expectedBatchHead(record);
1038
1019
  while (child.state === "enrolled" || child.state === "settled") {
1039
1020
  if (child.state === "settled") {
1040
1021
  // review-2(2nd round): a persisted settled child must not replay
@@ -1085,6 +1066,13 @@ async function driveInterruptedChild(
1085
1066
  persist();
1086
1067
  throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
1087
1068
  }
1069
+ if (!existing) {
1070
+ // No own commit exists yet: a fast-forward since settlement is the
1071
+ // user's work. Adopt it so this child's commit parents on it; a broken
1072
+ // lineage is left for commitChild to refuse with its own message.
1073
+ record = reconcileLineage(input.root, record).record;
1074
+ head = expectedBatchHead(record);
1075
+ }
1088
1076
  const planChild = input.children.find((c) => c.task_id === child.task_id);
1089
1077
  const intentPath = planChild?.intent_path ?? undefined;
1090
1078
  const doCommit = async () => {
@@ -1103,6 +1091,8 @@ async function driveInterruptedChild(
1103
1091
  intentPath,
1104
1092
  });
1105
1093
  };
1094
+ if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
1095
+ throw new Error("first unpersisted batch commit provenance is invalid");
1106
1096
  const adopted =
1107
1097
  existing ??
1108
1098
  (await doCommit().catch((error: unknown) => {
@@ -4,7 +4,7 @@
4
4
  import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, renameSync, lstatSync, constants, rmSync } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { dirname, join } from "node:path";
7
- import { readSecureProjectFile, withKernelStoreLock } from "../kernel/storage";
7
+ import { readSecureProjectFile, readSecureProjectBytes, withKernelStoreLock } from "../kernel/storage";
8
8
  import type { BatchPlanChild } from "./types";
9
9
 
10
10
  export type BatchRunState =
@@ -45,11 +45,8 @@ export interface BatchRunStateRecord {
45
45
  branch?: string;
46
46
  /** Timestamp of the literal-user batch confirmation. */
47
47
  confirmation_time: string;
48
- /** Authorization expiry from the batch capability binding. */
49
- authorization_expires_at: string;
50
48
  budget: {
51
49
  max_children: number;
52
- deadline_at: string;
53
50
  qa_failure_limit: number;
54
51
  };
55
52
  batch_state: BatchRunState;
@@ -58,6 +55,11 @@ export interface BatchRunStateRecord {
58
55
  consecutive_qa_failures: number;
59
56
  /** Commit heads produced by this batch, in child order. */
60
57
  commits: string[];
58
+ /**
59
+ * Fast-forward commits the user made on the batch branch, adopted as the new
60
+ * expected head. Never attributed to a child and never rewritten.
61
+ */
62
+ adopted_heads?: Array<{ from: string; to: string }>;
61
63
  created_at: string;
62
64
  updated_at: string;
63
65
  }
@@ -101,8 +103,19 @@ function statePath(batchId: string): string {
101
103
  return join(".imm", "state", "batches", `${batchId}.json`);
102
104
  }
103
105
 
106
+ /**
107
+ * A record persisted while batch authorization still carried a clock holds
108
+ * `authorization_expires_at` and `budget.deadline_at`. They are ignored on read
109
+ * and never written back.
110
+ */
111
+ function withoutRetiredClock(record: BatchRunStateRecord): BatchRunStateRecord {
112
+ const { authorization_expires_at: _expiry, ...rest } = record as BatchRunStateRecord & { authorization_expires_at?: unknown };
113
+ const { deadline_at: _deadline, ...budget } = rest.budget as BatchRunStateRecord["budget"] & { deadline_at?: unknown };
114
+ return { ...rest, budget };
115
+ }
116
+
104
117
  function canonicalBytes(record: BatchRunStateRecord): string {
105
- return `${JSON.stringify(record, null, 2)}\n`;
118
+ return `${JSON.stringify(withoutRetiredClock(record), null, 2)}\n`;
106
119
  }
107
120
 
108
121
  function validateRecordShape(value: unknown, batchId: string): asserts value is BatchRunStateRecord {
@@ -121,8 +134,6 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
121
134
  throw new Error(`batch run state ${batchId} has an invalid branch`);
122
135
  if (!isCanonicalTimestamp(record.confirmation_time))
123
136
  throw new Error(`batch run state ${batchId} has an invalid confirmation_time`);
124
- if (!isCanonicalTimestamp(record.authorization_expires_at))
125
- throw new Error(`batch run state ${batchId} has an invalid authorization_expires_at`);
126
137
  if (!isCanonicalTimestamp(record.created_at) || !isCanonicalTimestamp(record.updated_at))
127
138
  throw new Error(`batch run state ${batchId} has invalid state timestamps`);
128
139
  if (!Array.isArray(record.children) || record.children.length === 0)
@@ -142,7 +153,6 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
142
153
  typeof budget.max_children !== "number" ||
143
154
  !Number.isInteger(budget.max_children) ||
144
155
  budget.max_children <= 0 ||
145
- !isCanonicalTimestamp(budget.deadline_at) ||
146
156
  typeof budget.qa_failure_limit !== "number" ||
147
157
  !Number.isInteger(budget.qa_failure_limit) ||
148
158
  budget.qa_failure_limit <= 0
@@ -150,6 +160,20 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
150
160
  throw new Error(`batch run state ${batchId} has an invalid budget`);
151
161
  if (!Array.isArray(record.commits) || record.commits.some((c) => typeof c !== "string"))
152
162
  throw new Error(`batch run state ${batchId} has an invalid commits list`);
163
+ if (
164
+ record.adopted_heads !== undefined &&
165
+ (!Array.isArray(record.adopted_heads) ||
166
+ record.adopted_heads.some(
167
+ (a: unknown) =>
168
+ typeof a !== "object" ||
169
+ a === null ||
170
+ typeof (a as { from?: unknown }).from !== "string" ||
171
+ !(a as { from: string }).from ||
172
+ typeof (a as { to?: unknown }).to !== "string" ||
173
+ !(a as { to: string }).to,
174
+ ))
175
+ )
176
+ throw new Error(`batch run state ${batchId} has an invalid adopted_heads list`);
153
177
  const seenTaskIds = new Set<string>();
154
178
  for (const child of record.children) {
155
179
  if (
@@ -211,8 +235,7 @@ export function prepareBatchRunState(input: {
211
235
  base_head: string;
212
236
  branch?: string;
213
237
  confirmation_time: string;
214
- authorization_expires_at: string;
215
- budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
238
+ budget: { max_children: number; qa_failure_limit: number };
216
239
  now: string;
217
240
  }): BatchRunStateRecord {
218
241
  validateBatchId(input.batch_id);
@@ -224,7 +247,6 @@ export function prepareBatchRunState(input: {
224
247
  base_head: input.base_head,
225
248
  branch: input.branch ?? `imm/${input.initiative_slug}`,
226
249
  confirmation_time: input.confirmation_time,
227
- authorization_expires_at: input.authorization_expires_at,
228
250
  budget: input.budget,
229
251
  batch_state: "prepared",
230
252
  children: input.children.map((child) => ({
@@ -243,12 +265,18 @@ export function prepareBatchRunState(input: {
243
265
  return prepared;
244
266
  }
245
267
 
268
+ /** Validate a captured snapshot without reading or writing filesystem state. */
269
+ export function parseBatchRunState(raw: string, batchId: string): BatchRunStateRecord {
270
+ validateBatchId(batchId);
271
+ const parsed: unknown = JSON.parse(raw);
272
+ validateRecordShape(parsed, batchId);
273
+ return withoutRetiredClock(parsed);
274
+ }
275
+
246
276
  export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
247
277
  const path = statePath(batchId);
248
278
  if (!existsSync(join(root, path))) return null;
249
- const parsed: unknown = JSON.parse(readSecureProjectFile(root, path));
250
- validateRecordShape(parsed, batchId);
251
- return parsed;
279
+ return parseBatchRunState(readSecureProjectFile(root, path), batchId);
252
280
  }
253
281
 
254
282
  function ensureSecureDirectory(root: string, relative: string): string {
@@ -287,6 +315,19 @@ function writeFileAtomically(root: string, relative: string, bytes: string): voi
287
315
  }
288
316
  }
289
317
 
318
+ /** Replace an existing authorization only after a locked expected-byte check. */
319
+ export function replaceBatchRunState(root: string, expected: Buffer, next: BatchRunStateRecord, validate: () => void): BatchRunStateRecord {
320
+ const path = statePath(next.batch_id);
321
+ validateRecordShape(next, next.batch_id);
322
+ return withKernelStoreLock(root, () => {
323
+ if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
324
+ validate();
325
+ const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
326
+ writeFileAtomically(root, path, canonicalBytes(stored));
327
+ return stored;
328
+ });
329
+ }
330
+
290
331
  export function writeBatchRunState(
291
332
  root: string,
292
333
  record: BatchRunStateRecord,
@@ -296,10 +337,10 @@ export function writeBatchRunState(
296
337
  return withKernelStoreLock(root, () => {
297
338
  const existing = existsSync(join(root, path)) ? readSecureProjectFile(root, path) : null;
298
339
  if (existing !== null && existing === canonicalBytes(record)) return record;
299
- const stored: BatchRunStateRecord = {
340
+ const stored = withoutRetiredClock({
300
341
  ...record,
301
342
  updated_at: new Date().toISOString(),
302
- };
343
+ });
303
344
  // review-4: secure directory + atomic no-symlink write.
304
345
  ensureSecureDirectory(root, join(".imm", "state", "batches"));
305
346
  writeFileAtomically(root, path, canonicalBytes(stored));
@@ -2,13 +2,11 @@ import type { GithubInitiativeObservation } from "../github_issue_tracker";
2
2
 
3
3
  export interface BatchPlanBudget {
4
4
  max_children: number;
5
- deadline_at: string;
6
5
  qa_failure_limit: number;
7
6
  }
8
7
 
9
8
  export interface BatchPlanBudgetInput {
10
9
  max_children?: number;
11
- deadline_at?: string;
12
10
  qa_failure_limit?: number;
13
11
  }
14
12