immune-brain 4.4.1 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
- package/plugins/immune-brain/dist/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
- package/plugins/immune-brain/dist/imm-loop.md +52 -6
- package/plugins/immune-brain/dist/imm-planner.md +102 -13
- package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
- package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
- package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
- package/plugins/immune-brain/skills/BASELINE.md +18 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
|
@@ -6,7 +6,6 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import {
|
|
9
|
-
BatchAuthorizationExpiryError,
|
|
10
9
|
type BatchAuthorityRegistry,
|
|
11
10
|
type ValidatedBatchAuthorization,
|
|
12
11
|
computeBatchPlanDigest,
|
|
@@ -23,6 +22,7 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
|
|
|
23
22
|
recovery_findings: record.findings.filter(f => f.status === "open").map(f => ({ id: f.id, kind: f.kind, status: f.status, acceptance_id: f.acceptance_id })) };
|
|
24
23
|
}
|
|
25
24
|
import type { BatchPlanChild } from "./types";
|
|
25
|
+
import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
|
|
26
26
|
import {
|
|
27
27
|
type BatchRunnerGitPort,
|
|
28
28
|
type BatchGitPreflightResult,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
commitBatchChild,
|
|
31
31
|
lookupBatchCommit,
|
|
32
32
|
} from "./batch_git";
|
|
33
|
+
import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
|
|
33
34
|
import {
|
|
34
35
|
type BatchRunStateRecord,
|
|
35
36
|
type BatchChildRun,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
prepareBatchRunState,
|
|
39
40
|
readBatchRunState,
|
|
40
41
|
writeBatchRunState,
|
|
42
|
+
replaceBatchRunState,
|
|
41
43
|
writeBatchRunReport,
|
|
42
44
|
} from "./batch_state";
|
|
43
45
|
|
|
@@ -90,15 +92,15 @@ export interface BatchRunnerKernelPort {
|
|
|
90
92
|
* is held under this batch's derived capability (consumed child slot). */
|
|
91
93
|
ownsTaskClaim(taskId: string): boolean;
|
|
92
94
|
/** review-8(4th rework): Kernel-side proof that a replacement parked-batch
|
|
93
|
-
* authorization is genuine
|
|
95
|
+
* authorization is genuine and bound to this exact batch_id,
|
|
94
96
|
* plan_digest, and base_head, validated against the Kernel's authoritative
|
|
95
|
-
* binding state. Throws on fabrication
|
|
97
|
+
* binding state. Throws on fabrication or mismatch; the driver
|
|
96
98
|
* must call this before accepting a parked-batch resume. */
|
|
97
99
|
validateBatchAuthorization(input: {
|
|
98
100
|
registry: BatchAuthorityRegistry;
|
|
99
101
|
capability: object;
|
|
100
102
|
binding: Pick<ValidatedBatchAuthorization,
|
|
101
|
-
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget"
|
|
103
|
+
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget">;
|
|
102
104
|
}): ValidatedBatchAuthorization;
|
|
103
105
|
}
|
|
104
106
|
|
|
@@ -121,8 +123,7 @@ export interface StartBatchInput {
|
|
|
121
123
|
plan_digest: string;
|
|
122
124
|
base_head: string;
|
|
123
125
|
confirmation_time: string;
|
|
124
|
-
|
|
125
|
-
budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
|
|
126
|
+
budget: { max_children: number; qa_failure_limit: number };
|
|
126
127
|
now: string;
|
|
127
128
|
kernel: BatchRunnerKernelPort;
|
|
128
129
|
git?: BatchRunnerGitPort;
|
|
@@ -153,7 +154,7 @@ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: str
|
|
|
153
154
|
);
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
function budgetStopReason(record: BatchRunStateRecord
|
|
157
|
+
function budgetStopReason(record: BatchRunStateRecord): string | null {
|
|
157
158
|
// review-3: budget counts enrollments consumed (children that left
|
|
158
159
|
// pending), not commits, so parked children still consume authorization.
|
|
159
160
|
const enrolledCount = record.children.filter(
|
|
@@ -161,22 +162,9 @@ function budgetStopReason(record: BatchRunStateRecord, now: number): string | nu
|
|
|
161
162
|
).length;
|
|
162
163
|
if (enrolledCount >= record.budget.max_children)
|
|
163
164
|
return `max_children budget exhausted (${record.budget.max_children})`;
|
|
164
|
-
const deadline = Date.parse(record.budget.deadline_at);
|
|
165
|
-
if (!Number.isNaN(deadline) && now >= deadline)
|
|
166
|
-
return `deadline_at reached (${record.budget.deadline_at})`;
|
|
167
165
|
return null;
|
|
168
166
|
}
|
|
169
167
|
|
|
170
|
-
/** review-7(3rd rework): the typed expiry marker now lives at the Kernel
|
|
171
|
-
* boundary (kernel/batch_authority.ts) so a real enrollment-time expiry is
|
|
172
|
-
* structurally classifiable as an intentional budget stop; re-exported here
|
|
173
|
-
* for driver API compatibility. */
|
|
174
|
-
export { BatchAuthorizationExpiryError };
|
|
175
|
-
|
|
176
|
-
function isAuthorizationExpiryError(error: unknown): boolean {
|
|
177
|
-
return error instanceof BatchAuthorizationExpiryError;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
168
|
function requireFreshProjection<T extends AssuranceProjectionResult>(
|
|
181
169
|
result: T,
|
|
182
170
|
taskId: string,
|
|
@@ -201,7 +189,7 @@ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null
|
|
|
201
189
|
|
|
202
190
|
const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
203
191
|
completed: "The batch settled every enrollable child; review the commits and the tracker.",
|
|
204
|
-
budget_stopped: "
|
|
192
|
+
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
205
193
|
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
206
194
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
207
195
|
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
@@ -296,32 +284,49 @@ function failPersistedLineage(
|
|
|
296
284
|
return writeBatchRunState(root, record);
|
|
297
285
|
}
|
|
298
286
|
|
|
299
|
-
/**
|
|
300
|
-
*
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
encoding: "utf8",
|
|
315
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
287
|
+
/** A HEAD that fast-forwards on the batch branch is the user's own work: adopt
|
|
288
|
+
* it as the new expected head and record it. Any other movement is the fatal
|
|
289
|
+
* lineage break, returned as the failure message with the record unchanged. */
|
|
290
|
+
function reconcileLineage(
|
|
291
|
+
root: string,
|
|
292
|
+
record: BatchRunStateRecord,
|
|
293
|
+
): { record: BatchRunStateRecord; failure: string | null } {
|
|
294
|
+
if (!existsSync(join(root, ".git"))) return { record, failure: null };
|
|
295
|
+
const expected = expectedBatchHead(record);
|
|
296
|
+
const lineage = classifyBatchLineage({
|
|
297
|
+
root,
|
|
298
|
+
branch: record.branch ?? "",
|
|
299
|
+
expectedHead: expected,
|
|
300
|
+
childCommits: record.commits,
|
|
301
|
+
batchId: record.batch_id,
|
|
316
302
|
});
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
303
|
+
if (lineage.kind === "broken") return { record, failure: lineage.message };
|
|
304
|
+
if (lineage.kind === "equal") return { record, failure: null };
|
|
305
|
+
return {
|
|
306
|
+
record: writeBatchRunState(root, {
|
|
307
|
+
...record,
|
|
308
|
+
adopted_heads: [...(record.adopted_heads ?? []), { from: expected, to: lineage.head }],
|
|
309
|
+
}),
|
|
310
|
+
failure: null,
|
|
311
|
+
};
|
|
322
312
|
}
|
|
323
313
|
|
|
324
314
|
async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
|
|
315
|
+
if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
|
|
316
|
+
const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
317
|
+
const live = head.stdout.trim();
|
|
318
|
+
const expected = expectedBatchHead(record);
|
|
319
|
+
const adoptable = () =>
|
|
320
|
+
classifyBatchLineage({
|
|
321
|
+
root: input.root,
|
|
322
|
+
branch: record.branch ?? "",
|
|
323
|
+
expectedHead: expected,
|
|
324
|
+
childCommits: record.commits,
|
|
325
|
+
batchId: record.batch_id,
|
|
326
|
+
}).kind === "fast_forward";
|
|
327
|
+
if (head.status !== 0 || (live !== expected && !ownUnpersistedBatchHead(input.root, record, live) && !adoptable()))
|
|
328
|
+
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
329
|
+
}
|
|
325
330
|
const plan = input.registry.children(input.capability);
|
|
326
331
|
if (record.plan_digest !== computeBatchPlanDigest(plan) ||
|
|
327
332
|
record.children.length !== plan.length || record.children.some((child, index) => {
|
|
@@ -384,7 +389,7 @@ function replayTerminal(
|
|
|
384
389
|
);
|
|
385
390
|
}
|
|
386
391
|
|
|
387
|
-
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null):
|
|
392
|
+
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): ValidatedBatchAuthorization {
|
|
388
393
|
const authorized = input.kernel.validateBatchAuthorization({
|
|
389
394
|
registry: input.registry,
|
|
390
395
|
capability: input.capability,
|
|
@@ -394,7 +399,6 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
394
399
|
base_head: existing?.base_head ?? input.base_head,
|
|
395
400
|
initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
|
|
396
401
|
budget: input.budget,
|
|
397
|
-
expires_at: input.authorization_expires_at,
|
|
398
402
|
},
|
|
399
403
|
});
|
|
400
404
|
if (authorized.issued_at !== input.confirmation_time ||
|
|
@@ -409,6 +413,32 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
409
413
|
if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
|
|
410
414
|
input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
|
|
411
415
|
throw new Error("batch run input does not match the authorized plan or base_head");
|
|
416
|
+
return authorized;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
|
|
420
|
+
if (existing.plan_digest === input.plan_digest) return existing;
|
|
421
|
+
const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
|
|
422
|
+
budget: input.budget };
|
|
423
|
+
if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
|
|
424
|
+
input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
|
|
425
|
+
throw new Error("plan_digest mismatch: reconfirmation binding changed");
|
|
426
|
+
const validate = () => {
|
|
427
|
+
const plan = input.registry.children(input.capability);
|
|
428
|
+
if (existing.children.length !== plan.length || plan.some((child, index) => {
|
|
429
|
+
const old = existing.children[index]!, nextChild = input.children[index];
|
|
430
|
+
return !nextChild || old.task_id !== child.task_id || old.slice_id !== nextChild.slice_id ||
|
|
431
|
+
JSON.stringify(old.blocked_by) !== JSON.stringify(child.blocked_by) || JSON.stringify(nextChild.blocked_by) !== JSON.stringify(child.blocked_by);
|
|
432
|
+
})) throw new Error("plan_digest mismatch: reconfirmation topology changed");
|
|
433
|
+
const { issued_at: _issuedAt, ...binding } = validateRunAuthorization(input, { ...existing, plan_digest: input.plan_digest });
|
|
434
|
+
if (binding.branch !== existing.branch) throw new Error("plan_digest mismatch: reconfirmation branch changed");
|
|
435
|
+
prepareBatchRunState(input);
|
|
436
|
+
return input.registry.inspect(input.capability, binding);
|
|
437
|
+
};
|
|
438
|
+
const authority = validate();
|
|
439
|
+
const captured = takeReconfirmation(authority.nonce);
|
|
440
|
+
if (captured.root !== input.root || captured.plan_digest !== input.plan_digest) throw new Error("plan_digest mismatch: captured reconfirmation does not match run input");
|
|
441
|
+
return replaceBatchRunState(input.root, captured.stateBytes, next, () => { captured.assertUnchanged(); validate(); });
|
|
412
442
|
}
|
|
413
443
|
|
|
414
444
|
export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
|
|
@@ -460,7 +490,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
460
490
|
}
|
|
461
491
|
|
|
462
492
|
let existing = readBatchRunState(input.root, input.batch_id);
|
|
493
|
+
const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
|
|
463
494
|
if (existing) {
|
|
495
|
+
existing = applyPlanReconfirmation(input, existing);
|
|
464
496
|
try {
|
|
465
497
|
await validatePersistedRun(input, existing);
|
|
466
498
|
} catch (error) {
|
|
@@ -481,13 +513,10 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
481
513
|
if (existing?.batch_state === "needs_human") {
|
|
482
514
|
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
483
515
|
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
484
|
-
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
485
516
|
const freshAuthorization =
|
|
486
517
|
Number.isFinite(nextConfirmation) &&
|
|
487
|
-
nextConfirmation > priorConfirmation
|
|
488
|
-
|
|
489
|
-
nextExpiry > Date.now();
|
|
490
|
-
if (!freshAuthorization) {
|
|
518
|
+
nextConfirmation > priorConfirmation;
|
|
519
|
+
if (!freshAuthorization && !reconfirmed) {
|
|
491
520
|
return finalize(
|
|
492
521
|
input.root,
|
|
493
522
|
existing,
|
|
@@ -496,7 +525,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
496
525
|
);
|
|
497
526
|
}
|
|
498
527
|
|
|
499
|
-
validateRunAuthorization(input, existing);
|
|
528
|
+
if (!reconfirmed) validateRunAuthorization(input, existing);
|
|
500
529
|
prepareBatchRunState({ ...input, now: input.now });
|
|
501
530
|
const remapped = await Promise.all(
|
|
502
531
|
existing.children.map(async (child) => {
|
|
@@ -538,7 +567,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
538
567
|
existing = writeBatchRunState(input.root, {
|
|
539
568
|
...reparked,
|
|
540
569
|
confirmation_time: input.confirmation_time,
|
|
541
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
542
570
|
budget: input.budget,
|
|
543
571
|
batch_state: "needs_human",
|
|
544
572
|
});
|
|
@@ -557,42 +585,12 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
557
585
|
existing = writeBatchRunState(input.root, {
|
|
558
586
|
...existing,
|
|
559
587
|
confirmation_time: input.confirmation_time,
|
|
560
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
561
588
|
budget: input.budget,
|
|
562
589
|
batch_state: "running",
|
|
563
590
|
children: resumedChildren,
|
|
564
591
|
});
|
|
565
592
|
}
|
|
566
593
|
|
|
567
|
-
// A running batch can outlive its authorization while it waits on a child's
|
|
568
|
-
// reserved foreground Review. The driver owns every batch state transition,
|
|
569
|
-
// so the renewal the Host bound into the capability must be adopted here;
|
|
570
|
-
// otherwise the record keeps the expired stamp and the next child enrollment
|
|
571
|
-
// stops the batch as budget_stopped even though the literal user just
|
|
572
|
-
// re-confirmed it. Renewal requires the same proof the parked path requires:
|
|
573
|
-
// a strictly newer literal-user confirmation and a later, still-valid expiry.
|
|
574
|
-
if (existing?.batch_state === "running") {
|
|
575
|
-
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
576
|
-
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
577
|
-
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
578
|
-
const persistedExpiry = Date.parse(existing.authorization_expires_at);
|
|
579
|
-
const renewedAuthorization =
|
|
580
|
-
Number.isFinite(nextConfirmation) &&
|
|
581
|
-
nextConfirmation > priorConfirmation &&
|
|
582
|
-
Number.isFinite(nextExpiry) &&
|
|
583
|
-
nextExpiry > Date.now() &&
|
|
584
|
-
nextExpiry > persistedExpiry;
|
|
585
|
-
if (renewedAuthorization) {
|
|
586
|
-
validateRunAuthorization(input, existing);
|
|
587
|
-
existing = writeBatchRunState(input.root, {
|
|
588
|
-
...existing,
|
|
589
|
-
confirmation_time: input.confirmation_time,
|
|
590
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
591
|
-
budget: input.budget,
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
594
|
// review-2(6th round): an enrolled or settled child from an interrupted
|
|
597
595
|
// run must be driven to its own terminal settlement and commit before any
|
|
598
596
|
// new child is selected, or startBatch would double-claim the task or
|
|
@@ -658,7 +656,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
658
656
|
plan_digest: input.plan_digest,
|
|
659
657
|
base_head: input.base_head,
|
|
660
658
|
confirmation_time: input.confirmation_time,
|
|
661
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
662
659
|
budget: input.budget,
|
|
663
660
|
now: input.now,
|
|
664
661
|
});
|
|
@@ -672,17 +669,15 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
672
669
|
persist();
|
|
673
670
|
}
|
|
674
671
|
|
|
675
|
-
let head = record.commits.length
|
|
676
|
-
? record.commits[record.commits.length - 1]!
|
|
677
|
-
: record.base_head;
|
|
678
|
-
|
|
679
672
|
// Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
|
|
680
|
-
const
|
|
681
|
-
|
|
673
|
+
const lineage = reconcileLineage(input.root, record);
|
|
674
|
+
record = lineage.record;
|
|
675
|
+
if (lineage.failure) {
|
|
682
676
|
record.batch_state = "failed";
|
|
683
677
|
persist();
|
|
684
|
-
return finalize(input.root, record,
|
|
678
|
+
return finalize(input.root, record, lineage.failure, "");
|
|
685
679
|
}
|
|
680
|
+
let head = expectedBatchHead(record);
|
|
686
681
|
|
|
687
682
|
while (record.batch_state === "running") {
|
|
688
683
|
const child = nextEnrollableChild(record);
|
|
@@ -745,9 +740,8 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
745
740
|
);
|
|
746
741
|
persist();
|
|
747
742
|
} else {
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
|
|
743
|
+
// An adopted claim is not a new enrollment.
|
|
744
|
+
if (budgetStopReason(record)) {
|
|
751
745
|
record.batch_state = record.children.some(
|
|
752
746
|
(c) => c.state === "needs_human" || c.state === "skipped_blocked",
|
|
753
747
|
) ? "needs_human" : "budget_stopped";
|
|
@@ -764,7 +758,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
764
758
|
binding: {
|
|
765
759
|
batch_id: record.batch_id, plan_digest: record.plan_digest,
|
|
766
760
|
base_head: record.base_head, initiative_slug: record.initiative_slug,
|
|
767
|
-
budget: record.budget,
|
|
761
|
+
budget: record.budget,
|
|
768
762
|
},
|
|
769
763
|
});
|
|
770
764
|
for (const committed of record.children) {
|
|
@@ -786,17 +780,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
786
780
|
persist();
|
|
787
781
|
} catch (error) {
|
|
788
782
|
const message = error instanceof Error ? error.message : String(error);
|
|
789
|
-
// Only a typed BatchAuthorizationExpiryError is an intentional
|
|
790
|
-
// budget stop; free-form message matching misclassified
|
|
791
|
-
// infrastructure and validation failures as intentional stops.
|
|
792
|
-
if (isAuthorizationExpiryError(error)) {
|
|
793
|
-
record.children = record.children.map((c) =>
|
|
794
|
-
c.task_id === child.task_id ? { ...c, state: "pending", reason: message } : c,
|
|
795
|
-
);
|
|
796
|
-
record.batch_state = "budget_stopped";
|
|
797
|
-
persist();
|
|
798
|
-
break;
|
|
799
|
-
}
|
|
800
783
|
// review round 10: external HEAD movement after the pre-loop check
|
|
801
784
|
// (including between children) fails the batch, it never parks.
|
|
802
785
|
if (isLineageBreakError(message)) {
|
|
@@ -834,8 +817,8 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
|
834
817
|
switch (record.batch_state) {
|
|
835
818
|
case "budget_stopped":
|
|
836
819
|
return (
|
|
837
|
-
budgetStopReason(record
|
|
838
|
-
"
|
|
820
|
+
budgetStopReason(record) ??
|
|
821
|
+
"the child budget stopped new enrollments"
|
|
839
822
|
);
|
|
840
823
|
case "failed": {
|
|
841
824
|
const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
|
|
@@ -853,17 +836,17 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
|
853
836
|
/**
|
|
854
837
|
* review-1: resume the interrupted child first. A persisted enrolled child
|
|
855
838
|
* is driven through the Kernel obligation surface to its own terminal
|
|
856
|
-
* settlement and commit before any new enrollment
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
* expired authorization by requiring a new literal-user confirmation.
|
|
839
|
+
* settlement and commit before any new enrollment, so an interrupted run can
|
|
840
|
+
* never falsely complete or enroll a sibling while a child claim is still
|
|
841
|
+
* open. Never replays a committed mutation.
|
|
860
842
|
*/
|
|
861
843
|
export async function resumeBatch(
|
|
862
844
|
input: StartBatchInput,
|
|
863
845
|
projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
|
|
864
846
|
): Promise<BatchRunReport> {
|
|
865
847
|
let existing = readBatchRunState(input.root, input.batch_id);
|
|
866
|
-
if (!existing) return startBatch(input);
|
|
848
|
+
if (!existing || existing.batch_state === "needs_human") return startBatch(input);
|
|
849
|
+
existing = applyPlanReconfirmation(input, existing);
|
|
867
850
|
try {
|
|
868
851
|
await validatePersistedRun(input, existing);
|
|
869
852
|
} catch (error) {
|
|
@@ -877,11 +860,10 @@ export async function resumeBatch(
|
|
|
877
860
|
}
|
|
878
861
|
if (isTerminalBatchState(existing.batch_state))
|
|
879
862
|
return replayTerminal(input.root, existing);
|
|
880
|
-
if (existing.batch_state === "needs_human") return startBatch(input);
|
|
881
863
|
|
|
882
864
|
// An enrolled-but-unsettled child must reach its own Kernel terminal
|
|
883
|
-
// settlement before anything else
|
|
884
|
-
//
|
|
865
|
+
// settlement before anything else: its claim is already held and the
|
|
866
|
+
// Kernel owns its remaining obligations.
|
|
885
867
|
// review-1(2nd round): an `enrolled` child is verified against a fresh
|
|
886
868
|
// Kernel projection; a projection that finds no active claim means the
|
|
887
869
|
// persisted `enrolled` flag predates a successful enrollment, so the child
|
|
@@ -898,10 +880,11 @@ export async function resumeBatch(
|
|
|
898
880
|
// defers to driveInterruptedChild's lookupBatchCommit verification,
|
|
899
881
|
// which adopts a verified own commit (crash before committed-persist)
|
|
900
882
|
// instead of misjudging it as external drift.
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
883
|
+
const drivenLineage = reconcileLineage(input.root, existing);
|
|
884
|
+
existing = drivenLineage.record;
|
|
885
|
+
if (drivenLineage.failure) {
|
|
886
|
+
const failed = failPersistedLineage(input.root, existing, drivenLineage.failure);
|
|
887
|
+
return finalize(input.root, failed, drivenLineage.failure, "");
|
|
905
888
|
}
|
|
906
889
|
const fresh = requireFreshProjection(
|
|
907
890
|
await input.kernel.projectTask(input.root, driven.task_id),
|
|
@@ -1032,9 +1015,7 @@ async function driveInterruptedChild(
|
|
|
1032
1015
|
record.batch_state = "running";
|
|
1033
1016
|
persist();
|
|
1034
1017
|
}
|
|
1035
|
-
let head = record
|
|
1036
|
-
? record.commits[record.commits.length - 1]!
|
|
1037
|
-
: record.base_head;
|
|
1018
|
+
let head = expectedBatchHead(record);
|
|
1038
1019
|
while (child.state === "enrolled" || child.state === "settled") {
|
|
1039
1020
|
if (child.state === "settled") {
|
|
1040
1021
|
// review-2(2nd round): a persisted settled child must not replay
|
|
@@ -1085,6 +1066,13 @@ async function driveInterruptedChild(
|
|
|
1085
1066
|
persist();
|
|
1086
1067
|
throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
|
|
1087
1068
|
}
|
|
1069
|
+
if (!existing) {
|
|
1070
|
+
// No own commit exists yet: a fast-forward since settlement is the
|
|
1071
|
+
// user's work. Adopt it so this child's commit parents on it; a broken
|
|
1072
|
+
// lineage is left for commitChild to refuse with its own message.
|
|
1073
|
+
record = reconcileLineage(input.root, record).record;
|
|
1074
|
+
head = expectedBatchHead(record);
|
|
1075
|
+
}
|
|
1088
1076
|
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
1089
1077
|
const intentPath = planChild?.intent_path ?? undefined;
|
|
1090
1078
|
const doCommit = async () => {
|
|
@@ -1103,6 +1091,8 @@ async function driveInterruptedChild(
|
|
|
1103
1091
|
intentPath,
|
|
1104
1092
|
});
|
|
1105
1093
|
};
|
|
1094
|
+
if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
|
|
1095
|
+
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
1106
1096
|
const adopted =
|
|
1107
1097
|
existing ??
|
|
1108
1098
|
(await doCommit().catch((error: unknown) => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, renameSync, lstatSync, constants, rmSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { readSecureProjectFile, withKernelStoreLock } from "../kernel/storage";
|
|
7
|
+
import { readSecureProjectFile, readSecureProjectBytes, withKernelStoreLock } from "../kernel/storage";
|
|
8
8
|
import type { BatchPlanChild } from "./types";
|
|
9
9
|
|
|
10
10
|
export type BatchRunState =
|
|
@@ -45,11 +45,8 @@ export interface BatchRunStateRecord {
|
|
|
45
45
|
branch?: string;
|
|
46
46
|
/** Timestamp of the literal-user batch confirmation. */
|
|
47
47
|
confirmation_time: string;
|
|
48
|
-
/** Authorization expiry from the batch capability binding. */
|
|
49
|
-
authorization_expires_at: string;
|
|
50
48
|
budget: {
|
|
51
49
|
max_children: number;
|
|
52
|
-
deadline_at: string;
|
|
53
50
|
qa_failure_limit: number;
|
|
54
51
|
};
|
|
55
52
|
batch_state: BatchRunState;
|
|
@@ -58,6 +55,11 @@ export interface BatchRunStateRecord {
|
|
|
58
55
|
consecutive_qa_failures: number;
|
|
59
56
|
/** Commit heads produced by this batch, in child order. */
|
|
60
57
|
commits: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Fast-forward commits the user made on the batch branch, adopted as the new
|
|
60
|
+
* expected head. Never attributed to a child and never rewritten.
|
|
61
|
+
*/
|
|
62
|
+
adopted_heads?: Array<{ from: string; to: string }>;
|
|
61
63
|
created_at: string;
|
|
62
64
|
updated_at: string;
|
|
63
65
|
}
|
|
@@ -101,8 +103,19 @@ function statePath(batchId: string): string {
|
|
|
101
103
|
return join(".imm", "state", "batches", `${batchId}.json`);
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A record persisted while batch authorization still carried a clock holds
|
|
108
|
+
* `authorization_expires_at` and `budget.deadline_at`. They are ignored on read
|
|
109
|
+
* and never written back.
|
|
110
|
+
*/
|
|
111
|
+
function withoutRetiredClock(record: BatchRunStateRecord): BatchRunStateRecord {
|
|
112
|
+
const { authorization_expires_at: _expiry, ...rest } = record as BatchRunStateRecord & { authorization_expires_at?: unknown };
|
|
113
|
+
const { deadline_at: _deadline, ...budget } = rest.budget as BatchRunStateRecord["budget"] & { deadline_at?: unknown };
|
|
114
|
+
return { ...rest, budget };
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
function canonicalBytes(record: BatchRunStateRecord): string {
|
|
105
|
-
return `${JSON.stringify(record, null, 2)}\n`;
|
|
118
|
+
return `${JSON.stringify(withoutRetiredClock(record), null, 2)}\n`;
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
function validateRecordShape(value: unknown, batchId: string): asserts value is BatchRunStateRecord {
|
|
@@ -121,8 +134,6 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
|
|
|
121
134
|
throw new Error(`batch run state ${batchId} has an invalid branch`);
|
|
122
135
|
if (!isCanonicalTimestamp(record.confirmation_time))
|
|
123
136
|
throw new Error(`batch run state ${batchId} has an invalid confirmation_time`);
|
|
124
|
-
if (!isCanonicalTimestamp(record.authorization_expires_at))
|
|
125
|
-
throw new Error(`batch run state ${batchId} has an invalid authorization_expires_at`);
|
|
126
137
|
if (!isCanonicalTimestamp(record.created_at) || !isCanonicalTimestamp(record.updated_at))
|
|
127
138
|
throw new Error(`batch run state ${batchId} has invalid state timestamps`);
|
|
128
139
|
if (!Array.isArray(record.children) || record.children.length === 0)
|
|
@@ -142,7 +153,6 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
|
|
|
142
153
|
typeof budget.max_children !== "number" ||
|
|
143
154
|
!Number.isInteger(budget.max_children) ||
|
|
144
155
|
budget.max_children <= 0 ||
|
|
145
|
-
!isCanonicalTimestamp(budget.deadline_at) ||
|
|
146
156
|
typeof budget.qa_failure_limit !== "number" ||
|
|
147
157
|
!Number.isInteger(budget.qa_failure_limit) ||
|
|
148
158
|
budget.qa_failure_limit <= 0
|
|
@@ -150,6 +160,20 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
|
|
|
150
160
|
throw new Error(`batch run state ${batchId} has an invalid budget`);
|
|
151
161
|
if (!Array.isArray(record.commits) || record.commits.some((c) => typeof c !== "string"))
|
|
152
162
|
throw new Error(`batch run state ${batchId} has an invalid commits list`);
|
|
163
|
+
if (
|
|
164
|
+
record.adopted_heads !== undefined &&
|
|
165
|
+
(!Array.isArray(record.adopted_heads) ||
|
|
166
|
+
record.adopted_heads.some(
|
|
167
|
+
(a: unknown) =>
|
|
168
|
+
typeof a !== "object" ||
|
|
169
|
+
a === null ||
|
|
170
|
+
typeof (a as { from?: unknown }).from !== "string" ||
|
|
171
|
+
!(a as { from: string }).from ||
|
|
172
|
+
typeof (a as { to?: unknown }).to !== "string" ||
|
|
173
|
+
!(a as { to: string }).to,
|
|
174
|
+
))
|
|
175
|
+
)
|
|
176
|
+
throw new Error(`batch run state ${batchId} has an invalid adopted_heads list`);
|
|
153
177
|
const seenTaskIds = new Set<string>();
|
|
154
178
|
for (const child of record.children) {
|
|
155
179
|
if (
|
|
@@ -211,8 +235,7 @@ export function prepareBatchRunState(input: {
|
|
|
211
235
|
base_head: string;
|
|
212
236
|
branch?: string;
|
|
213
237
|
confirmation_time: string;
|
|
214
|
-
|
|
215
|
-
budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
|
|
238
|
+
budget: { max_children: number; qa_failure_limit: number };
|
|
216
239
|
now: string;
|
|
217
240
|
}): BatchRunStateRecord {
|
|
218
241
|
validateBatchId(input.batch_id);
|
|
@@ -224,7 +247,6 @@ export function prepareBatchRunState(input: {
|
|
|
224
247
|
base_head: input.base_head,
|
|
225
248
|
branch: input.branch ?? `imm/${input.initiative_slug}`,
|
|
226
249
|
confirmation_time: input.confirmation_time,
|
|
227
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
228
250
|
budget: input.budget,
|
|
229
251
|
batch_state: "prepared",
|
|
230
252
|
children: input.children.map((child) => ({
|
|
@@ -243,12 +265,18 @@ export function prepareBatchRunState(input: {
|
|
|
243
265
|
return prepared;
|
|
244
266
|
}
|
|
245
267
|
|
|
268
|
+
/** Validate a captured snapshot without reading or writing filesystem state. */
|
|
269
|
+
export function parseBatchRunState(raw: string, batchId: string): BatchRunStateRecord {
|
|
270
|
+
validateBatchId(batchId);
|
|
271
|
+
const parsed: unknown = JSON.parse(raw);
|
|
272
|
+
validateRecordShape(parsed, batchId);
|
|
273
|
+
return withoutRetiredClock(parsed);
|
|
274
|
+
}
|
|
275
|
+
|
|
246
276
|
export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
|
|
247
277
|
const path = statePath(batchId);
|
|
248
278
|
if (!existsSync(join(root, path))) return null;
|
|
249
|
-
|
|
250
|
-
validateRecordShape(parsed, batchId);
|
|
251
|
-
return parsed;
|
|
279
|
+
return parseBatchRunState(readSecureProjectFile(root, path), batchId);
|
|
252
280
|
}
|
|
253
281
|
|
|
254
282
|
function ensureSecureDirectory(root: string, relative: string): string {
|
|
@@ -287,6 +315,19 @@ function writeFileAtomically(root: string, relative: string, bytes: string): voi
|
|
|
287
315
|
}
|
|
288
316
|
}
|
|
289
317
|
|
|
318
|
+
/** Replace an existing authorization only after a locked expected-byte check. */
|
|
319
|
+
export function replaceBatchRunState(root: string, expected: Buffer, next: BatchRunStateRecord, validate: () => void): BatchRunStateRecord {
|
|
320
|
+
const path = statePath(next.batch_id);
|
|
321
|
+
validateRecordShape(next, next.batch_id);
|
|
322
|
+
return withKernelStoreLock(root, () => {
|
|
323
|
+
if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
|
|
324
|
+
validate();
|
|
325
|
+
const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
|
|
326
|
+
writeFileAtomically(root, path, canonicalBytes(stored));
|
|
327
|
+
return stored;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
290
331
|
export function writeBatchRunState(
|
|
291
332
|
root: string,
|
|
292
333
|
record: BatchRunStateRecord,
|
|
@@ -296,10 +337,10 @@ export function writeBatchRunState(
|
|
|
296
337
|
return withKernelStoreLock(root, () => {
|
|
297
338
|
const existing = existsSync(join(root, path)) ? readSecureProjectFile(root, path) : null;
|
|
298
339
|
if (existing !== null && existing === canonicalBytes(record)) return record;
|
|
299
|
-
const stored
|
|
340
|
+
const stored = withoutRetiredClock({
|
|
300
341
|
...record,
|
|
301
342
|
updated_at: new Date().toISOString(),
|
|
302
|
-
};
|
|
343
|
+
});
|
|
303
344
|
// review-4: secure directory + atomic no-symlink write.
|
|
304
345
|
ensureSecureDirectory(root, join(".imm", "state", "batches"));
|
|
305
346
|
writeFileAtomically(root, path, canonicalBytes(stored));
|
|
@@ -2,13 +2,11 @@ import type { GithubInitiativeObservation } from "../github_issue_tracker";
|
|
|
2
2
|
|
|
3
3
|
export interface BatchPlanBudget {
|
|
4
4
|
max_children: number;
|
|
5
|
-
deadline_at: string;
|
|
6
5
|
qa_failure_limit: number;
|
|
7
6
|
}
|
|
8
7
|
|
|
9
8
|
export interface BatchPlanBudgetInput {
|
|
10
9
|
max_children?: number;
|
|
11
|
-
deadline_at?: string;
|
|
12
10
|
qa_failure_limit?: number;
|
|
13
11
|
}
|
|
14
12
|
|