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.
- package/README.md +223 -12
- package/README.zh-CN.md +228 -11
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +24 -3
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1339 -66
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
- package/plugins/immune-brain/dist/imm-run.md +198 -3
- package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +6 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +16 -4
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +24 -2
- package/plugins/immune-brain/runtime/claude/role_agents.ts +4 -3
- package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +55 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +15 -2
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +2 -2
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +909 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +41 -22
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +20 -1
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +356 -8
- package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
- 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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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,
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
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):
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
564
|
-
const existingBatch:
|
|
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:
|
|
687
|
-
const existingBatch:
|
|
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
|
|
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
|
|
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
|
+
}
|