immune-brain 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +223 -12
  2. package/README.zh-CN.md +228 -11
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +18 -5
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +189 -3
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1885 -149
  10. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  12. package/plugins/immune-brain/dist/imm-planner.md +4 -1
  13. package/plugins/immune-brain/dist/imm-run.md +198 -3
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
  15. package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
  17. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  18. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  19. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -0
  20. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +105 -58
  21. package/plugins/immune-brain/runtime/claude/mcp_server.ts +89 -16
  22. package/plugins/immune-brain/runtime/claude/role_agents.ts +4 -3
  23. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  24. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  25. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  26. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  27. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  28. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  29. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  30. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  31. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  32. package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
  33. package/plugins/immune-brain/runtime/loop_contract.ts +55 -2
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +10 -0
  36. package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
  37. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +16 -3
  38. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +2 -2
  41. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +946 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
  43. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +48 -33
  44. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
  45. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +22 -1
  46. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_state.ts +398 -12
  48. package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
  49. package/plugins/immune-brain/skills/imm-run/SKILL.md +4 -0
@@ -0,0 +1,387 @@
1
+ // Lane integration for unattended batch runs: build the batch-branch candidate
2
+ // for a Lane's commit, prove its identity against that commit, and fast-forward
3
+ // the batch branch. Git plumbing only. The runtime never creates, switches or
4
+ // deletes a Git worktree, never pushes, and never opens a pull request; the
5
+ // only ref it moves is the batch branch it already owns, by fast-forward.
6
+ // Defined by docs/specs/parallel-batch-lanes.spec.md (Temporal sequence, step 2).
7
+ import { spawnSync } from "node:child_process";
8
+ import type { SnapshotDescriptor } from "../assurance/coordinator";
9
+ import { runDeterministicQa } from "../assurance/qa";
10
+ import { VerificationAbortedError, type VerificationDescriptor } from "../assurance/verification";
11
+ import { parseTaskIntentV1 } from "../kernel/intent";
12
+ import { parseVerificationDescriptor } from "../verification_descriptor";
13
+
14
+ export type BatchIntegrationReason =
15
+ | "batch_integration_conflict"
16
+ | "batch_integration_check_failed"
17
+ | "batch_head_lineage_broken";
18
+
19
+ /** An integration failure that leaves the batch branch unmoved. */
20
+ export class BatchIntegrationError extends Error {
21
+ constructor(
22
+ readonly reason: BatchIntegrationReason,
23
+ message: string,
24
+ ) {
25
+ super(message);
26
+ this.name = "BatchIntegrationError";
27
+ }
28
+ }
29
+
30
+ const COMMITTER_ENV = {
31
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "Immune-Brain Batch",
32
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "immune-brain@local",
33
+ };
34
+
35
+ interface GitResult {
36
+ status: number | null;
37
+ stdout: string;
38
+ stderr: string;
39
+ }
40
+
41
+ function git(root: string, args: string[], extra: { input?: string; env?: Record<string, string> } = {}): GitResult {
42
+ const result = spawnSync("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], {
43
+ encoding: "utf8",
44
+ stdio: ["pipe", "pipe", "pipe"],
45
+ input: extra.input,
46
+ env: { ...process.env, ...COMMITTER_ENV, ...(extra.env ?? {}) },
47
+ });
48
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
49
+ }
50
+
51
+ function gitOut(root: string, args: string[], what: string): string {
52
+ const result = git(root, args);
53
+ if (result.status !== 0) throw new Error(`${what}: ${result.stderr.trim() || "git failed"}`);
54
+ return result.stdout;
55
+ }
56
+
57
+ /**
58
+ * The per-path result of the change from `from` to `to`: the new mode and blob
59
+ * of every path the change touches (a deletion is mode 000000 with a null
60
+ * blob). Two changes with equal identities leave the same bytes and modes.
61
+ */
62
+ export function changeIdentity(root: string, from: string, to: string): Map<string, string> {
63
+ const raw = gitOut(
64
+ root,
65
+ ["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-ext-diff", "--no-commit-id", from, to],
66
+ `failed to inspect change ${from}..${to}`,
67
+ );
68
+ const tokens = raw.split("\0");
69
+ const identity = new Map<string, string>();
70
+ for (let index = 0; index < tokens.length; index += 1) {
71
+ const meta = tokens[index]!;
72
+ if (!meta.startsWith(":")) continue;
73
+ const [, newMode, , newSha] = meta.slice(1).split(" ");
74
+ const path = tokens[index + 1];
75
+ if (path === undefined || !newMode || !newSha) throw new Error(`unreadable change record for ${from}..${to}`);
76
+ identity.set(path, `${newMode} ${newSha}`);
77
+ index += 1;
78
+ }
79
+ return identity;
80
+ }
81
+
82
+ /** Whether two changes touch exactly the same paths with the same blob and mode. */
83
+ export function identitiesEqual(left: Map<string, string>, right: Map<string, string>): boolean {
84
+ if (left.size !== right.size) return false;
85
+ for (const [path, value] of left) if (right.get(path) !== value) return false;
86
+ return true;
87
+ }
88
+
89
+ function singleParent(root: string, commit: string): string {
90
+ const parents = gitOut(root, ["rev-parse", `${commit}^@`], `failed to read parents of ${commit}`)
91
+ .split(/\s+/)
92
+ .filter(Boolean);
93
+ if (parents.length !== 1)
94
+ throw new BatchIntegrationError(
95
+ "batch_head_lineage_broken",
96
+ `batch_head_lineage_broken: lane commit ${commit} has ${parents.length} parents, expected exactly one`,
97
+ );
98
+ return parents[0]!;
99
+ }
100
+
101
+ function isAncestor(root: string, ancestor: string, descendant: string): boolean {
102
+ return git(root, ["merge-base", "--is-ancestor", ancestor, descendant]).status === 0;
103
+ }
104
+
105
+ /**
106
+ * The batch-branch candidate for a lane commit. When the batch head is still
107
+ * the lane base the lane commit itself is the candidate. Otherwise the lane's
108
+ * change is replayed onto the batch head with a tree-level merge that moves no
109
+ * ref and no working tree; a content conflict is an integration conflict.
110
+ */
111
+ export function buildCandidate(input: { root: string; batch_head: string; lane_base: string; lane_commit: string }): string {
112
+ const { root, batch_head: head, lane_base: base, lane_commit: laneCommit } = input;
113
+ if (head === base) return laneCommit;
114
+ const merged = git(root, ["merge-tree", "--write-tree", `--merge-base=${base}`, head, laneCommit]);
115
+ if (merged.status === 1)
116
+ throw new BatchIntegrationError(
117
+ "batch_integration_conflict",
118
+ `batch_integration_conflict: lane commit ${laneCommit} does not apply cleanly onto batch head ${head}`,
119
+ );
120
+ if (merged.status !== 0)
121
+ throw new Error(`failed to build integration candidate: ${merged.stderr.trim() || "git merge-tree failed"}`);
122
+ const tree = merged.stdout.split("\n")[0]?.trim();
123
+ if (!tree) throw new Error("failed to build integration candidate: no tree produced");
124
+ const message = gitOut(root, ["log", "-n", "1", "--format=%B", laneCommit], "failed to read lane commit message");
125
+ const author = gitOut(root, ["log", "-n", "1", "--format=%an%x00%ae%x00%aI", laneCommit], "failed to read lane commit author")
126
+ .trim()
127
+ .split("\0");
128
+ const created = git(root, ["commit-tree", tree, "-p", head, "-F", "-"], {
129
+ input: message,
130
+ env: {
131
+ GIT_AUTHOR_NAME: author[0] ?? COMMITTER_ENV.GIT_COMMITTER_NAME,
132
+ GIT_AUTHOR_EMAIL: author[1] ?? COMMITTER_ENV.GIT_COMMITTER_EMAIL,
133
+ ...(author[2] ? { GIT_AUTHOR_DATE: author[2] } : {}),
134
+ },
135
+ });
136
+ const candidate = created.stdout.trim();
137
+ if (created.status !== 0 || !candidate)
138
+ throw new Error(`failed to build integration candidate: ${created.stderr.trim() || "git commit-tree failed"}`);
139
+ return candidate;
140
+ }
141
+
142
+ export interface IntegrateLaneInput {
143
+ /** Coordinator worktree root, checked out on the batch branch. */
144
+ root: string;
145
+ branch: string;
146
+ /** The batch head the integration expects to sit on. */
147
+ batch_head: string;
148
+ lane_base: string;
149
+ lane_commit: string;
150
+ }
151
+
152
+ /**
153
+ * Check the lineage and build the candidate for a lane commit, proving its
154
+ * identity against that commit. Moves no ref and writes no working tree; the
155
+ * candidate is an unreferenced commit object until it is accepted.
156
+ */
157
+ export function prepareLaneCandidate(input: IntegrateLaneInput): string {
158
+ const { root, branch, batch_head: head, lane_base: base, lane_commit: laneCommit } = input;
159
+ const currentBranch = git(root, ["symbolic-ref", "--short", "HEAD"]);
160
+ if (currentBranch.status !== 0 || currentBranch.stdout.trim() !== branch)
161
+ throw new BatchIntegrationError(
162
+ "batch_head_lineage_broken",
163
+ `batch_head_lineage_broken: current branch ${currentBranch.stdout.trim()} does not match expected branch ${branch}`,
164
+ );
165
+ const currentHead = git(root, ["rev-parse", "HEAD"]).stdout.trim();
166
+ if (currentHead !== head)
167
+ throw new BatchIntegrationError(
168
+ "batch_head_lineage_broken",
169
+ `batch_head_lineage_broken: current HEAD ${currentHead} does not match expected batch head ${head}`,
170
+ );
171
+ if (singleParent(root, laneCommit) !== base)
172
+ throw new BatchIntegrationError(
173
+ "batch_head_lineage_broken",
174
+ `batch_head_lineage_broken: lane commit ${laneCommit} does not descend from lane base ${base}`,
175
+ );
176
+ if (!isAncestor(root, base, head))
177
+ throw new BatchIntegrationError(
178
+ "batch_head_lineage_broken",
179
+ `batch_head_lineage_broken: lane base ${base} is not an ancestor of batch head ${head}`,
180
+ );
181
+
182
+ const candidate = buildCandidate({ root, batch_head: head, lane_base: base, lane_commit: laneCommit });
183
+ if (!identitiesEqual(changeIdentity(root, base, laneCommit), changeIdentity(root, head, candidate)))
184
+ throw new BatchIntegrationError(
185
+ "batch_integration_conflict",
186
+ `batch_integration_conflict: candidate ${candidate} does not carry the lane commit ${laneCommit} change`,
187
+ );
188
+ return candidate;
189
+ }
190
+
191
+ /** Fast-forward the batch branch, which the caller has checked out, to an accepted candidate. */
192
+ export function fastForwardToCandidate(root: string, candidate: string): { commit: string } {
193
+ const moved = git(root, ["merge", "--ff-only", "--quiet", candidate]);
194
+ if (moved.status !== 0)
195
+ throw new BatchIntegrationError(
196
+ "batch_integration_conflict",
197
+ `batch_integration_conflict: batch branch could not fast-forward to ${candidate}: ${moved.stderr.trim() || "git merge failed"}`,
198
+ );
199
+ const landed = git(root, ["rev-parse", "HEAD"]).stdout.trim();
200
+ if (landed !== candidate) throw new Error(`batch branch landed on ${landed}, expected ${candidate}`);
201
+ return { commit: candidate };
202
+ }
203
+
204
+ /**
205
+ * Fast-forward the batch branch to the lane commit's candidate. Accepted only
206
+ * when the candidate's changed path set and per-path blob and mode equal the
207
+ * lane commit's; on any refusal the batch branch is unmoved. This form runs no
208
+ * descriptor rerun; the lane driver uses `integrateGuardedLaneCommit`.
209
+ */
210
+ export function integrateLaneCommit(input: IntegrateLaneInput): { commit: string } {
211
+ return fastForwardToCandidate(input.root, prepareLaneCandidate(input));
212
+ }
213
+
214
+ /** A child whose intent descriptors the integration guard reruns on the candidate. */
215
+ export interface IntegrationCheckChild {
216
+ task_id: string;
217
+ /** The intent sidecar, relative to the repository root; read from the candidate tree. */
218
+ intent_path: string | null;
219
+ }
220
+
221
+ export interface IntegrateGuardedInput extends IntegrateLaneInput {
222
+ child: IntegrationCheckChild;
223
+ /** Siblings already integrated; those whose commit lies in `lane_base..batch_head` are rechecked. */
224
+ siblings: Array<IntegrationCheckChild & { commit: string | null }>;
225
+ signal?: AbortSignal;
226
+ }
227
+
228
+ function commitsBetween(root: string, from: string, to: string): Set<string> {
229
+ return new Set(gitOut(root, ["rev-list", `${from}..${to}`], `failed to list commits ${from}..${to}`).split(/\s+/).filter(Boolean));
230
+ }
231
+
232
+ function readCandidateAcceptance(
233
+ root: string,
234
+ candidate: string,
235
+ check: IntegrationCheckChild,
236
+ ): Array<{ id: string; assertion: string; verification: string }> {
237
+ if (!check.intent_path)
238
+ throw new BatchIntegrationError(
239
+ "batch_integration_check_failed",
240
+ `batch_integration_check_failed: ${check.task_id} has no intent to rerun`,
241
+ );
242
+ const shown = git(root, ["show", `${candidate}:${check.intent_path}`]);
243
+ if (shown.status !== 0)
244
+ throw new BatchIntegrationError(
245
+ "batch_integration_check_failed",
246
+ `batch_integration_check_failed: intent ${check.intent_path} of ${check.task_id} is absent from candidate ${candidate}`,
247
+ );
248
+ try {
249
+ const intent = parseTaskIntentV1(JSON.parse(shown.stdout));
250
+ return intent.acceptance.map((item) => ({ id: `${check.task_id}:${item.id}`, assertion: item.assertion, verification: item.verification }));
251
+ } catch (error) {
252
+ throw new BatchIntegrationError(
253
+ "batch_integration_check_failed",
254
+ `batch_integration_check_failed: intent of ${check.task_id} is unreadable in candidate ${candidate}: ${error instanceof Error ? error.message : String(error)}`,
255
+ );
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Rerun the deterministic QA descriptors of the child and of each sibling
261
+ * integrated since the lane base, against the candidate's tree. An
262
+ * orchestration guard: it writes no Kernel attestation and no finding, and
263
+ * only materializes a throwaway delivery tree. Any failure, including one that
264
+ * prevents the rerun, rejects the candidate.
265
+ */
266
+ export async function verifyCandidateDescriptors(input: {
267
+ root: string;
268
+ candidate: string;
269
+ lane_base: string;
270
+ batch_head: string;
271
+ child: IntegrationCheckChild;
272
+ siblings: IntegrateGuardedInput["siblings"];
273
+ signal?: AbortSignal;
274
+ }): Promise<void> {
275
+ const { root, candidate } = input;
276
+ const since = commitsBetween(root, input.lane_base, input.batch_head);
277
+ const checks = [input.child, ...input.siblings.filter((sibling) => sibling.commit !== null && since.has(sibling.commit))];
278
+ const acceptance = checks.flatMap((check) => readCandidateAcceptance(root, candidate, check));
279
+ const descriptors = new Map<string, VerificationDescriptor>();
280
+ try {
281
+ for (const item of acceptance) descriptors.set(item.id, parseVerificationDescriptor(item.verification));
282
+ } catch (error) {
283
+ throw new BatchIntegrationError(
284
+ "batch_integration_check_failed",
285
+ `batch_integration_check_failed: a verification descriptor is invalid: ${error instanceof Error ? error.message : String(error)}`,
286
+ );
287
+ }
288
+ if (acceptance.length === 0)
289
+ throw new BatchIntegrationError("batch_integration_check_failed", "batch_integration_check_failed: no descriptor to rerun");
290
+ const tree = gitOut(root, ["rev-parse", `${candidate}^{tree}`], `failed to read the tree of ${candidate}`).trim();
291
+ const snapshot: SnapshotDescriptor = {
292
+ contract: "assurance_kernel/assurance_snapshot/v2",
293
+ task_id: input.child.task_id,
294
+ run_id: null,
295
+ role: "qa",
296
+ record_revision: "",
297
+ workspace_revision: "",
298
+ intent_revision: 0,
299
+ intent_content_hash: "",
300
+ diff_hash: "",
301
+ lifecycle: "active",
302
+ artifact_state: "frozen",
303
+ risk: "material",
304
+ fresh_acceptance_ids: [],
305
+ missing_acceptance_ids: [],
306
+ stale_attestation_ids: [],
307
+ acceptance,
308
+ dirty_files: [],
309
+ review_bundle_digest: null,
310
+ review_revision: {
311
+ contract: "assurance_kernel/review_revision_identity/v1",
312
+ base_head: input.lane_base,
313
+ review_commit: candidate,
314
+ review_tree: tree,
315
+ manifest_digest: "",
316
+ },
317
+ root,
318
+ };
319
+ let verdict;
320
+ try {
321
+ verdict = await runDeterministicQa(snapshot, descriptors, { signal: input.signal });
322
+ } catch (error) {
323
+ if (error instanceof VerificationAbortedError) throw error;
324
+ throw new BatchIntegrationError(
325
+ "batch_integration_check_failed",
326
+ `batch_integration_check_failed: the descriptor rerun could not complete: ${error instanceof Error ? error.message : String(error)}`,
327
+ );
328
+ }
329
+ if (verdict.decision !== "pass")
330
+ throw new BatchIntegrationError(
331
+ "batch_integration_check_failed",
332
+ `batch_integration_check_failed: ${(verdict.findings ?? []).map((finding) => finding.acceptance_id).join(", ") || "a descriptor"} failed on candidate ${candidate}`,
333
+ );
334
+ }
335
+
336
+ /**
337
+ * Integrate a lane commit: build and identity-check the candidate, rerun the
338
+ * descriptors on it, and only then fast-forward the batch branch. Every
339
+ * refusal leaves the batch branch unmoved.
340
+ */
341
+ export async function integrateGuardedLaneCommit(input: IntegrateGuardedInput): Promise<{ commit: string }> {
342
+ const candidate = prepareLaneCandidate(input);
343
+ await verifyCandidateDescriptors({
344
+ root: input.root,
345
+ candidate,
346
+ lane_base: input.lane_base,
347
+ batch_head: input.batch_head,
348
+ child: input.child,
349
+ siblings: input.siblings,
350
+ signal: input.signal,
351
+ });
352
+ return fastForwardToCandidate(input.root, candidate);
353
+ }
354
+
355
+ /**
356
+ * Find a candidate this batch already fast-forwarded onto the batch branch for
357
+ * a lane commit, so an interruption between the fast-forward and the state
358
+ * write is adopted rather than replayed. A commit qualifies only with the
359
+ * child's subject, the batch trailer, and a change identical to the lane's.
360
+ */
361
+ export function findIntegratedCandidate(input: {
362
+ root: string;
363
+ task_id: string;
364
+ batch_id: string;
365
+ from_head: string;
366
+ lane_base: string;
367
+ lane_commit: string;
368
+ }): string | null {
369
+ const { root, task_id: taskId, batch_id: batchId, from_head: from, lane_base: base, lane_commit: laneCommit } = input;
370
+ const listed = git(root, [
371
+ "log",
372
+ "--fixed-strings",
373
+ `--grep=imm(${taskId}):`,
374
+ // %(trailers) ends in a newline, so records are delimited by \x01, not by line.
375
+ "--format=%x01%H%x00%(trailers:key=Immune-Brain-Batch,valueonly)%x00%s",
376
+ `${from}..HEAD`,
377
+ ]);
378
+ if (listed.status !== 0) throw new Error(`failed to search for integrated ${taskId}: ${listed.stderr.trim()}`);
379
+ const lane = changeIdentity(root, base, laneCommit);
380
+ for (const record of listed.stdout.split("\x01")) {
381
+ const [commit, trailer, subject] = record.split("\0");
382
+ if (!commit || trailer?.trim() !== batchId || !subject?.trim().startsWith(`imm(${taskId}):`)) continue;
383
+ const parent = git(root, ["rev-parse", `${commit}^`]).stdout.trim();
384
+ if (parent && identitiesEqual(lane, changeIdentity(root, parent, commit))) return commit;
385
+ }
386
+ return null;
387
+ }
@@ -9,7 +9,7 @@ import { enrollTask } from "../kernel/enrollment";
9
9
  import type { EnrollmentAuthorityRegistry } from "../kernel/enrollment_authority";
10
10
  import { readTaskRecordRaw } from "../kernel/storage";
11
11
  import { readActiveClaimTaskId, isOwnBatchClaim } from "./batch_preflight";
12
- import type { BatchRunStateRecord } from "./batch_state";
12
+ import type { AnyBatchRunStateRecord } from "./batch_state";
13
13
  import { batchQaFailureFacts, type BatchChildAdvanceResult, type BatchRunnerKernelPort } from "./batch_runner";
14
14
 
15
15
  /** Inputs the shared port closes over. Only advanceTask is Host-owned. */
@@ -27,7 +27,7 @@ export interface BatchKernelPortInput {
27
27
  /** Resume ownership context straight from the batch preflight projection. */
28
28
  resume: {
29
29
  isResuming: boolean;
30
- existingBatch: BatchRunStateRecord | null;
30
+ existingBatch: AnyBatchRunStateRecord | null;
31
31
  batchBranch: string;
32
32
  };
33
33
  /** Optional test overrides for the remaining members (invariant I5). */