immune-brain 5.0.0 → 5.1.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 (29) 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 +1 -0
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +24 -3
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1339 -66
  8. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
  9. package/plugins/immune-brain/dist/imm-run.md +198 -3
  10. package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
  11. package/plugins/immune-brain/runtime/claude/interaction.ts +6 -0
  12. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +16 -4
  13. package/plugins/immune-brain/runtime/claude/mcp_server.ts +24 -2
  14. package/plugins/immune-brain/runtime/claude/role_agents.ts +4 -3
  15. package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
  16. package/plugins/immune-brain/runtime/loop_contract.ts +55 -2
  17. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  18. package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
  19. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +15 -2
  20. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
  21. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +2 -2
  22. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +909 -0
  23. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
  24. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +41 -22
  25. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +20 -1
  26. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
  27. package/plugins/immune-brain/runtime/unattended/batch_state.ts +356 -8
  28. package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
  29. package/plugins/immune-brain/skills/imm-run/SKILL.md +4 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { stableStringify } from "../canonical_json";
3
- import { observeGithubInitiative, type GithubInitiativeObservation } from "../github_issue_tracker";
3
+ import { observeGithubInitiative } from "../github_issue_tracker";
4
+ import { hasLocalInitiative, observeLocalInitiative } from "../local_initiative";
4
5
  import { readTaskTombstone } from "../kernel/backend_claim";
5
6
  import {
6
7
  observeTaskIntent,
@@ -8,12 +9,14 @@ import {
8
9
  } from "../kernel/intent";
9
10
  import { readTaskRecordRaw } from "../kernel/storage";
10
11
  import { inspectSpecBinding, type SpecBindingInspection } from "../kernel/spec_binding";
12
+ import { projectParallelGroups } from "./batch_schedule";
11
13
  import type {
12
14
  BatchPlan,
13
15
  BatchPlanBudget,
14
16
  BatchPlanChild,
15
17
  BatchPlanChildReason,
16
18
  BatchPlanDigestChild,
19
+ InitiativeObservation,
17
20
  InitiativeObservationReader,
18
21
  ProjectBatchPlanInput,
19
22
  } from "./types";
@@ -57,12 +60,22 @@ function timestamp(value: unknown, name: string): { milliseconds: number; iso: s
57
60
  return { milliseconds, iso: new Date(milliseconds).toISOString() };
58
61
  }
59
62
 
60
- function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug: string): GithubInitiativeObservation {
61
- if (!value || value.contract !== "immune_brain/github_initiative_observation/v1")
63
+ /**
64
+ * The Initiative's carrier decides the reader: a Local carrier file owns its
65
+ * slug and is read with no GitHub operation; every other slug is a GitHub one.
66
+ */
67
+ export const observeInitiative: InitiativeObservationReader = async (root, initiativeSlug) =>
68
+ hasLocalInitiative(root, initiativeSlug)
69
+ ? observeLocalInitiative(root, initiativeSlug)
70
+ : observeGithubInitiative(root, initiativeSlug);
71
+
72
+ function normalizeObservation(value: InitiativeObservation, initiativeSlug: string): InitiativeObservation {
73
+ const local = value?.contract === "immune_brain/local_initiative_observation/v1";
74
+ if (!value || (!local && value.contract !== "immune_brain/github_initiative_observation/v1"))
62
75
  throw new Error("tracker returned an invalid Initiative observation contract");
63
76
  if (value.initiative_id !== initiativeSlug)
64
77
  throw new Error("tracker returned an observation for another Initiative");
65
- if (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0)
78
+ if (!local && (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0))
66
79
  throw new Error("tracker returned an invalid Initiative Issue number");
67
80
  if (!Array.isArray(value.tasks)) throw new Error("tracker returned an invalid Initiative Task list");
68
81
  const taskIds = new Set<string>();
@@ -71,7 +84,8 @@ function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug
71
84
  if (!task || typeof task !== "object") throw new Error(`tracker Task ${index} is invalid`);
72
85
  if (!ID_PATTERN.test(task.task_id)) throw new Error(`tracker Task ${index} has an invalid task_id`);
73
86
  if (!ID_PATTERN.test(task.slice_id)) throw new Error(`tracker Task ${index} has an invalid slice_id`);
74
- if (!Number.isSafeInteger(task.issue_number) || task.issue_number <= 0)
87
+ const issueNumber = (task as { issue_number?: number }).issue_number;
88
+ if (!local && (!Number.isSafeInteger(issueNumber) || issueNumber! <= 0))
75
89
  throw new Error(`tracker Task ${task.task_id} has an invalid Issue number`);
76
90
  if (!Array.isArray(task.blocked_by) || task.blocked_by.some((id) => typeof id !== "string" || !ID_PATTERN.test(id)))
77
91
  throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
@@ -88,13 +102,15 @@ function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug
88
102
  const unknown = task.blocked_by.find((id) => !taskIds.has(id));
89
103
  if (unknown) throw new Error(`tracker Task ${task.task_id} depends on unknown Task ${unknown}`);
90
104
  }
91
- return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) };
105
+ return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) } as InitiativeObservation;
92
106
  }
