immune-brain 5.1.0 → 5.1.2
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/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +37 -5
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
- package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
- package/plugins/immune-brain/dist/imm-planner.md +41 -5
- package/plugins/immune-brain/dist/imm-run.md +123 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
- package/plugins/immune-brain/hooks/hooks.json +22 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- 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 +15 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
- package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
- 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/runtime_contracts.json +7 -0
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
- 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/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Coordinator-side recovery for a Lane Executor's writes that landed in the
|
|
2
|
+
// coordinator checkout instead of its Lane. On a lane-batch re-entry with a
|
|
3
|
+
// dirty coordinator working tree, every dirty path must be provably a Lane's:
|
|
4
|
+
// that Lane changed the path (its bytes differ from the Lane's base) and the
|
|
5
|
+
// coordinator's bytes equal the Lane's, in its worktree or a commit on its
|
|
6
|
+
// branch. Planning is read-only, so the batch preflight can run it before any
|
|
7
|
+
// gate; only the runner, after authorization, backs the paths up, restores
|
|
8
|
+
// them to HEAD and records the restore. Anything else (a staged change, a
|
|
9
|
+
// deletion, bytes no Lane wrote) leaves every file untouched, so a user's own
|
|
10
|
+
// uncommitted work is never restored.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import type { BatchLaneRunStateRecord } from "./batch_state";
|
|
15
|
+
|
|
16
|
+
export interface LaneLeakRestore {
|
|
17
|
+
at: string;
|
|
18
|
+
/** Repository-relative directory holding the backed-up bytes and `restore.json`. */
|
|
19
|
+
backup: string;
|
|
20
|
+
paths: Array<{ path: string; kind: "modified" | "untracked"; lane_task_id: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(root: string, args: string[]): { status: number | null; stdout: string } {
|
|
24
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
28
|
+
});
|
|
29
|
+
return { status: result.status, stdout: result.stdout ?? "" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function blobOfFile(root: string, file: string): string | null {
|
|
33
|
+
const hashed = git(root, ["hash-object", "--no-filters", "--", file]);
|
|
34
|
+
return hashed.status === 0 ? hashed.stdout.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function blobAt(root: string, commit: string, path: string): string | null {
|
|
38
|
+
const blob = git(root, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]);
|
|
39
|
+
return blob.status === 0 ? blob.stdout.trim() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The Lane of this batch that wrote `blob` at `path`, or null. A Lane only
|
|
44
|
+
* counts when its bytes for the path differ from the Lane's own base: a Lane
|
|
45
|
+
* that never touched the path still holds its base bytes, and matching those
|
|
46
|
+
* would mistake a user's revert for a Lane write.
|
|
47
|
+
*/
|
|
48
|
+
function laneOrigin(root: string, record: BatchLaneRunStateRecord, path: string, blob: string): string | null {
|
|
49
|
+
for (const child of record.children) {
|
|
50
|
+
const lane = child.lane;
|
|
51
|
+
if (!lane) continue;
|
|
52
|
+
if (blobAt(root, lane.base_head, path) === blob) continue;
|
|
53
|
+
if (existsSync(join(lane.path, path)) && blobOfFile(lane.path, join(lane.path, path)) === blob) return child.task_id;
|
|
54
|
+
const commits = git(root, ["rev-list", `${lane.base_head}..${lane.branch}`]);
|
|
55
|
+
if (commits.status !== 0) continue;
|
|
56
|
+
for (const commit of commits.stdout.split("\n").filter(Boolean))
|
|
57
|
+
if (blobAt(root, commit, path) === blob) return child.task_id;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type LeakRestorePlan =
|
|
63
|
+
| { kind: "clean" }
|
|
64
|
+
| { kind: "provable"; paths: LaneLeakRestore["paths"] }
|
|
65
|
+
| { kind: "unprovable"; path: string; detail: string };
|
|
66
|
+
|
|
67
|
+
/** Read-only: attribute every dirty path to a Lane of this batch, or name the first that cannot be. */
|
|
68
|
+
export function planLaneLeakRestore(
|
|
69
|
+
root: string,
|
|
70
|
+
record: BatchLaneRunStateRecord,
|
|
71
|
+
entries: Array<{ code: string; path: string }>,
|
|
72
|
+
): LeakRestorePlan {
|
|
73
|
+
if (entries.length === 0) return { kind: "clean" };
|
|
74
|
+
const planned: LaneLeakRestore["paths"] = [];
|
|
75
|
+
for (const { code, path } of entries) {
|
|
76
|
+
if (path.startsWith(".imm/")) return { kind: "unprovable", path, detail: "Immune-Brain state is never restored" };
|
|
77
|
+
const kind = code === "??" ? "untracked" : code === " M" ? "modified" : null;
|
|
78
|
+
if (!kind) return { kind: "unprovable", path, detail: `status ${code.trim() || code} is not a Lane write` };
|
|
79
|
+
const blob = blobOfFile(root, join(root, path));
|
|
80
|
+
if (!blob) return { kind: "unprovable", path, detail: "its bytes cannot be read" };
|
|
81
|
+
const origin = laneOrigin(root, record, path, blob);
|
|
82
|
+
if (!origin) return { kind: "unprovable", path, detail: "its bytes match no Lane of this batch" };
|
|
83
|
+
planned.push({ path, kind, lane_task_id: origin });
|
|
84
|
+
}
|
|
85
|
+
return { kind: "provable", paths: planned };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Back up and restore an already planned, fully provable set. Called only by
|
|
90
|
+
* the runner after the batch is authorized; it re-plans against the current
|
|
91
|
+
* status first, so a change since the preflight is never restored blind.
|
|
92
|
+
*/
|
|
93
|
+
export function restoreProvableLaneLeaks(
|
|
94
|
+
root: string,
|
|
95
|
+
record: BatchLaneRunStateRecord,
|
|
96
|
+
entries: Array<{ code: string; path: string }>,
|
|
97
|
+
now: string,
|
|
98
|
+
): { kind: "clean" } | { kind: "restored"; restore: LaneLeakRestore } | { kind: "unprovable"; path: string; detail: string } {
|
|
99
|
+
const plan = planLaneLeakRestore(root, record, entries);
|
|
100
|
+
if (plan.kind !== "provable") return plan;
|
|
101
|
+
const planned = plan.paths;
|
|
102
|
+
const stamp = now.replace(/[^0-9TZ]/g, "");
|
|
103
|
+
const backup = join(".imm", "state", "batches", "restores", record.batch_id, stamp);
|
|
104
|
+
const backupRoot = join(root, backup);
|
|
105
|
+
mkdirSync(join(backupRoot, "files"), { recursive: true });
|
|
106
|
+
const tracked = planned.filter((p) => p.kind === "modified").map((p) => p.path);
|
|
107
|
+
if (tracked.length > 0) {
|
|
108
|
+
const diff = git(root, ["diff", "--binary", "HEAD", "--", ...tracked]);
|
|
109
|
+
writeFileSync(join(backupRoot, "restore.patch"), diff.stdout);
|
|
110
|
+
}
|
|
111
|
+
// Back every modified path up before changing any of them; an untracked
|
|
112
|
+
// path is moved into the backup below, so it keeps exactly one copy.
|
|
113
|
+
for (const { path, kind } of planned) {
|
|
114
|
+
mkdirSync(dirname(join(backupRoot, "files", path)), { recursive: true });
|
|
115
|
+
if (kind === "modified") copyFileSync(join(root, path), join(backupRoot, "files", path));
|
|
116
|
+
}
|
|
117
|
+
const restore: LaneLeakRestore = { at: now, backup, paths: planned };
|
|
118
|
+
writeFileSync(join(backupRoot, "restore.json"), `${JSON.stringify({
|
|
119
|
+
...restore,
|
|
120
|
+
undo: "copy each file under files/ back to the same repository path",
|
|
121
|
+
}, null, 2)}\n`);
|
|
122
|
+
for (const { path, kind } of planned) {
|
|
123
|
+
if (kind === "untracked") {
|
|
124
|
+
renameSync(join(root, path), join(backupRoot, "files", path));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const restored = git(root, ["restore", "--source=HEAD", "--worktree", "--", path]);
|
|
128
|
+
if (restored.status !== 0) throw new Error(`failed to restore ${path} from HEAD; its bytes are backed up under ${backup}`);
|
|
129
|
+
}
|
|
130
|
+
return { kind: "restored", restore };
|
|
131
|
+
}
|
|
@@ -26,7 +26,8 @@ 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 { isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
|
|
29
|
+
import { BATCH_RUN_STATES, isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
|
|
30
|
+
import { planLaneLeakRestore } from "./batch_leak_restore";
|
|
30
31
|
import type {
|
|
31
32
|
BatchPlanBudget,
|
|
32
33
|
BatchPlanChild,
|
|
@@ -126,7 +127,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
|
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
129
|
* The batch record for this initiative, or a corruption marker. A terminal
|
|
129
|
-
* record (completed, budget_stopped, failed, rejected) is not active: returning
|
|
130
|
+
* record (completed, budget_stopped, failed, rejected, superseded) is not active: returning
|
|
130
131
|
* it would let a settled batch block or be resumed by a later run.
|
|
131
132
|
*/
|
|
132
133
|
export type BatchRecordLookup =
|
|
@@ -150,15 +151,9 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
150
151
|
const candidate = record as AnyBatchRunStateRecord;
|
|
151
152
|
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
|
|
152
153
|
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"needs_human",
|
|
157
|
-
"completed",
|
|
158
|
-
"budget_stopped",
|
|
159
|
-
"failed",
|
|
160
|
-
"rejected",
|
|
161
|
-
]);
|
|
154
|
+
// Every state the state owner accepts, so a retired record reads as settled
|
|
155
|
+
// instead of failing this lookup closed as an unknown state.
|
|
156
|
+
const validStates = new Set<string>(BATCH_RUN_STATES);
|
|
162
157
|
if (
|
|
163
158
|
typeof candidate.batch_id !== "string" ||
|
|
164
159
|
typeof candidate.base_head !== "string" ||
|
|
@@ -380,7 +375,7 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
|
|
|
380
375
|
return scope;
|
|
381
376
|
}
|
|
382
377
|
|
|
383
|
-
function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
|
|
378
|
+
export function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
|
|
384
379
|
// review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
|
|
385
380
|
// format. Trimming the whole output first shifted the fixed status columns of
|
|
386
381
|
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
@@ -600,9 +595,19 @@ export async function projectBatchPreflight(
|
|
|
600
595
|
if (branchExists.status === 0 && !existingBatch)
|
|
601
596
|
return reject("branch_already_exists", batchBranch);
|
|
602
597
|
|
|
603
|
-
|
|
598
|
+
let statusEntries = porcelainEntries(root);
|
|
604
599
|
if (statusEntries === null)
|
|
605
600
|
return reject("git_status_unreadable");
|
|
601
|
+
// A lane batch keeps its delivery in Lanes, so a dirty coordinator is a leak.
|
|
602
|
+
// This read-only check only attributes it: when every dirty path is provably
|
|
603
|
+
// a Lane's write, the runner restores them after the gate; any change of
|
|
604
|
+
// unknown origin is refused below and nothing is touched.
|
|
605
|
+
let unprovableLeak = "";
|
|
606
|
+
if (statusEntries.length > 0 && activeRecord && isLaneBatchRecord(activeRecord)) {
|
|
607
|
+
const plan = planLaneLeakRestore(root, activeRecord, statusEntries);
|
|
608
|
+
if (plan.kind === "provable") statusEntries = [];
|
|
609
|
+
else if (plan.kind === "unprovable") unprovableLeak = `${plan.path}: ${plan.detail}`;
|
|
610
|
+
}
|
|
606
611
|
if (statusEntries.length > 0) {
|
|
607
612
|
if (!isResuming)
|
|
608
613
|
return reject("working_tree_dirty");
|
|
@@ -622,7 +627,7 @@ export async function projectBatchPreflight(
|
|
|
622
627
|
}
|
|
623
628
|
const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
|
|
624
629
|
if (dirtyBytes)
|
|
625
|
-
return reject("working_tree_unstaged");
|
|
630
|
+
return reject("working_tree_unstaged", unprovableLeak);
|
|
626
631
|
let outsideScope = false;
|
|
627
632
|
for (const { path } of statusEntries) {
|
|
628
633
|
if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
|
|
@@ -659,7 +664,9 @@ export async function projectBatchPreflight(
|
|
|
659
664
|
let reconfirmation: ReconfirmationSnapshot | undefined;
|
|
660
665
|
if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
661
666
|
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
662
|
-
catch { return reject("plan_projection_failed",
|
|
667
|
+
catch { return reject("plan_projection_failed", activeRecord.commits.length
|
|
668
|
+
? `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.`
|
|
669
|
+
: "batch plan reconfirmation is not eligible"); }
|
|
663
670
|
}
|
|
664
671
|
|
|
665
672
|
const projection: BatchPreflightProjection = {
|
|
@@ -752,7 +759,12 @@ export interface BatchConfirmationFacts {
|
|
|
752
759
|
* own failure-envelope shape.
|
|
753
760
|
*/
|
|
754
761
|
export type BatchGateDecision<HostRejection> =
|
|
755
|
-
| {
|
|
762
|
+
| {
|
|
763
|
+
kind: "confirmed";
|
|
764
|
+
request_id: string;
|
|
765
|
+
/** ADR 0018: the user also delegated in-envelope Lane Intent revisions (lane mode only). */
|
|
766
|
+
delegate_revisions?: boolean;
|
|
767
|
+
}
|
|
756
768
|
| { kind: "host_rejection"; value: HostRejection };
|
|
757
769
|
|
|
758
770
|
export type BatchAuthorizationOutcome<HostRejection> =
|
|
@@ -762,6 +774,8 @@ export type BatchAuthorizationOutcome<HostRejection> =
|
|
|
762
774
|
reuse_authorization: boolean;
|
|
763
775
|
reuse_blockers: string[];
|
|
764
776
|
binding: BatchAuthorizationBinding;
|
|
777
|
+
/** The gate's delegation answer; absent when the authorization was reused and no gate opened. */
|
|
778
|
+
revision_delegation?: boolean;
|
|
765
779
|
}
|
|
766
780
|
| { outcome: "rejected"; rejection: BatchPreflightRejection }
|
|
767
781
|
| { outcome: "host_rejection"; value: HostRejection };
|
|
@@ -852,10 +866,12 @@ export async function authorizeBatch<HostRejection>(
|
|
|
852
866
|
};
|
|
853
867
|
|
|
854
868
|
let requestId: string | null = null;
|
|
869
|
+
let revisionDelegation: boolean | undefined;
|
|
855
870
|
if (!reuseAuthorization) {
|
|
856
871
|
const decision = await options.gate(facts);
|
|
857
872
|
if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
|
|
858
873
|
requestId = decision.request_id;
|
|
874
|
+
revisionDelegation = decision.delegate_revisions === true;
|
|
859
875
|
}
|
|
860
876
|
|
|
861
877
|
// Post-gate cascade: the drift projection reports the live claim it read
|
|
@@ -920,5 +936,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
920
936
|
reuse_authorization: reuseAuthorization,
|
|
921
937
|
reuse_blockers: reuseBlockers,
|
|
922
938
|
binding,
|
|
939
|
+
...(revisionDelegation !== undefined ? { revision_delegation: revisionDelegation } : {}),
|
|
923
940
|
};
|
|
924
941
|
}
|
|
@@ -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";
|
|
@@ -91,8 +93,9 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
91
93
|
},
|
|
92
94
|
working_tree_unstaged: {
|
|
93
95
|
state: "rejected",
|
|
94
|
-
reason:
|
|
95
|
-
|
|
96
|
+
reason: (detail: string) =>
|
|
97
|
+
`branch preflight failed: working tree has unstaged or untracked changes${detail ? ` (not restored: ${detail})` : ""}`,
|
|
98
|
+
recovery_action: "establish where each unstaged or untracked change came from before staging anything; move changes that are not the active child's verified work out of the working tree, then retry in the current Host",
|
|
96
99
|
},
|
|
97
100
|
working_tree_out_of_scope: {
|
|
98
101
|
state: "rejected",
|
|
@@ -170,6 +173,16 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
170
173
|
reason: (detail: string) => detail || "batch run rejected",
|
|
171
174
|
recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
|
|
172
175
|
},
|
|
176
|
+
stale_batch_absent: {
|
|
177
|
+
state: "rejected",
|
|
178
|
+
reason: (detail: string) => `no retirable batch record to dispose: ${detail}`,
|
|
179
|
+
recovery_action: "start a batch for this Initiative, or inspect the batch records under .imm/state/batches in the current Host",
|
|
180
|
+
},
|
|
181
|
+
stale_batch_in_flight: {
|
|
182
|
+
state: "blocked",
|
|
183
|
+
reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
|
|
184
|
+
recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
|
|
185
|
+
},
|
|
173
186
|
});
|
|
174
187
|
|
|
175
188
|
export interface BatchReason {
|
|
@@ -108,6 +108,48 @@ export interface StartBatchInput {
|
|
|
108
108
|
max_parallel?: number;
|
|
109
109
|
/** Present only in lane mode: Lanes someone else created, offered to the batch. */
|
|
110
110
|
lane_offers?: LaneOffer[];
|
|
111
|
+
/**
|
|
112
|
+
* Lane mode only: the opted-in tracker projection the coordinator runs after
|
|
113
|
+
* integrating a child. A Lane settlement writes nothing to the tracker, so
|
|
114
|
+
* this is where its Child Issue is closed, exactly once. Absent, nothing is
|
|
115
|
+
* projected.
|
|
116
|
+
*/
|
|
117
|
+
tracker?: BatchTrackerPort;
|
|
118
|
+
/**
|
|
119
|
+
* Lane mode only: the plugin or runtime directory the Lane Executor Host
|
|
120
|
+
* loads. Absent, the repository's own plugin source is compared when the
|
|
121
|
+
* repository is the Immune-Brain package; otherwise nothing is compared.
|
|
122
|
+
*/
|
|
123
|
+
executor_runtime?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Lane mode only: the project's full verification commands, recorded when a
|
|
126
|
+
* new batch starts and run on the batch branch once every child is
|
|
127
|
+
* integrated. Ignored on a resume; the recorded list stands.
|
|
128
|
+
*/
|
|
129
|
+
final_verification?: string[];
|
|
130
|
+
/**
|
|
131
|
+
* Lane mode only: one text instruction the Parent wants to send a Lane
|
|
132
|
+
* session. The tick records it and answers whether it may be sent; it is
|
|
133
|
+
* never sent to a blocked or working session.
|
|
134
|
+
*/
|
|
135
|
+
lane_instruction?: import("./batch_lanes").LaneInstructionRequest;
|
|
136
|
+
/**
|
|
137
|
+
* Lane mode only, ADR 0018: the literal user's answer, in this call's own
|
|
138
|
+
* batch confirmation, on delegating in-envelope Lane Intent revisions to the
|
|
139
|
+
* coordinator. Absent when no gate opened (a reused authorization): the
|
|
140
|
+
* recorded grant stands.
|
|
141
|
+
*/
|
|
142
|
+
revision_delegation?: boolean;
|
|
143
|
+
/** Lane mode only, ADR 0018: one breaking Intent revision the coordinator approves under the grant. */
|
|
144
|
+
lane_revision?: import("./batch_delegation").LaneRevisionRequest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The one tracker effect a lane batch may cause: closing an integrated child's Issue. */
|
|
148
|
+
export interface BatchTrackerPort {
|
|
149
|
+
markTerminal(
|
|
150
|
+
root: string,
|
|
151
|
+
input: { task_id: string; phase: "done"; terminal_event_id: string },
|
|
152
|
+
): Promise<{ status: string; message: string }>;
|
|
111
153
|
}
|
|
112
154
|
|
|
113
155
|
/** One git seam: the injected BatchRunnerGitPort, or the default adapter over
|
|
@@ -179,6 +221,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
|
179
221
|
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
180
222
|
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
181
223
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
224
|
+
superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
|
|
182
225
|
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
183
226
|
running: "The batch is still running; no terminal report is due yet.",
|
|
184
227
|
prepared: "The batch is prepared but not started.",
|
|
@@ -449,6 +492,7 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
|
|
|
449
492
|
})();
|
|
450
493
|
if (persisted !== null && isLaneBatchRecord(persisted))
|
|
451
494
|
return laneRejectionReport(
|
|
495
|
+
input,
|
|
452
496
|
persisted,
|
|
453
497
|
reason,
|
|
454
498
|
"settle the reported kernel store condition and retry in the current Host",
|
|
@@ -83,6 +83,46 @@ export function scopesOverlap(left: readonly string[], right: readonly string[])
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The entry pairs that make two scope lists overlap, in list order: the
|
|
88
|
+
* machine evidence behind `scopesOverlap`, so a plan can name the shared paths
|
|
89
|
+
* instead of asserting disjointness by hand. An empty list on either side
|
|
90
|
+
* overlaps everything and is reported as the pair `["", entry]`.
|
|
91
|
+
*/
|
|
92
|
+
export function overlappingScopeEntries(left: readonly string[], right: readonly string[]): Array<[string, string]> {
|
|
93
|
+
if (!left.length || !right.length) return [[left[0] ?? "", right[0] ?? ""]];
|
|
94
|
+
const pairs: Array<[string, string]> = [];
|
|
95
|
+
for (const entry of left) {
|
|
96
|
+
const prefix = scopePrefix(entry);
|
|
97
|
+
for (const other of right) if (prefixOverlaps(prefix, scopePrefix(other))) pairs.push([entry, other]);
|
|
98
|
+
}
|
|
99
|
+
return pairs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ScopeOverlapReport {
|
|
103
|
+
contract: "assurance_kernel/intent_scope_overlap/v1";
|
|
104
|
+
overlaps: Array<{ task_ids: [string, string]; entries: Array<[string, string]> }>;
|
|
105
|
+
parallel_groups: BatchPlanParallelGroups;
|
|
106
|
+
scope_conflicts: BatchPlanScopeConflict[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare the `scope_hint` of every pair of Intents of one Initiative. The
|
|
111
|
+
* groups use the same projection the Batch Plan uses, so the Planner's review
|
|
112
|
+
* table and the batch confirmation agree on what runs together.
|
|
113
|
+
*/
|
|
114
|
+
export function projectScopeOverlap(children: readonly PlanScheduleChild[]): ScopeOverlapReport {
|
|
115
|
+
const overlaps: ScopeOverlapReport["overlaps"] = [];
|
|
116
|
+
for (let i = 0; i < children.length; i += 1)
|
|
117
|
+
for (let j = i + 1; j < children.length; j += 1) {
|
|
118
|
+
const entries = scopesOverlap(children[i]!.scope_hint, children[j]!.scope_hint)
|
|
119
|
+
? overlappingScopeEntries(children[i]!.scope_hint, children[j]!.scope_hint)
|
|
120
|
+
: [];
|
|
121
|
+
if (entries.length) overlaps.push({ task_ids: [children[i]!.task_id, children[j]!.task_id], entries });
|
|
122
|
+
}
|
|
123
|
+
return { contract: "assurance_kernel/intent_scope_overlap/v1", overlaps, ...projectParallelGroups(children) };
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
function positiveLimit(maxParallel: number): number {
|
|
87
127
|
if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
|
|
88
128
|
if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)
|