immune-brain 3.6.9 → 4.0.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/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +34 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
// Host-independent batch preflight. Both Host adapters used to carry this
|
|
2
|
+
// projection verbatim (claim ownership, branch availability, working-tree
|
|
3
|
+
// cleanliness against the authorized scope, recovery children, plan digest, and
|
|
4
|
+
// base HEAD). It now lives here, below the Host boundary: no confirmation, no
|
|
5
|
+
// capability, no Host SDK, and no Host-identity branch. A Host adapter keeps
|
|
6
|
+
// only its confirmation transport, its failure-envelope shape, and its
|
|
7
|
+
// non-interactive refusal form.
|
|
8
|
+
//
|
|
9
|
+
// Every read is lock-free. Mutating Kernel entrypoints run pending-transaction
|
|
10
|
+
// recovery, and a preflight must write nothing before the literal user approves
|
|
11
|
+
// anything, including on decline, cancel, or rejection.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
import { LITERAL_USER_ACTOR_ID } from "../kernel/actor_identity";
|
|
19
|
+
import { readBackendClaim } from "../kernel/backend_claim";
|
|
20
|
+
import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
|
|
21
|
+
import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
22
|
+
import { readTaskIntent } from "../kernel/intent";
|
|
23
|
+
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw } from "../kernel/storage";
|
|
24
|
+
import { pathMatchesScope } from "../workspace_scope";
|
|
25
|
+
import { projectBatchPlan } from "./batch_plan";
|
|
26
|
+
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
27
|
+
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
28
|
+
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
29
|
+
import type {
|
|
30
|
+
BatchPlanBudget,
|
|
31
|
+
BatchPlanChild,
|
|
32
|
+
BatchPlanChildReason,
|
|
33
|
+
BatchPlanChildStatus,
|
|
34
|
+
InitiativeObservationReader,
|
|
35
|
+
} from "./types";
|
|
36
|
+
|
|
37
|
+
const INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
|
+
|
|
39
|
+
export interface BatchPreflightOptions {
|
|
40
|
+
root: string;
|
|
41
|
+
initiative_slug: string;
|
|
42
|
+
now?: string;
|
|
43
|
+
readInitiative?: InitiativeObservationReader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BatchPreflightProjection {
|
|
47
|
+
initiative_slug: string;
|
|
48
|
+
batch_branch: string;
|
|
49
|
+
is_resuming: boolean;
|
|
50
|
+
existing_batch: BatchRunStateRecord | null;
|
|
51
|
+
base_head: string;
|
|
52
|
+
budget: BatchPlanBudget;
|
|
53
|
+
plan_digest: string;
|
|
54
|
+
recovery_children: BatchPlanChild[];
|
|
55
|
+
/** Risk per child, captured by the authoritative read each Host renders. */
|
|
56
|
+
risk_by_task: Record<string, string>;
|
|
57
|
+
/** Children the plan excluded, in plan order. Empty on a resume. */
|
|
58
|
+
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type BatchPreflightOutcome =
|
|
62
|
+
| { ok: true; projection: BatchPreflightProjection }
|
|
63
|
+
| { ok: false; state: "blocked" | "rejected"; reason: string; recovery_action: string };
|
|
64
|
+
|
|
65
|
+
export interface BatchPreflightRejection {
|
|
66
|
+
state: "blocked" | "rejected";
|
|
67
|
+
reason: string;
|
|
68
|
+
recovery_action: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function batchRejection(key: BatchReasonKey, detail = ""): BatchPreflightRejection {
|
|
72
|
+
const resolved = batchReason(key, detail);
|
|
73
|
+
return {
|
|
74
|
+
state: resolved.state === "blocked" ? "blocked" : "rejected",
|
|
75
|
+
reason: resolved.reason,
|
|
76
|
+
recovery_action: resolved.recovery_action,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function reject(
|
|
81
|
+
key: BatchReasonKey,
|
|
82
|
+
detail = "",
|
|
83
|
+
): { ok: false } & BatchPreflightRejection {
|
|
84
|
+
return { ok: false, ...batchRejection(key, detail) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The live claim's task id, or null: the workspace owner first, then an active
|
|
89
|
+
* backend claim. Read-only and lock-free, so a preflight or a drift check can
|
|
90
|
+
* call it before any literal-user approval without writing anything.
|
|
91
|
+
*/
|
|
92
|
+
export function readActiveClaimTaskId(root: string): string | null {
|
|
93
|
+
const workspace = readWorkspaceStateRaw(root);
|
|
94
|
+
const claim = readBackendClaim(root);
|
|
95
|
+
return workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The batch record for this initiative, or a corruption marker. A terminal
|
|
100
|
+
* record (completed, budget_stopped, failed, rejected) is not active: returning
|
|
101
|
+
* it would let a settled batch block or be resumed by a later run.
|
|
102
|
+
*/
|
|
103
|
+
export type BatchRecordLookup =
|
|
104
|
+
| { corrupt: true; path: string }
|
|
105
|
+
| { corrupt: false; record: BatchRunStateRecord }
|
|
106
|
+
| null;
|
|
107
|
+
|
|
108
|
+
export function findExistingActiveBatch(root: string, initiativeSlug: string): BatchRecordLookup {
|
|
109
|
+
const batchesDir = join(root, ".imm", "state", "batches");
|
|
110
|
+
if (!existsSync(batchesDir)) return null;
|
|
111
|
+
for (const file of readdirSync(batchesDir)) {
|
|
112
|
+
if (!file.endsWith(".json")) continue;
|
|
113
|
+
let record: unknown;
|
|
114
|
+
try {
|
|
115
|
+
record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
|
|
116
|
+
} catch {
|
|
117
|
+
// Unreadable Kernel batch state must fail closed: silently treating the
|
|
118
|
+
// initiative as batchless could authorize a parallel run.
|
|
119
|
+
return { corrupt: true, path: file };
|
|
120
|
+
}
|
|
121
|
+
const candidate = record as BatchRunStateRecord;
|
|
122
|
+
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
123
|
+
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
124
|
+
const validStates = new Set([
|
|
125
|
+
"prepared",
|
|
126
|
+
"running",
|
|
127
|
+
"needs_human",
|
|
128
|
+
"completed",
|
|
129
|
+
"budget_stopped",
|
|
130
|
+
"failed",
|
|
131
|
+
"rejected",
|
|
132
|
+
]);
|
|
133
|
+
if (
|
|
134
|
+
typeof candidate.batch_id !== "string" ||
|
|
135
|
+
typeof candidate.base_head !== "string" ||
|
|
136
|
+
!Array.isArray(candidate.children) ||
|
|
137
|
+
!validStates.has(candidate.batch_state)
|
|
138
|
+
) {
|
|
139
|
+
return { corrupt: true, path: file };
|
|
140
|
+
}
|
|
141
|
+
if (isTerminalBatchState(candidate.batch_state)) continue;
|
|
142
|
+
return { corrupt: false, record: candidate };
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The newest *settled* record for this initiative, or null. A terminal record is
|
|
149
|
+
* not a batch to resume. Its branch and commit lineage may be reused by a new,
|
|
150
|
+
* explicitly confirmed run, while its record and terminal report stay intact.
|
|
151
|
+
*/
|
|
152
|
+
export function findSettledBatchRecord(root: string, initiativeSlug: string): BatchRunStateRecord | null {
|
|
153
|
+
const batchesDir = join(root, ".imm", "state", "batches");
|
|
154
|
+
if (!existsSync(batchesDir)) return null;
|
|
155
|
+
let newest: BatchRunStateRecord | null = null;
|
|
156
|
+
for (const file of readdirSync(batchesDir).sort()) {
|
|
157
|
+
if (!file.endsWith(".json")) continue;
|
|
158
|
+
let record: unknown;
|
|
159
|
+
try {
|
|
160
|
+
record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
|
|
161
|
+
} catch {
|
|
162
|
+
// The active lookup already fails closed on an unreadable record.
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const candidate = record as BatchRunStateRecord;
|
|
166
|
+
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
|
|
167
|
+
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
168
|
+
if (!isTerminalBatchState(candidate.batch_state)) continue;
|
|
169
|
+
if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
|
|
170
|
+
if (!Array.isArray(candidate.children)) continue;
|
|
171
|
+
if (!newest || Date.parse(candidate.updated_at) >= Date.parse(newest.updated_at)) newest = candidate;
|
|
172
|
+
}
|
|
173
|
+
return newest;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The HEAD a resumable batch must still sit on: its last child commit, or its base. */
|
|
177
|
+
export function expectedBatchHead(record: { base_head: string; commits?: unknown }): string {
|
|
178
|
+
const commits = Array.isArray(record.commits) ? (record.commits as string[]) : [];
|
|
179
|
+
return commits.length > 0 ? commits[commits.length - 1]! : record.base_head;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether the live claim is this batch's own interrupted child: same task, on
|
|
184
|
+
* the batch branch, still a live child of the record. Positive evidence only:
|
|
185
|
+
* the driver's durable child slot (enrolled/needs_human), the batch Git lineage,
|
|
186
|
+
* the Kernel's own event-id derivation, the intent identity on the TaskRecord,
|
|
187
|
+
* and the claim created before the batch's last durable write. The mutable
|
|
188
|
+
* confirmation_time is deliberately not used, so a needs_human re-authorization
|
|
189
|
+
* (which updates confirmation_time) can never turn this batch's own claim into a
|
|
190
|
+
* foreign one.
|
|
191
|
+
*
|
|
192
|
+
* Called fresh at pre-confirmation, post-confirmation, and from ownsTaskClaim so
|
|
193
|
+
* a claim swapped during confirmation is never adopted.
|
|
194
|
+
*/
|
|
195
|
+
export function isOwnBatchClaim(
|
|
196
|
+
root: string,
|
|
197
|
+
existingBatch: BatchRunStateRecord,
|
|
198
|
+
taskId: string,
|
|
199
|
+
batchBranch: string,
|
|
200
|
+
): boolean {
|
|
201
|
+
// Ownership is derived from the store: the active run is the claim, and the
|
|
202
|
+
// workspace owner is the active run's task.
|
|
203
|
+
let claim: Record<string, any> | null = null;
|
|
204
|
+
let workspaceOwner: string | null = null;
|
|
205
|
+
try {
|
|
206
|
+
claim = readBackendClaim(root) as unknown as Record<string, any> | null;
|
|
207
|
+
workspaceOwner = readWorkspaceStateRaw(root).state.current_working;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const currentTaskId =
|
|
212
|
+
workspaceOwner || (claim?.lifecycle_status === "active" ? claim?.task_id : null);
|
|
213
|
+
if (currentTaskId !== taskId || !claim) return false;
|
|
214
|
+
const branch = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
|
|
215
|
+
if (branch !== batchBranch) return false;
|
|
216
|
+
const childInBatch = existingBatch.children.find((c) => c.task_id === taskId);
|
|
217
|
+
if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
let rec: Record<string, any> | null = null;
|
|
221
|
+
try {
|
|
222
|
+
const run = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
|
|
223
|
+
rec = run ? (JSON.parse(run.record_json) as Record<string, any>) : null;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
if (!rec) return false;
|
|
228
|
+
const lineageHeads = [existingBatch.base_head].concat(
|
|
229
|
+
Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
|
|
230
|
+
);
|
|
231
|
+
if (!lineageHeads.includes(rec.git_base_head)) return false;
|
|
232
|
+
if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
|
|
233
|
+
const createdAt = Date.parse(claim.created_at);
|
|
234
|
+
if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
|
|
235
|
+
if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
|
|
236
|
+
if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
|
|
237
|
+
if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The in-flight child's authorized scope, read lock-free: the Kernel TaskRecord
|
|
243
|
+
* snapshot first (a frozen sidecar is archived and must not shrink the scope to
|
|
244
|
+
* empty), then the immutable terminal audit pair for a settled child, then the
|
|
245
|
+
* sidecar the record's own `intent_ref` path names.
|
|
246
|
+
*/
|
|
247
|
+
function authorizedScopeOf(root: string, taskId: string, state: string): string[] {
|
|
248
|
+
let scope: string[] = [];
|
|
249
|
+
let recordedIntentPath: string | undefined;
|
|
250
|
+
try {
|
|
251
|
+
const recordRead = readTaskRecordRaw(root, taskId);
|
|
252
|
+
scope = recordRead.record?.intent_snapshot?.scope_hint ?? [];
|
|
253
|
+
recordedIntentPath = recordRead.record?.intent_ref?.path;
|
|
254
|
+
} catch {
|
|
255
|
+
// fallback below
|
|
256
|
+
}
|
|
257
|
+
if (scope.length === 0 && state === "settled") {
|
|
258
|
+
// A settled child has no live state record; its authority is the immutable
|
|
259
|
+
// terminal audit pair. Read-only, so a refusal still writes nothing.
|
|
260
|
+
try {
|
|
261
|
+
const localRun = localRunId(root, taskId);
|
|
262
|
+
const settled = readAuditTaskPair(root, taskId, localRun ?? undefined);
|
|
263
|
+
const snapshot = (settled?.record as { intent_snapshot?: { scope_hint?: string[] } } | null | undefined)
|
|
264
|
+
?.intent_snapshot;
|
|
265
|
+
scope = snapshot?.scope_hint ?? [];
|
|
266
|
+
recordedIntentPath = recordedIntentPath ?? (settled?.record as { intent_ref?: { path?: string } } | null | undefined)
|
|
267
|
+
?.intent_ref?.path;
|
|
268
|
+
} catch {
|
|
269
|
+
// fallback below
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (scope.length === 0) {
|
|
273
|
+
try {
|
|
274
|
+
scope = readTaskIntent(root, taskId, recordedIntentPath).intent.scope_hint ?? [];
|
|
275
|
+
} catch {
|
|
276
|
+
// fallback below
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return scope;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
|
|
283
|
+
// review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
|
|
284
|
+
// format. Trimming the whole output first shifted the fixed status columns of
|
|
285
|
+
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
286
|
+
const statusProc = spawnSync(
|
|
287
|
+
"git",
|
|
288
|
+
["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
|
|
289
|
+
{ encoding: "utf8" },
|
|
290
|
+
);
|
|
291
|
+
if (statusProc.status !== 0) return null;
|
|
292
|
+
const entries: Array<{ code: string; path: string }> = [];
|
|
293
|
+
// -z with --no-renames lists each side of a rename as its own D/A entry, so a
|
|
294
|
+
// cross-scope rename cannot hide the out-of-scope source deletion.
|
|
295
|
+
for (const entry of statusProc.stdout.split("\0")) {
|
|
296
|
+
if (entry.length === 0) continue;
|
|
297
|
+
entries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
|
|
298
|
+
}
|
|
299
|
+
return entries;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The preflight projection, or the first rejection it hits. Purely read-only:
|
|
304
|
+
* it performs no confirmation, mints no capability, and branches on no Host.
|
|
305
|
+
*/
|
|
306
|
+
interface PlanSurface {
|
|
307
|
+
budget: BatchPlanBudget;
|
|
308
|
+
plan_digest: string;
|
|
309
|
+
recovery_children: BatchPlanChild[];
|
|
310
|
+
risk_by_task: Record<string, string>;
|
|
311
|
+
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type PlanSurfaceOutcome =
|
|
315
|
+
| { ok: true; surface: PlanSurface }
|
|
316
|
+
| { ok: false; key: BatchReasonKey; detail: string };
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The plan half of the projection: the confirmed plan surface for a fresh run,
|
|
320
|
+
* or the record's reconstructed children for a resume. Shared by the preflight
|
|
321
|
+
* and the post-confirmation drift check so both read one implementation.
|
|
322
|
+
*/
|
|
323
|
+
async function projectPlanSurface(input: {
|
|
324
|
+
root: string;
|
|
325
|
+
initiative_slug: string;
|
|
326
|
+
is_resuming: boolean;
|
|
327
|
+
existing_batch: BatchRunStateRecord | null;
|
|
328
|
+
now: string;
|
|
329
|
+
readInitiative?: InitiativeObservationReader;
|
|
330
|
+
}): Promise<PlanSurfaceOutcome> {
|
|
331
|
+
const { root, initiative_slug: initiativeSlug, is_resuming: isResuming, existing_batch: existingBatch, now } = input;
|
|
332
|
+
let recoveryChildren: BatchPlanChild[] = [];
|
|
333
|
+
let planDigest: string;
|
|
334
|
+
let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
|
|
335
|
+
const riskByTask = new Map<string, string>();
|
|
336
|
+
// Only an active run supplies children and budget; a terminal record supplies
|
|
337
|
+
// branch provenance, never the plan or authorization of the next run.
|
|
338
|
+
let budget: BatchPlanBudget;
|
|
339
|
+
|
|
340
|
+
if (isResuming && existingBatch) {
|
|
341
|
+
budget = existingBatch.budget;
|
|
342
|
+
try {
|
|
343
|
+
recoveryChildren = existingBatch.children.map((c) => {
|
|
344
|
+
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
345
|
+
let read: { intent: { revision: number; risk: string }; content_hash: string } = {
|
|
346
|
+
intent: { revision: 1, risk: "material" },
|
|
347
|
+
content_hash: "",
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
const taskRecordRead = readTaskRecordRaw(root, c.task_id);
|
|
351
|
+
if (taskRecordRead.record) {
|
|
352
|
+
read = {
|
|
353
|
+
intent: taskRecordRead.record.intent_snapshot as { revision: number; risk: string },
|
|
354
|
+
content_hash: taskRecordRead.record.intent_ref.content_hash,
|
|
355
|
+
};
|
|
356
|
+
} else {
|
|
357
|
+
read = readTaskIntent(root, c.task_id, intentPath) as unknown as typeof read;
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
|
|
361
|
+
try {
|
|
362
|
+
read = readTaskIntent(root, c.task_id, archivePath) as unknown as typeof read;
|
|
363
|
+
} catch {
|
|
364
|
+
read = readTaskIntent(root, c.task_id, intentPath) as unknown as typeof read;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Keep the risk captured by the authoritative read above; a stale
|
|
368
|
+
// reconstructed path must never fabricate a risk later.
|
|
369
|
+
riskByTask.set(c.task_id, read.intent?.risk ?? "material");
|
|
370
|
+
const isDone = c.state === "committed" || c.state === "settled";
|
|
371
|
+
return {
|
|
372
|
+
task_id: c.task_id,
|
|
373
|
+
slice_id: c.slice_id,
|
|
374
|
+
status: isDone ? ("already_settled" as const) : ("enrollable" as const),
|
|
375
|
+
blocked_by: [...c.blocked_by],
|
|
376
|
+
// Persisted reasons come from the same closed vocabulary the plan wrote.
|
|
377
|
+
reason: (c.reason ?? null) as BatchPlanChildReason | null,
|
|
378
|
+
intent_path: intentPath,
|
|
379
|
+
intent_revision: read.intent.revision,
|
|
380
|
+
intent_content_hash: read.content_hash,
|
|
381
|
+
} satisfies BatchPlanChild;
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return { ok: false, key: "plan_projection_failed", detail: err instanceof Error ? err.message : String(err) };
|
|
385
|
+
}
|
|
386
|
+
planDigest = computeBatchPlanDigest(
|
|
387
|
+
recoveryChildren.map((c) => ({
|
|
388
|
+
task_id: c.task_id,
|
|
389
|
+
intent_path: c.intent_path ?? `docs/plans/${c.task_id}.intent.json`,
|
|
390
|
+
intent_revision: c.intent_revision ?? 1,
|
|
391
|
+
intent_content_hash: c.intent_content_hash ?? "",
|
|
392
|
+
blocked_by: c.blocked_by,
|
|
393
|
+
})),
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
let plan;
|
|
397
|
+
try {
|
|
398
|
+
plan = await projectBatchPlan(root, initiativeSlug, { confirmation_time: now }, input.readInitiative);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
401
|
+
if (msg.includes("has no enrollable children"))
|
|
402
|
+
return { ok: false, key: "empty_enrollable_set", detail: "" };
|
|
403
|
+
return { ok: false, key: "plan_projection_failed", detail: msg };
|
|
404
|
+
}
|
|
405
|
+
if (!plan.enrollable.length)
|
|
406
|
+
return { ok: false, key: "empty_enrollable_set", detail: "" };
|
|
407
|
+
|
|
408
|
+
budget = plan.budget;
|
|
409
|
+
const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
|
|
410
|
+
recoveryChildren = plan.children
|
|
411
|
+
.filter((c) => c.status === "enrollable")
|
|
412
|
+
.map((c) => {
|
|
413
|
+
const digestChild = enrollableChildById.get(c.task_id);
|
|
414
|
+
return {
|
|
415
|
+
...c,
|
|
416
|
+
blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
|
|
417
|
+
} satisfies BatchPlanChild;
|
|
418
|
+
});
|
|
419
|
+
planDigest = computeBatchPlanDigest(plan.enrollable);
|
|
420
|
+
|
|
421
|
+
for (const c of plan.children.filter((item) => item.status === "enrollable")) {
|
|
422
|
+
let childRisk = "material";
|
|
423
|
+
try {
|
|
424
|
+
childRisk = readTaskIntent(root, c.task_id, c.intent_path ?? undefined).intent.risk;
|
|
425
|
+
} catch {
|
|
426
|
+
// fallback
|
|
427
|
+
}
|
|
428
|
+
riskByTask.set(c.task_id, childRisk);
|
|
429
|
+
}
|
|
430
|
+
excluded = plan.children
|
|
431
|
+
.filter((c) => c.status !== "enrollable")
|
|
432
|
+
.map((c) => ({ task_id: c.task_id, slice_id: c.slice_id, reason: c.reason ?? c.status }));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
surface: {
|
|
438
|
+
budget,
|
|
439
|
+
plan_digest: planDigest,
|
|
440
|
+
recovery_children: recoveryChildren,
|
|
441
|
+
risk_by_task: Object.fromEntries(
|
|
442
|
+
[...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
443
|
+
),
|
|
444
|
+
excluded,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function projectBatchPreflight(
|
|
450
|
+
options: BatchPreflightOptions,
|
|
451
|
+
): Promise<BatchPreflightOutcome> {
|
|
452
|
+
const { root, initiative_slug: initiativeSlug, readInitiative } = options;
|
|
453
|
+
if (!INITIATIVE_SLUG_PATTERN.test(initiativeSlug))
|
|
454
|
+
return reject("invalid_slug", initiativeSlug);
|
|
455
|
+
|
|
456
|
+
// Existing active/paused batch for this initiative: a terminal record is not active.
|
|
457
|
+
const found = findExistingActiveBatch(root, initiativeSlug);
|
|
458
|
+
if (found?.corrupt)
|
|
459
|
+
return reject("batch_state_unreadable", found.path);
|
|
460
|
+
// Settled records retain branch provenance; only active records are resumed.
|
|
461
|
+
const activeRecord: BatchRunStateRecord | null = found ? found.record : null;
|
|
462
|
+
const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
|
|
463
|
+
const isResuming = activeRecord !== null;
|
|
464
|
+
const batchBranch = `imm/${initiativeSlug}`;
|
|
465
|
+
|
|
466
|
+
// 1. Active workspace claim (pre-confirmation).
|
|
467
|
+
const activeTaskId = readActiveClaimTaskId(root);
|
|
468
|
+
const ownClaim =
|
|
469
|
+
isResuming && activeTaskId !== null && isOwnBatchClaim(root, activeRecord!, activeTaskId, batchBranch);
|
|
470
|
+
if (activeTaskId && !ownClaim)
|
|
471
|
+
return reject("claim_already_active", activeTaskId);
|
|
472
|
+
|
|
473
|
+
// 2. HEAD, branch availability, working tree (pre-confirmation, read-only).
|
|
474
|
+
let baseHead: string;
|
|
475
|
+
try {
|
|
476
|
+
baseHead = readGitHead(root);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
return reject("git_head_unreadable", err instanceof Error ? err.message : String(err));
|
|
479
|
+
}
|
|
480
|
+
const branchExists = spawnSync("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
|
|
481
|
+
if (branchExists.status === 0 && !existingBatch)
|
|
482
|
+
return reject("branch_already_exists", batchBranch);
|
|
483
|
+
|
|
484
|
+
const statusEntries = porcelainEntries(root);
|
|
485
|
+
if (statusEntries === null)
|
|
486
|
+
return reject("git_status_unreadable");
|
|
487
|
+
if (statusEntries.length > 0) {
|
|
488
|
+
if (!isResuming)
|
|
489
|
+
return reject("working_tree_dirty");
|
|
490
|
+
// Kernel projections accept staged in-flight work inside the active child's
|
|
491
|
+
// authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
|
|
492
|
+
// `settled` belongs here: Kernel settlement happens before the batch commits
|
|
493
|
+
// the child, and settlement clears the live state record, so a crash in that
|
|
494
|
+
// window resumes into a settled child whose staged work is legitimate.
|
|
495
|
+
const inFlightChild = existingBatch!.children.find(
|
|
496
|
+
(c) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
|
|
497
|
+
);
|
|
498
|
+
let authorizedScope: string[] = [];
|
|
499
|
+
if (inFlightChild) {
|
|
500
|
+
authorizedScope = authorizedScopeOf(root, inFlightChild.task_id, inFlightChild.state);
|
|
501
|
+
if (authorizedScope.length === 0)
|
|
502
|
+
return reject("authorized_scope_underivable");
|
|
503
|
+
}
|
|
504
|
+
const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
|
|
505
|
+
if (dirtyBytes)
|
|
506
|
+
return reject("working_tree_unstaged");
|
|
507
|
+
let outsideScope = false;
|
|
508
|
+
for (const { path } of statusEntries) {
|
|
509
|
+
if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
|
|
510
|
+
// Scope entries may be exact files, directories, or globs; delegate the
|
|
511
|
+
// boundary matching to the Kernel's own helper instead of exact includes.
|
|
512
|
+
let matched = false;
|
|
513
|
+
for (const scopePath of authorizedScope) {
|
|
514
|
+
if (pathMatchesScope(path, scopePath)) {
|
|
515
|
+
matched = true;
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (!matched) {
|
|
520
|
+
outsideScope = true;
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (outsideScope)
|
|
525
|
+
return reject("working_tree_out_of_scope");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 3. Project or reconstruct the plan (pre-confirmation).
|
|
529
|
+
const now = options.now ?? new Date().toISOString();
|
|
530
|
+
const planSurface = await projectPlanSurface({
|
|
531
|
+
root,
|
|
532
|
+
initiative_slug: initiativeSlug,
|
|
533
|
+
is_resuming: isResuming,
|
|
534
|
+
existing_batch: existingBatch,
|
|
535
|
+
now,
|
|
536
|
+
readInitiative,
|
|
537
|
+
});
|
|
538
|
+
if (!planSurface.ok)
|
|
539
|
+
return reject(planSurface.key, planSurface.detail);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
projection: {
|
|
544
|
+
initiative_slug: initiativeSlug,
|
|
545
|
+
batch_branch: batchBranch,
|
|
546
|
+
is_resuming: isResuming,
|
|
547
|
+
existing_batch: existingBatch,
|
|
548
|
+
base_head: baseHead,
|
|
549
|
+
budget: planSurface.surface.budget,
|
|
550
|
+
plan_digest: planSurface.surface.plan_digest,
|
|
551
|
+
recovery_children: planSurface.surface.recovery_children,
|
|
552
|
+
risk_by_task: planSurface.surface.risk_by_task,
|
|
553
|
+
excluded: planSurface.surface.excluded,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The drift surface a Host re-checks after the literal user confirmed, using
|
|
560
|
+
* the same implementations as the preflight: the live claim, the batch plan
|
|
561
|
+
* digest, and the base HEAD. `plan_digest === null` means the plan could not be
|
|
562
|
+
* read at all, which a Host reports as unreadable rather than changed.
|
|
563
|
+
*/
|
|
564
|
+
export interface BatchDriftProjection {
|
|
565
|
+
active_claim_task_id: string | null;
|
|
566
|
+
own_claim: boolean;
|
|
567
|
+
base_head: string | null;
|
|
568
|
+
plan_digest: string | null;
|
|
569
|
+
plan_unavailable_reason: string | null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
|
|
573
|
+
const { root, initiative_slug: initiativeSlug, readInitiative } = options;
|
|
574
|
+
const found = findExistingActiveBatch(root, initiativeSlug);
|
|
575
|
+
const activeRecord: BatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
|
|
576
|
+
const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
|
|
577
|
+
const isResuming = activeRecord !== null;
|
|
578
|
+
const batchBranch = `imm/${initiativeSlug}`;
|
|
579
|
+
const activeClaimTaskId = readActiveClaimTaskId(root);
|
|
580
|
+
const surface = await projectPlanSurface({
|
|
581
|
+
root,
|
|
582
|
+
initiative_slug: initiativeSlug,
|
|
583
|
+
is_resuming: isResuming,
|
|
584
|
+
existing_batch: existingBatch,
|
|
585
|
+
now: options.now ?? new Date().toISOString(),
|
|
586
|
+
readInitiative,
|
|
587
|
+
});
|
|
588
|
+
// HEAD is read AFTER the plan surface: the whole point of the drift check is
|
|
589
|
+
// to catch a commit that landed during the asynchronous plan read.
|
|
590
|
+
let baseHead: string | null = null;
|
|
591
|
+
try {
|
|
592
|
+
baseHead = readGitHead(root);
|
|
593
|
+
} catch {
|
|
594
|
+
// The Host reports an unreadable repository from the null it sees here.
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
active_claim_task_id: activeClaimTaskId,
|
|
598
|
+
own_claim:
|
|
599
|
+
isResuming && activeClaimTaskId !== null && isOwnBatchClaim(root, existingBatch!, activeClaimTaskId, batchBranch),
|
|
600
|
+
base_head: baseHead,
|
|
601
|
+
plan_digest: surface.ok ? surface.surface.plan_digest : null,
|
|
602
|
+
plan_unavailable_reason: surface.ok ? null : batchReason(surface.key, surface.detail).reason,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The literal-user gate facts: the shared plan and Kernel facts a Host renders
|
|
608
|
+
* its own confirmation UI around. Rendering stays Host-specific; no decision
|
|
609
|
+
* does.
|
|
610
|
+
*/
|
|
611
|
+
export interface BatchConfirmationFacts {
|
|
612
|
+
initiative_slug: string;
|
|
613
|
+
batch_branch: string;
|
|
614
|
+
plan_digest: string;
|
|
615
|
+
budget: BatchPlanBudget;
|
|
616
|
+
expires_at: string;
|
|
617
|
+
reuse_blockers: string[];
|
|
618
|
+
children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
|
|
619
|
+
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* A Host's answer to its own gate. `request_id` is the Host's confirmation
|
|
624
|
+
* identity; a Host rejection rides through untouched so each adapter keeps its
|
|
625
|
+
* own failure-envelope shape.
|
|
626
|
+
*/
|
|
627
|
+
export type BatchGateDecision<HostRejection> =
|
|
628
|
+
| { kind: "confirmed"; request_id: string }
|
|
629
|
+
| { kind: "host_rejection"; value: HostRejection };
|
|
630
|
+
|
|
631
|
+
export type BatchAuthorizationOutcome<HostRejection> =
|
|
632
|
+
| {
|
|
633
|
+
outcome: "authorized";
|
|
634
|
+
batch_id: string;
|
|
635
|
+
reuse_authorization: boolean;
|
|
636
|
+
reuse_blockers: string[];
|
|
637
|
+
expires_at: string;
|
|
638
|
+
binding: BatchAuthorizationBinding;
|
|
639
|
+
}
|
|
640
|
+
| { outcome: "rejected"; rejection: BatchPreflightRejection }
|
|
641
|
+
| { outcome: "host_rejection"; value: HostRejection };
|
|
642
|
+
|
|
643
|
+
export interface BatchAuthorizationOptions<HostRejection> {
|
|
644
|
+
root: string;
|
|
645
|
+
initiative_slug: string;
|
|
646
|
+
now: string;
|
|
647
|
+
projection: BatchPreflightProjection;
|
|
648
|
+
readInitiative?: InitiativeObservationReader;
|
|
649
|
+
/** Host-native literal-user gate; called only when the authorization cannot be reused. */
|
|
650
|
+
gate: (facts: BatchConfirmationFacts) => Promise<BatchGateDecision<HostRejection>>;
|
|
651
|
+
/** Host-native confirmation reference for the binding. */
|
|
652
|
+
confirmationRef: (input: { batch_id: string; request_id: string | null }) => string;
|
|
653
|
+
/** Host-native binding nonce. */
|
|
654
|
+
nonce: string;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The one authorization flow both Host adapters call after the shared
|
|
659
|
+
* preflight: ADR-0005's reuse/expiry decision, the literal-user gate, the
|
|
660
|
+
* post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
|
|
661
|
+
* supplies its gate, its confirmation reference, and its nonce; every decision
|
|
662
|
+
* below stays Host-independent.
|
|
663
|
+
*/
|
|
664
|
+
export async function authorizeBatch<HostRejection>(
|
|
665
|
+
options: BatchAuthorizationOptions<HostRejection>,
|
|
666
|
+
): Promise<BatchAuthorizationOutcome<HostRejection>> {
|
|
667
|
+
const { root, initiative_slug: initiativeSlug, now, projection } = options;
|
|
668
|
+
const {
|
|
669
|
+
batch_branch: batchBranch,
|
|
670
|
+
base_head: baseHead,
|
|
671
|
+
budget,
|
|
672
|
+
plan_digest: planDigest,
|
|
673
|
+
is_resuming: isResuming,
|
|
674
|
+
} = projection;
|
|
675
|
+
const existingBatch = projection.existing_batch;
|
|
676
|
+
|
|
677
|
+
// ADR-0005 Decision 1: one Batch Authorization spans the work it authorizes,
|
|
678
|
+
// so its expiry is the deadline the literal user confirmed rather than a fixed
|
|
679
|
+
// window that lapses while a child is parked on a foreground Review.
|
|
680
|
+
const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
|
|
681
|
+
const expiresAt = isResuming && !isExistingExpired && existingBatch!.batch_state === "running"
|
|
682
|
+
? existingBatch!.authorization_expires_at
|
|
683
|
+
: budget.deadline_at;
|
|
684
|
+
|
|
685
|
+
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
686
|
+
// reuses it instead of opening a second native gate. Anything that no longer
|
|
687
|
+
// binds falls through to the gate below, which names the reason.
|
|
688
|
+
const reuseBlockers: string[] = [];
|
|
689
|
+
if (isResuming && existingBatch) {
|
|
690
|
+
if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
|
|
691
|
+
if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
|
|
692
|
+
if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
|
|
693
|
+
if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
|
|
694
|
+
if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
|
|
695
|
+
}
|
|
696
|
+
const reuseAuthorization = isResuming && reuseBlockers.length === 0;
|
|
697
|
+
|
|
698
|
+
const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
|
|
699
|
+
const facts: BatchConfirmationFacts = {
|
|
700
|
+
initiative_slug: initiativeSlug,
|
|
701
|
+
batch_branch: batchBranch,
|
|
702
|
+
plan_digest: planDigest,
|
|
703
|
+
budget,
|
|
704
|
+
expires_at: expiresAt,
|
|
705
|
+
reuse_blockers: reuseBlockers,
|
|
706
|
+
children: projection.recovery_children.map((child) => ({
|
|
707
|
+
task_id: child.task_id,
|
|
708
|
+
slice_id: child.slice_id,
|
|
709
|
+
risk: projection.risk_by_task[child.task_id] ?? "material",
|
|
710
|
+
status: child.status,
|
|
711
|
+
})),
|
|
712
|
+
excluded: projection.excluded,
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
let requestId: string | null = null;
|
|
716
|
+
if (!reuseAuthorization) {
|
|
717
|
+
const decision = await options.gate(facts);
|
|
718
|
+
if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
|
|
719
|
+
requestId = decision.request_id;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Post-gate cascade: the drift projection reports the live claim it read
|
|
723
|
+
// before its own asynchronous plan read, so a claim swapped during the gate
|
|
724
|
+
// and one appearing during the read are both caught without either adapter
|
|
725
|
+
// recomputing the claim.
|
|
726
|
+
const drift = await projectBatchDrift({
|
|
727
|
+
root,
|
|
728
|
+
initiative_slug: initiativeSlug,
|
|
729
|
+
now,
|
|
730
|
+
readInitiative: options.readInitiative,
|
|
731
|
+
});
|
|
732
|
+
if (drift.active_claim_task_id && !drift.own_claim)
|
|
733
|
+
return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", drift.active_claim_task_id) };
|
|
734
|
+
if (drift.plan_digest === null)
|
|
735
|
+
return { outcome: "rejected", rejection: batchRejection("plan_became_unreadable", drift.plan_unavailable_reason ?? "") };
|
|
736
|
+
if (drift.plan_digest !== planDigest)
|
|
737
|
+
return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
|
|
738
|
+
if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
739
|
+
if (drift.base_head !== baseHead)
|
|
740
|
+
return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
|
|
741
|
+
|
|
742
|
+
// The drift projection's own claim read happened before its plan read; this
|
|
743
|
+
// one happens after it, so a foreign enrollment completing during that read is
|
|
744
|
+
// still caught before any authority is issued.
|
|
745
|
+
const finalClaimTaskId = readActiveClaimTaskId(root);
|
|
746
|
+
const finalOwnClaim =
|
|
747
|
+
isResuming && finalClaimTaskId !== null && isOwnBatchClaim(root, existingBatch!, finalClaimTaskId, batchBranch);
|
|
748
|
+
if (finalClaimTaskId && !finalOwnClaim)
|
|
749
|
+
return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
|
|
750
|
+
|
|
751
|
+
const binding: BatchAuthorizationBinding = {
|
|
752
|
+
batch_id: batchId,
|
|
753
|
+
initiative_slug: initiativeSlug,
|
|
754
|
+
plan_digest: planDigest,
|
|
755
|
+
branch: batchBranch,
|
|
756
|
+
base_head: isResuming && existingBatch ? existingBatch.base_head : baseHead,
|
|
757
|
+
budget,
|
|
758
|
+
actor_id: LITERAL_USER_ACTOR_ID,
|
|
759
|
+
confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
|
|
760
|
+
expires_at: expiresAt,
|
|
761
|
+
nonce: options.nonce,
|
|
762
|
+
};
|
|
763
|
+
return {
|
|
764
|
+
outcome: "authorized",
|
|
765
|
+
batch_id: batchId,
|
|
766
|
+
reuse_authorization: reuseAuthorization,
|
|
767
|
+
reuse_blockers: reuseBlockers,
|
|
768
|
+
expires_at: expiresAt,
|
|
769
|
+
binding,
|
|
770
|
+
};
|
|
771
|
+
}
|