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.
- 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 +18 -5
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +189 -3
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1885 -149
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
- package/plugins/immune-brain/dist/imm-planner.md +4 -1
- package/plugins/immune-brain/dist/imm-run.md +198 -3
- package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
- package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
- package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +105 -58
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +89 -16
- package/plugins/immune-brain/runtime/claude/role_agents.ts +4 -3
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
- package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
- package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
- 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/code-review.md +10 -0
- package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +16 -3
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
- 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 +946 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +48 -33
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +22 -1
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +398 -12
- 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 { BATCH_RUN_STATES, 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;
|
|
@@ -123,12 +126,12 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
|
|
|
123
126
|
|
|
124
127
|
/**
|
|
125
128
|
* The batch record for this initiative, or a corruption marker. A terminal
|
|
126
|
-
* record (completed, budget_stopped, failed, rejected) is not active: returning
|
|
129
|
+
* record (completed, budget_stopped, failed, rejected, superseded) is not active: returning
|
|
127
130
|
* it would let a settled batch block or be resumed by a later run.
|
|
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,18 +147,12 @@ 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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"needs_human",
|
|
154
|
-
"completed",
|
|
155
|
-
"budget_stopped",
|
|
156
|
-
"failed",
|
|
157
|
-
"rejected",
|
|
158
|
-
]);
|
|
153
|
+
// Every state the state owner accepts, so a retired record reads as settled
|
|
154
|
+
// instead of failing this lookup closed as an unknown state.
|
|
155
|
+
const validStates = new Set<string>(BATCH_RUN_STATES);
|
|
159
156
|
if (
|
|
160
157
|
typeof candidate.batch_id !== "string" ||
|
|
161
158
|
typeof candidate.base_head !== "string" ||
|
|
@@ -166,7 +163,7 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
166
163
|
}
|
|
167
164
|
if (isTerminalBatchState(candidate.batch_state)) continue;
|
|
168
165
|
try {
|
|
169
|
-
const record =
|
|
166
|
+
const record = readAnyBatchRunState(root, file.slice(0, -5));
|
|
170
167
|
if (record) return { corrupt: false, record };
|
|
171
168
|
} catch {
|
|
172
169
|
// Reuse the state owner's full validation before offering a renewal.
|
|
@@ -181,10 +178,10 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
181
178
|
* not a batch to resume. Its branch and commit lineage may be reused by a new,
|
|
182
179
|
* explicitly confirmed run, while its record and terminal report stay intact.
|
|
183
180
|
*/
|
|
184
|
-
export function findSettledBatchRecord(root: string, initiativeSlug: string):
|
|
181
|
+
export function findSettledBatchRecord(root: string, initiativeSlug: string): AnyBatchRunStateRecord | null {
|
|
185
182
|
const batchesDir = join(root, ".imm", "state", "batches");
|
|
186
183
|
if (!existsSync(batchesDir)) return null;
|
|
187
|
-
let newest:
|
|
184
|
+
let newest: AnyBatchRunStateRecord | null = null;
|
|
188
185
|
for (const file of readdirSync(batchesDir).sort()) {
|
|
189
186
|
if (!file.endsWith(".json")) continue;
|
|
190
187
|
let record: unknown;
|
|
@@ -194,8 +191,8 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
|
|
|
194
191
|
// The active lookup already fails closed on an unreadable record.
|
|
195
192
|
continue;
|
|
196
193
|
}
|
|
197
|
-
const candidate = record as
|
|
198
|
-
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
194
|
+
const candidate = record as AnyBatchRunStateRecord;
|
|
195
|
+
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
|
|
199
196
|
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
200
197
|
if (!isTerminalBatchState(candidate.batch_state)) continue;
|
|
201
198
|
if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
|
|
@@ -294,7 +291,7 @@ export function expectedBatchHead(record: {
|
|
|
294
291
|
*/
|
|
295
292
|
export function isOwnBatchClaim(
|
|
296
293
|
root: string,
|
|
297
|
-
existingBatch:
|
|
294
|
+
existingBatch: AnyBatchRunStateRecord,
|
|
298
295
|
taskId: string,
|
|
299
296
|
batchBranch: string,
|
|
300
297
|
): boolean {
|
|
@@ -407,6 +404,8 @@ interface PlanSurface {
|
|
|
407
404
|
recovery_children: BatchPlanChild[];
|
|
408
405
|
risk_by_task: Record<string, string>;
|
|
409
406
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
407
|
+
parallel_groups: string[][];
|
|
408
|
+
scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
410
409
|
}
|
|
411
410
|
|
|
412
411
|
type PlanSurfaceOutcome =
|
|
@@ -422,7 +421,7 @@ async function projectPlanSurface(input: {
|
|
|
422
421
|
root: string;
|
|
423
422
|
initiative_slug: string;
|
|
424
423
|
is_resuming: boolean;
|
|
425
|
-
existing_batch:
|
|
424
|
+
existing_batch: AnyBatchRunStateRecord | null;
|
|
426
425
|
now: string;
|
|
427
426
|
readInitiative?: InitiativeObservationReader;
|
|
428
427
|
}): Promise<PlanSurfaceOutcome> {
|
|
@@ -430,6 +429,8 @@ async function projectPlanSurface(input: {
|
|
|
430
429
|
let recoveryChildren: BatchPlanChild[] = [];
|
|
431
430
|
let planDigest: string;
|
|
432
431
|
let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
|
|
432
|
+
let parallelGroups: string[][] = [];
|
|
433
|
+
let scopeConflicts: Array<{ task_id: string; overlaps_with: string[] }> = [];
|
|
433
434
|
const riskByTask = new Map<string, string>();
|
|
434
435
|
// Only an active run supplies children and budget; a terminal record supplies
|
|
435
436
|
// branch provenance, never the plan or authorization of the next run.
|
|
@@ -469,7 +470,8 @@ async function projectPlanSurface(input: {
|
|
|
469
470
|
// Keep the risk captured by the authoritative read above; a stale
|
|
470
471
|
// reconstructed path must never fabricate a risk later.
|
|
471
472
|
riskByTask.set(c.task_id, read.intent?.risk ?? "material");
|
|
472
|
-
|
|
473
|
+
// A lane child integrated or released is already on the batch branch.
|
|
474
|
+
const isDone = ["committed", "settled", "integrated", "released"].includes(c.state);
|
|
473
475
|
return {
|
|
474
476
|
task_id: c.task_id,
|
|
475
477
|
slice_id: c.slice_id,
|
|
@@ -508,6 +510,8 @@ async function projectPlanSurface(input: {
|
|
|
508
510
|
return { ok: false, key: "empty_enrollable_set", detail: "" };
|
|
509
511
|
|
|
510
512
|
budget = plan.budget;
|
|
513
|
+
parallelGroups = plan.parallel_groups;
|
|
514
|
+
scopeConflicts = plan.scope_conflicts;
|
|
511
515
|
const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
|
|
512
516
|
recoveryChildren = plan.children
|
|
513
517
|
.filter((c) => c.status === "enrollable")
|
|
@@ -544,6 +548,8 @@ async function projectPlanSurface(input: {
|
|
|
544
548
|
[...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
545
549
|
),
|
|
546
550
|
excluded,
|
|
551
|
+
parallel_groups: parallelGroups,
|
|
552
|
+
scope_conflicts: scopeConflicts,
|
|
547
553
|
},
|
|
548
554
|
};
|
|
549
555
|
}
|
|
@@ -560,8 +566,8 @@ export async function projectBatchPreflight(
|
|
|
560
566
|
if (found?.corrupt)
|
|
561
567
|
return reject("batch_state_unreadable", found.path);
|
|
562
568
|
// Settled records retain branch provenance; only active records are resumed.
|
|
563
|
-
const activeRecord:
|
|
564
|
-
const existingBatch:
|
|
569
|
+
const activeRecord: AnyBatchRunStateRecord | null = found ? found.record : null;
|
|
570
|
+
const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
|
|
565
571
|
const isResuming = activeRecord !== null;
|
|
566
572
|
const batchBranch = `imm/${initiativeSlug}`;
|
|
567
573
|
|
|
@@ -645,9 +651,11 @@ export async function projectBatchPreflight(
|
|
|
645
651
|
if (!planSurface.ok)
|
|
646
652
|
return reject(planSurface.key, planSurface.detail);
|
|
647
653
|
let reconfirmation: ReconfirmationSnapshot | undefined;
|
|
648
|
-
if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
654
|
+
if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
649
655
|
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
650
|
-
catch { return reject("plan_projection_failed",
|
|
656
|
+
catch { return reject("plan_projection_failed", activeRecord.commits.length
|
|
657
|
+
? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child that is still mid-flight must settle first. This rejection neither terminates the old batch nor authorizes the revised plan.`
|
|
658
|
+
: "batch plan reconfirmation is not eligible"); }
|
|
651
659
|
}
|
|
652
660
|
|
|
653
661
|
const projection: BatchPreflightProjection = {
|
|
@@ -661,6 +669,8 @@ export async function projectBatchPreflight(
|
|
|
661
669
|
recovery_children: planSurface.surface.recovery_children,
|
|
662
670
|
risk_by_task: planSurface.surface.risk_by_task,
|
|
663
671
|
excluded: planSurface.surface.excluded,
|
|
672
|
+
parallel_groups: planSurface.surface.parallel_groups,
|
|
673
|
+
scope_conflicts: planSurface.surface.scope_conflicts,
|
|
664
674
|
};
|
|
665
675
|
if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
|
|
666
676
|
return { ok: true, projection };
|
|
@@ -683,8 +693,8 @@ export interface BatchDriftProjection {
|
|
|
683
693
|
export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
|
|
684
694
|
const { root, initiative_slug: initiativeSlug, readInitiative } = options;
|
|
685
695
|
const found = findExistingActiveBatch(root, initiativeSlug);
|
|
686
|
-
const activeRecord:
|
|
687
|
-
const existingBatch:
|
|
696
|
+
const activeRecord: AnyBatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
|
|
697
|
+
const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
|
|
688
698
|
const isResuming = activeRecord !== null;
|
|
689
699
|
const batchBranch = `imm/${initiativeSlug}`;
|
|
690
700
|
const activeClaimTaskId = readActiveClaimTaskId(root);
|
|
@@ -727,6 +737,9 @@ export interface BatchConfirmationFacts {
|
|
|
727
737
|
reuse_blockers: string[];
|
|
728
738
|
children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
|
|
729
739
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
740
|
+
/** Start waves and the children that serialize because their scopes overlap. */
|
|
741
|
+
parallel_groups: string[][];
|
|
742
|
+
scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
730
743
|
}
|
|
731
744
|
|
|
732
745
|
/**
|
|
@@ -790,7 +803,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
790
803
|
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
791
804
|
// reuses it instead of opening a second native gate. Anything that no longer
|
|
792
805
|
// binds falls through to the gate below, which names the reason.
|
|
793
|
-
const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
|
|
806
|
+
const ownsUnpersistedHead = isResuming && existingBatch !== null && !isLaneBatchRecord(existingBatch) && existingBatch.plan_digest === planDigest &&
|
|
794
807
|
expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
|
|
795
808
|
// A fast-forward on the batch branch is the user's own work: the runner adopts
|
|
796
809
|
// it, so it neither blocks reuse nor counts as moved provenance.
|
|
@@ -830,6 +843,8 @@ export async function authorizeBatch<HostRejection>(
|
|
|
830
843
|
status: child.status,
|
|
831
844
|
})),
|
|
832
845
|
excluded: projection.excluded,
|
|
846
|
+
parallel_groups: projection.parallel_groups,
|
|
847
|
+
scope_conflicts: projection.scope_conflicts,
|
|
833
848
|
};
|
|
834
849
|
|
|
835
850
|
let requestId: string | null = null;
|
|
@@ -856,7 +871,7 @@ export async function authorizeBatch<HostRejection>(
|
|
|
856
871
|
if (drift.plan_digest !== planDigest)
|
|
857
872
|
return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
|
|
858
873
|
if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
859
|
-
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch
|
|
874
|
+
if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch as BatchRunStateRecord, baseHead)))
|
|
860
875
|
return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
|
|
861
876
|
|
|
862
877
|
// The drift projection's own claim read happened before its plan read; this
|
|
@@ -34,7 +34,9 @@ export type BatchReasonKey =
|
|
|
34
34
|
| "repository_became_unreadable"
|
|
35
35
|
| "head_moved"
|
|
36
36
|
| "cancelled_before_execution"
|
|
37
|
-
| "batch_run_rejected"
|
|
37
|
+
| "batch_run_rejected"
|
|
38
|
+
| "stale_batch_absent"
|
|
39
|
+
| "stale_batch_in_flight";
|
|
38
40
|
|
|
39
41
|
export interface BatchReasonSpec {
|
|
40
42
|
readonly state: "rejected" | "cancelled" | "blocked";
|
|
@@ -170,6 +172,16 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
170
172
|
reason: (detail: string) => detail || "batch run rejected",
|
|
171
173
|
recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
|
|
172
174
|
},
|
|
175
|
+
stale_batch_absent: {
|
|
176
|
+
state: "rejected",
|
|
177
|
+
reason: (detail: string) => `no retirable batch record to dispose: ${detail}`,
|
|
178
|
+
recovery_action: "start a batch for this Initiative, or inspect the batch records under .imm/state/batches in the current Host",
|
|
179
|
+
},
|
|
180
|
+
stale_batch_in_flight: {
|
|
181
|
+
state: "blocked",
|
|
182
|
+
reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
|
|
183
|
+
recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
|
|
184
|
+
},
|
|
173
185
|
});
|
|
174
186
|
|
|
175
187
|
export interface BatchReason {
|
|
@@ -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
|
|
@@ -172,6 +179,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
|
172
179
|
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
173
180
|
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
174
181
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
182
|
+
superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
|
|
175
183
|
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
176
184
|
running: "The batch is still running; no terminal report is due yet.",
|
|
177
185
|
prepared: "The batch is prepared but not started.",
|
|
@@ -435,11 +443,18 @@ export async function startBatch(input: StartBatchInput): Promise<BatchRunReport
|
|
|
435
443
|
function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport {
|
|
436
444
|
const persisted = (() => {
|
|
437
445
|
try {
|
|
438
|
-
return
|
|
446
|
+
return readAnyBatchRunState(input.root, input.batch_id);
|
|
439
447
|
} catch {
|
|
440
448
|
return null;
|
|
441
449
|
}
|
|
442
450
|
})();
|
|
451
|
+
if (persisted !== null && isLaneBatchRecord(persisted))
|
|
452
|
+
return laneRejectionReport(
|
|
453
|
+
input,
|
|
454
|
+
persisted,
|
|
455
|
+
reason,
|
|
456
|
+
"settle the reported kernel store condition and retry in the current Host",
|
|
457
|
+
) as unknown as BatchRunReport;
|
|
443
458
|
return reportFor(
|
|
444
459
|
{
|
|
445
460
|
...(persisted ?? prepareBatchRunState({ ...input, children: [], now: input.now })),
|
|
@@ -451,6 +466,12 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
|
|
|
451
466
|
}
|
|
452
467
|
|
|
453
468
|
async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport> {
|
|
469
|
+
// Lane mode is selected by `max_parallel` or by a recorded v2 batch; a batch
|
|
470
|
+
// given neither keeps the serial path below unchanged.
|
|
471
|
+
const recorded = readAnyBatchRunState(input.root, input.batch_id);
|
|
472
|
+
if (input.max_parallel !== undefined || (recorded !== null && isLaneBatchRecord(recorded)))
|
|
473
|
+
return (await runLaneBatch(input, recorded)) as unknown as BatchRunReport;
|
|
474
|
+
|
|
454
475
|
// Validation failure before the first enrollment: zero writes, rejected.
|
|
455
476
|
// reportFor only builds the report object; finalize would persist it and
|
|
456
477
|
// 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
|
+
}
|