93
107
 
94
- function dependencyOrder(observation: GithubInitiativeObservation): GithubInitiativeObservation["tasks"] {
95
- const remaining = new Map(observation.tasks.map((task) => [task.task_id, task]));
108
+ type ObservedTask = { task_id: string; slice_id: string; blocked_by: string[] };
109
+
110
+ function dependencyOrder(observation: InitiativeObservation): ObservedTask[] {
111
+ const remaining = new Map<string, ObservedTask>(observation.tasks.map((task) => [task.task_id, task]));
96
112
  const done = new Set<string>();
97
- const order: GithubInitiativeObservation["tasks"] = [];
113
+ const order: ObservedTask[] = [];
98
114
  while (remaining.size) {
99
115
  const ready = [...remaining.values()]
100
116
  .filter((task) => task.blocked_by.every((id) => done.has(id)))
@@ -109,7 +125,7 @@ function dependencyOrder(observation: GithubInitiativeObservation): GithubInitia
109
125
  return order;
110
126
  }
111
127
 
112
- function dependencyClosures(order: GithubInitiativeObservation["tasks"]): Map<string, string[]> {
128
+ function dependencyClosures(order: ObservedTask[]): Map<string, string[]> {
113
129
  const closures = new Map<string, string[]>();
114
130
  for (const task of order) {
115
131
  const closure = new Set<string>();
@@ -138,7 +154,7 @@ export async function projectBatchPlan(
138
154
  root: string,
139
155
  initiativeSlug: string,
140
156
  input: ProjectBatchPlanInput,
141
- readInitiative: InitiativeObservationReader = observeGithubInitiative,
157
+ readInitiative: InitiativeObservationReader = observeInitiative,
142
158
  ): Promise<BatchPlan> {
143
159
  if (!ID_PATTERN.test(initiativeSlug)) throw new Error("initiative_slug is invalid");
144
160
  if (!input || typeof input !== "object") throw new Error("batch plan input is required");
@@ -147,6 +163,7 @@ export async function projectBatchPlan(
147
163
  const order = dependencyOrder(trackerObservation);
148
164
  const closures = dependencyClosures(order);
149
165
  const children: BatchPlanChild[] = [];
166
+ const scopeHints = new Map<string, string[]>();
150
167
  for (const task of order) {
151
168
  const blockedBy = closures.get(task.task_id)!;
152
169
  const child = { task_id: task.task_id, slice_id: task.slice_id, blocked_by: blockedBy };
@@ -187,6 +204,7 @@ export async function projectBatchPlan(
187
204
  });
188
205
  continue;
189
206
  }
207
+ scopeHints.set(task.task_id, [...read.intent.scope_hint]);
190
208
  children.push({
191
209
  ...child,
192
210
  status: "enrollable",
@@ -233,5 +251,12 @@ export async function projectBatchPlan(
233
251
  enrollable,
234
252
  plan_digest: planDigest,
235
253
  budget: budget(input, enrollable.length),
254
+ ...projectParallelGroups(
255
+ enrollable.map((child) => ({
256
+ task_id: child.task_id,
257
+ blocked_by: child.blocked_by,
258
+ scope_hint: scopeHints.get(child.task_id)!,
259
+ })),
260
+ ),
236
261
  };
237
262
  }
@@ -26,7 +26,7 @@ import { pathMatchesScope } from "../workspace_scope";
26
26
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
27
27
  import { projectBatchPlan } from "./batch_plan";
28
28
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
29
- import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
29
+ import { isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
30
30
  import type {
31
31
  BatchPlanBudget,
32
32
  BatchPlanChild,
@@ -50,7 +50,7 @@ export interface BatchPreflightProjection {
50
50
  initiative_slug: string;
51
51
  batch_branch: string;
52
52
  is_resuming: boolean;
53
- existing_batch: BatchRunStateRecord | null;
53
+ existing_batch: AnyBatchRunStateRecord | null;
54
54
  base_head: string;
55
55
  budget: BatchPlanBudget;
56
56
  plan_digest: string;
@@ -59,6 +59,9 @@ export interface BatchPreflightProjection {
59
59
  risk_by_task: Record<string, string>;
60
60
  /** Children the plan excluded, in plan order. Empty on a resume. */
61
61
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
62
+ /** Start waves and serialized-by-scope children; empty on a resume. */
63
+ parallel_groups: string[][];
64
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
62
65
  }
63
66
 
64
67
  export type BatchPreflightOutcome =
@@ -109,8 +112,8 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
109
112
  for (const file of readdirSync(batchesDir)) {
110
113
  if (!file.endsWith(".json")) continue;
111
114
  try {
112
- const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as BatchRunStateRecord;
113
- if (record?.contract !== "assurance_kernel/batch_run_state/v1") continue;
115
+ const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as AnyBatchRunStateRecord;
116
+ if (record?.contract !== "assurance_kernel/batch_run_state/v1" && record?.contract !== "assurance_kernel/batch_run_state/v2") continue;
114
117
  if (record.batch_state !== "running" && record.batch_state !== "needs_human") continue;
115
118
  if (record.children.some((child) => child.task_id === taskId && (child.state === "enrolled" || child.state === "settled")))
116
119
  return record.initiative_slug;
@@ -128,7 +131,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
128
131
  */
129
132
  export type BatchRecordLookup =
130
133
  | { corrupt: true; path: string }
131
- | { corrupt: false; record: BatchRunStateRecord }
134
+ | { corrupt: false; record: AnyBatchRunStateRecord }
132
135
  | null;
133
136
 
134
137
  export function findExistingActiveBatch(root: string, initiativeSlug: string): BatchRecordLookup {
@@ -144,8 +147,8 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
144
147
  // initiative as batchless could authorize a parallel run.
145
148
  return { corrupt: true, path: file };
146
149
  }
147
- const candidate = record as BatchRunStateRecord;
148
- if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
150
+ const candidate = record as AnyBatchRunStateRecord;
151
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
149
152
  if (candidate.initiative_slug !== initiativeSlug) continue;
150
153
  const validStates = new Set([
151
154
  "prepared",
@@ -166,7 +169,7 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
166
169
  }
167
170
  if (isTerminalBatchState(candidate.batch_state)) continue;
168
171
  try {
169
- const record = readBatchRunState(root, file.slice(0, -5));
172
+ const record = readAnyBatchRunState(root, file.slice(0, -5));
170
173
  if (record) return { corrupt: false, record };
171
174
  } catch {
172
175
  // Reuse the state owner's full validation before offering a renewal.
@@ -181,10 +184,10 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
181
184
  * not a batch to resume. Its branch and commit lineage may be reused by a new,
182
185
  * explicitly confirmed run, while its record and terminal report stay intact.
183
186
  */
184
- export function findSettledBatchRecord(root: string, initiativeSlug: string): BatchRunStateRecord | null {
187
+ export function findSettledBatchRecord(root: string, initiativeSlug: string): AnyBatchRunStateRecord | null {
185
188
  const batchesDir = join(root, ".imm", "state", "batches");
186
189
  if (!existsSync(batchesDir)) return null;
187
- let newest: BatchRunStateRecord | null = null;
190
+ let newest: AnyBatchRunStateRecord | null = null;
188
191
  for (const file of readdirSync(batchesDir).sort()) {
189
192
  if (!file.endsWith(".json")) continue;
190
193
  let record: unknown;
@@ -194,8 +197,8 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
194
197
  // The active lookup already fails closed on an unreadable record.
195
198
  continue;
196
199
  }
197
- const candidate = record as BatchRunStateRecord;
198
- if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
200
+ const candidate = record as AnyBatchRunStateRecord;
201
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
199
202
  if (candidate.initiative_slug !== initiativeSlug) continue;
200
203
  if (!isTerminalBatchState(candidate.batch_state)) continue;
201
204
  if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
@@ -294,7 +297,7 @@ export function expectedBatchHead(record: {
294
297
  */
295
298
  export function isOwnBatchClaim(
296
299
  root: string,
297
- existingBatch: BatchRunStateRecord,
300
+ existingBatch: AnyBatchRunStateRecord,
298
301
  taskId: string,
299
302
  batchBranch: string,
300
303
  ): boolean {
@@ -407,6 +410,8 @@ interface PlanSurface {
407
410
  recovery_children: BatchPlanChild[];
408
411
  risk_by_task: Record<string, string>;
409
412
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
413
+ parallel_groups: string[][];
414
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
410
415
  }
411
416
 
412
417
  type PlanSurfaceOutcome =
@@ -422,7 +427,7 @@ async function projectPlanSurface(input: {
422
427
  root: string;
423
428
  initiative_slug: string;
424
429
  is_resuming: boolean;
425
- existing_batch: BatchRunStateRecord | null;
430
+ existing_batch: AnyBatchRunStateRecord | null;
426
431
  now: string;
427
432
  readInitiative?: InitiativeObservationReader;
428
433
  }): Promise<PlanSurfaceOutcome> {
@@ -430,6 +435,8 @@ async function projectPlanSurface(input: {
430
435
  let recoveryChildren: BatchPlanChild[] = [];
431
436
  let planDigest: string;
432
437
  let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
438
+ let parallelGroups: string[][] = [];
439
+ let scopeConflicts: Array<{ task_id: string; overlaps_with: string[] }> = [];
433
440
  const riskByTask = new Map<string, string>();
434
441
  // Only an active run supplies children and budget; a terminal record supplies
435
442
  // branch provenance, never the plan or authorization of the next run.
@@ -469,7 +476,8 @@ async function projectPlanSurface(input: {
469
476
  // Keep the risk captured by the authoritative read above; a stale
470
477
  // reconstructed path must never fabricate a risk later.
471
478
  riskByTask.set(c.task_id, read.intent?.risk ?? "material");
472
- const isDone = c.state === "committed" || c.state === "settled";
479
+ // A lane child integrated or released is already on the batch branch.
480
+ const isDone = ["committed", "settled", "integrated", "released"].includes(c.state);
473
481
  return {
474
482
  task_id: c.task_id,
475
483
  slice_id: c.slice_id,
@@ -508,6 +516,8 @@ async function projectPlanSurface(input: {
508
516
  return { ok: false, key: "empty_enrollable_set", detail: "" };
509
517
 
510
518
  budget = plan.budget;
519
+ parallelGroups = plan.parallel_groups;
520
+ scopeConflicts = plan.scope_conflicts;
511
521
  const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
512
522
  recoveryChildren = plan.children
513
523
  .filter((c) => c.status === "enrollable")
@@ -544,6 +554,8 @@ async function projectPlanSurface(input: {
544
554
  [...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
545
555
  ),
546
556
  excluded,
557
+ parallel_groups: parallelGroups,
558
+ scope_conflicts: scopeConflicts,
547
559
  },
548
560
  };
549
561
  }
@@ -560,8 +572,8 @@ export async function projectBatchPreflight(
560
572
  if (found?.corrupt)
561
573
  return reject("batch_state_unreadable", found.path);
562
574
  // Settled records retain branch provenance; only active records are resumed.
563
- const activeRecord: BatchRunStateRecord | null = found ? found.record : null;
564
- const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
575
+ const activeRecord: AnyBatchRunStateRecord | null = found ? found.record : null;
576
+ const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
565
577
  const isResuming = activeRecord !== null;
566
578
  const batchBranch = `imm/${initiativeSlug}`;
567
579
 
@@ -645,7 +657,7 @@ export async function projectBatchPreflight(
645
657
  if (!planSurface.ok)
646
658
  return reject(planSurface.key, planSurface.detail);
647
659
  let reconfirmation: ReconfirmationSnapshot | undefined;
648
- if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
660
+ if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
649
661
  try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
650
662
  catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
651
663
  }
@@ -661,6 +673,8 @@ export async function projectBatchPreflight(
661
673
  recovery_children: planSurface.surface.recovery_children,
662
674
  risk_by_task: planSurface.surface.risk_by_task,
663
675
  excluded: planSurface.surface.excluded,
676
+ parallel_groups: planSurface.surface.parallel_groups,
677
+ scope_conflicts: planSurface.surface.scope_conflicts,
664
678
  };
665
679
  if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
666
680
  return { ok: true, projection };
@@ -683,8 +697,8 @@ export interface BatchDriftProjection {
683
697
  export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
684
698
  const { root, initiative_slug: initiativeSlug, readInitiative } = options;
685
699
  const found = findExistingActiveBatch(root, initiativeSlug);
686
- const activeRecord: BatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
687
- const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
700
+ const activeRecord: AnyBatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
701
+ const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
688
702
  const isResuming = activeRecord !== null;
689
703
  const batchBranch = `imm/${initiativeSlug}`;
690
704
  const activeClaimTaskId = readActiveClaimTaskId(root);
@@ -727,6 +741,9 @@ export interface BatchConfirmationFacts {
727
741
  reuse_blockers: string[];
728
742
  children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
729
743
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
744
+ /** Start waves and the children that serialize because their scopes overlap. */
745
+ parallel_groups: string[][];
746
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
730
747
  }
731
748
 
732
749
  /**
@@ -790,7 +807,7 @@ export async function authorizeBatch<HostRejection>(
790
807
  // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
791
808
  // reuses it instead of opening a second native gate. Anything that no longer
792
809
  // binds falls through to the gate below, which names the reason.
793
- const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
810
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && !isLaneBatchRecord(existingBatch) && existingBatch.plan_digest === planDigest &&
794
811
  expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
795
812
  // A fast-forward on the batch branch is the user's own work: the runner adopts
796
813
  // it, so it neither blocks reuse nor counts as moved provenance.
@@ -830,6 +847,8 @@ export async function authorizeBatch<HostRejection>(
830
847
  status: child.status,
831
848
  })),
832
849
  excluded: projection.excluded,
850
+ parallel_groups: projection.parallel_groups,
851
+ scope_conflicts: projection.scope_conflicts,
833
852
  };
834
853
 
835
854
  let requestId: string | null = null;
@@ -856,7 +875,7 @@ export async function authorizeBatch<HostRejection>(
856
875
  if (drift.plan_digest !== planDigest)
857
876
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
858
877
  if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
859
- if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
878
+ if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch as BatchRunStateRecord, baseHead)))
860
879
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
861
880
 
862
881
  // The drift projection's own claim read happened before its plan read; this
@@ -28,11 +28,14 @@ import {
28
28
  type BatchRunnerGitPort,
29
29
  } from "./batch_git";
30
30
  import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
31
+ import { laneRejectionReport, runLaneBatch, type LaneOffer } from "./batch_lanes";
31
32
  import {
32
33
  type BatchRunStateRecord,
33
34
  type BatchChildRun,
34
35
  type BatchRunReport,
36
+ isLaneBatchRecord,
35
37
  isTerminalBatchState,
38
+ readAnyBatchRunState,
36
39
  prepareBatchRunState,
37
40
  readBatchRunState,
38
41
  writeBatchRunState,
@@ -101,6 +104,10 @@ export interface StartBatchInput {
101
104
  now: string;
102
105
  kernel: BatchRunnerKernelPort;
103
106
  git?: BatchRunnerGitPort;
107
+ /** Present only in lane mode: how many children may run at once. */
108
+ max_parallel?: number;
109
+ /** Present only in lane mode: Lanes someone else created, offered to the batch. */
110
+ lane_offers?: LaneOffer[];
104
111
  }
105
112
 
106
113
  /** One git seam: the injected BatchRunnerGitPort, or the default adapter over
@@ -435,11 +442,17 @@ export async function startBatch(input: StartBatchInput): Promise<BatchRunReport
435
442
  function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport {
436
443
  const persisted = (() => {
437
444
  try {
438
- return readBatchRunState(input.root, input.batch_id);
445
+ return readAnyBatchRunState(input.root, input.batch_id);
439
446
  } catch {
440
447
  return null;
441
448
  }
442
449
  })();
450
+ if (persisted !== null && isLaneBatchRecord(persisted))
451
+ return laneRejectionReport(
452
+ persisted,
453
+ reason,
454
+ "settle the reported kernel store condition and retry in the current Host",
455
+ ) as unknown as BatchRunReport;
443
456
  return reportFor(
444
457
  {
445
458
  ...(persisted ?? prepareBatchRunState({ ...input, children: [], now: input.now })),
@@ -451,6 +464,12 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
451
464
  }
452
465
 
453
466
  async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport> {
467
+ // Lane mode is selected by `max_parallel` or by a recorded v2 batch; a batch
468
+ // given neither keeps the serial path below unchanged.
469
+ const recorded = readAnyBatchRunState(input.root, input.batch_id);
470
+ if (input.max_parallel !== undefined || (recorded !== null && isLaneBatchRecord(recorded)))
471
+ return (await runLaneBatch(input, recorded)) as unknown as BatchRunReport;
472
+
454
473
  // Validation failure before the first enrollment: zero writes, rejected.
455
474
  // reportFor only builds the report object; finalize would persist it and
456
475
  // the spec forbids any write on a pre-enrollment rejection.
@@ -0,0 +1,162 @@
1
+ // Pure scheduling for unattended batches. No I/O, no persisted state, no Host
2
+ // concept: given child states, dependency edges, scope hints and a parallelism
3
+ // limit it answers which children may start now, and which children of a plan
4
+ // serialize because their scopes cannot be proved disjoint.
5
+ import type { BatchPlanParallelGroups, BatchPlanScopeConflict } from "./types";
6
+
7
+ /**
8
+ * Child states the scheduler understands. The serial record (v1) uses
9
+ * `committed`; the lane record names the same milestone `integrated`/`released`
10
+ * and adds the in-flight lane states. Listing both keeps this function usable
11
+ * for either record without a persisted-state change.
12
+ */
13
+ export type ScheduleChildState =
14
+ | "pending"
15
+ | "lane_admitted"
16
+ | "enrolled"
17
+ | "settled"
18
+ | "lane_committed"
19
+ | "committed"
20
+ | "integrated"
21
+ | "released"
22
+ | "needs_human"
23
+ | "skipped_blocked";
24
+
25
+ const IN_FLIGHT_STATES: ReadonlySet<ScheduleChildState> = new Set([
26
+ "lane_admitted",
27
+ "enrolled",
28
+ "settled",
29
+ "lane_committed",
30
+ ]);
31
+
32
+ const DONE_STATES: ReadonlySet<ScheduleChildState> = new Set(["committed", "integrated", "released"]);
33
+
34
+ export interface ScheduleChild {
35
+ task_id: string;
36
+ state: ScheduleChildState;
37
+ blocked_by: string[];
38
+ scope_hint: string[];
39
+ }
40
+
41
+ const WILDCARD = /[*?[\]{}!]/;
42
+
43
+ /**
44
+ * Reduce one scope entry to the directory prefix it is certain to live under.
45
+ * A literal path keeps all its segments. A pattern keeps only the whole
46
+ * segments before its first wildcard: `src/foo*` could match `src/foobar/x`, so
47
+ * the partial segment `foo` is not a safe prefix. An empty result means the
48
+ * entry can match anywhere. Anything that is not a plain repository-relative
49
+ * path is also treated as matching anywhere, because disjointness is only ever
50
+ * claimed for entries that can be proved.
51
+ */
52
+ function scopePrefix(entry: string): string[] {
53
+ if (typeof entry !== "string") return [];
54
+ let value = entry.trim().toLowerCase();
55
+ while (value.startsWith("./")) value = value.slice(2);
56
+ if (!value || value.startsWith("/") || value.includes("\\")) return [];
57
+ const wildcard = value.search(WILDCARD);
58
+ const literal = wildcard === -1 ? value : value.slice(0, value.lastIndexOf("/", wildcard) + 1);
59
+ const segments = literal.split("/").filter((segment) => segment.length > 0);
60
+ if (segments.some((segment) => segment === "." || segment === "..")) return [];
61
+ return segments;
62
+ }
63
+
64
+ function prefixOverlaps(left: string[], right: string[]): boolean {
65
+ if (!left.length || !right.length) return true;
66
+ const shared = Math.min(left.length, right.length);
67
+ for (let index = 0; index < shared; index += 1) if (left[index] !== right[index]) return false;
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Two scope lists overlap unless every pair of entries is provably disjoint:
73
+ * neither entry's directory prefix equals or contains the other. Identical,
74
+ * nested, and wildcard scopes therefore overlap; an empty list proves nothing
75
+ * about the child and overlaps everything.
76
+ */
77
+ export function scopesOverlap(left: readonly string[], right: readonly string[]): boolean {
78
+ if (!left.length || !right.length) return true;
79
+ const rightPrefixes = right.map(scopePrefix);
80
+ return left.some((entry) => {
81
+ const prefix = scopePrefix(entry);
82
+ return rightPrefixes.some((other) => prefixOverlaps(prefix, other));
83
+ });
84
+ }
85
+
86
+ function positiveLimit(maxParallel: number): number {
87
+ if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
88
+ if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)
89
+ throw new Error("max_parallel must be a positive safe integer");
90
+ return maxParallel;
91
+ }
92
+
93
+ /**
94
+ * The children that may start now, in record order. A pending child starts when
95
+ * every dependency is done, its scope is disjoint from every in-flight child and
96
+ * from every child already chosen in this call, and the in-flight count stays
97
+ * below `maxParallel`. With `maxParallel` 1 and nothing in flight this is
98
+ * exactly the serial runner's choice: the first pending child whose
99
+ * dependencies are done.
100
+ */
101
+ export function startableChildren(children: readonly ScheduleChild[], maxParallel: number): string[] {
102
+ const limit = positiveLimit(maxParallel);
103
+ const done = new Set(children.filter((child) => DONE_STATES.has(child.state)).map((child) => child.task_id));
104
+ const occupied = children.filter((child) => IN_FLIGHT_STATES.has(child.state));
105
+ const startable: string[] = [];
106
+ for (const child of children) {
107
+ if (occupied.length >= limit) break;
108
+ if (child.state !== "pending") continue;
109
+ if (!child.blocked_by.every((id) => done.has(id))) continue;
110
+ if (occupied.some((other) => scopesOverlap(child.scope_hint, other.scope_hint))) continue;
111
+ startable.push(child.task_id);
112
+ occupied.push(child);
113
+ }
114
+ return startable;
115
+ }
116
+
117
+ export interface PlanScheduleChild {
118
+ task_id: string;
119
+ blocked_by: string[];
120
+ scope_hint: string[];
121
+ }
122
+
123
+ /**
124
+ * Project a plan's enrollable children into start waves. Each wave is the
125
+ * largest set that is ready (every dependency in an earlier wave) and pairwise
126
+ * scope-disjoint, chosen in plan order. A child that was ready but left out of
127
+ * a wave because its scope overlaps a member of that wave is reported once with
128
+ * every child it overlapped, so a confirmation can say why it serializes.
129
+ */
130
+ export function projectParallelGroups(children: readonly PlanScheduleChild[]): {
131
+ parallel_groups: BatchPlanParallelGroups;
132
+ scope_conflicts: BatchPlanScopeConflict[];
133
+ } {
134
+ const remaining = [...children];
135
+ const placed = new Set<string>();
136
+ const groups: BatchPlanParallelGroups = [];
137
+ const conflicts = new Map<string, Set<string>>();
138
+ while (remaining.length) {
139
+ const ready = remaining.filter((child) => child.blocked_by.every((id) => placed.has(id)));
140
+ if (!ready.length) throw new Error("Initiative Task dependencies must form an acyclic graph");
141
+ const wave: PlanScheduleChild[] = [];
142
+ for (const child of ready) {
143
+ const overlapped = wave.filter((member) => scopesOverlap(child.scope_hint, member.scope_hint));
144
+ if (overlapped.length) {
145
+ const known = conflicts.get(child.task_id) ?? new Set<string>();
146
+ for (const member of overlapped) known.add(member.task_id);
147
+ conflicts.set(child.task_id, known);
148
+ continue;
149
+ }
150
+ wave.push(child);
151
+ }
152
+ groups.push(wave.map((child) => child.task_id));
153
+ for (const child of wave) {
154
+ placed.add(child.task_id);
155
+ remaining.splice(remaining.indexOf(child), 1);
156
+ }
157
+ }
158
+ const scopeConflicts = children
159
+ .filter((child) => conflicts.has(child.task_id))
160
+ .map((child) => ({ task_id: child.task_id, overlaps_with: [...conflicts.get(child.task_id)!].sort() }));
161
+ return { parallel_groups: groups, scope_conflicts: scopeConflicts };
162
+ }