immune-brain 4.5.0 → 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/claude/mcp-server.mjs +679 -762
- package/plugins/immune-brain/dist/imm-loop.md +11 -4
- package/plugins/immune-brain/dist/imm-planner.md +37 -12
- 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 +738 -677
- 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/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/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 +120 -31
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +6 -7
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +72 -119
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +44 -8
- 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,
|
|
@@ -31,6 +30,7 @@ import {
|
|
|
31
30
|
commitBatchChild,
|
|
32
31
|
lookupBatchCommit,
|
|
33
32
|
} from "./batch_git";
|
|
33
|
+
import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
|
|
34
34
|
import {
|
|
35
35
|
type BatchRunStateRecord,
|
|
36
36
|
type BatchChildRun,
|
|
@@ -92,15 +92,15 @@ export interface BatchRunnerKernelPort {
|
|
|
92
92
|
* is held under this batch's derived capability (consumed child slot). */
|
|
93
93
|
ownsTaskClaim(taskId: string): boolean;
|
|
94
94
|
/** review-8(4th rework): Kernel-side proof that a replacement parked-batch
|
|
95
|
-
* authorization is genuine
|
|
95
|
+
* authorization is genuine and bound to this exact batch_id,
|
|
96
96
|
* plan_digest, and base_head, validated against the Kernel's authoritative
|
|
97
|
-
* binding state. Throws on fabrication
|
|
97
|
+
* binding state. Throws on fabrication or mismatch; the driver
|
|
98
98
|
* must call this before accepting a parked-batch resume. */
|
|
99
99
|
validateBatchAuthorization(input: {
|
|
100
100
|
registry: BatchAuthorityRegistry;
|
|
101
101
|
capability: object;
|
|
102
102
|
binding: Pick<ValidatedBatchAuthorization,
|
|
103
|
-
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget"
|
|
103
|
+
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget">;
|
|
104
104
|
}): ValidatedBatchAuthorization;
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -123,8 +123,7 @@ export interface StartBatchInput {
|
|
|
123
123
|
plan_digest: string;
|
|
124
124
|
base_head: string;
|
|
125
125
|
confirmation_time: string;
|
|
126
|
-
|
|
127
|
-
budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
|
|
126
|
+
budget: { max_children: number; qa_failure_limit: number };
|
|
128
127
|
now: string;
|
|
129
128
|
kernel: BatchRunnerKernelPort;
|
|
130
129
|
git?: BatchRunnerGitPort;
|
|
@@ -155,7 +154,7 @@ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: str
|
|
|
155
154
|
);
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
function budgetStopReason(record: BatchRunStateRecord
|
|
157
|
+
function budgetStopReason(record: BatchRunStateRecord): string | null {
|
|
159
158
|
// review-3: budget counts enrollments consumed (children that left
|
|
160
159
|
// pending), not commits, so parked children still consume authorization.
|
|
161
160
|
const enrolledCount = record.children.filter(
|
|
@@ -163,22 +162,9 @@ function budgetStopReason(record: BatchRunStateRecord, now: number): string | nu
|
|
|
163
162
|
).length;
|
|
164
163
|
if (enrolledCount >= record.budget.max_children)
|
|
165
164
|
return `max_children budget exhausted (${record.budget.max_children})`;
|
|
166
|
-
const deadline = Date.parse(record.budget.deadline_at);
|
|
167
|
-
if (!Number.isNaN(deadline) && now >= deadline)
|
|
168
|
-
return `deadline_at reached (${record.budget.deadline_at})`;
|
|
169
165
|
return null;
|
|
170
166
|
}
|
|
171
167
|
|
|
172
|
-
/** review-7(3rd rework): the typed expiry marker now lives at the Kernel
|
|
173
|
-
* boundary (kernel/batch_authority.ts) so a real enrollment-time expiry is
|
|
174
|
-
* structurally classifiable as an intentional budget stop; re-exported here
|
|
175
|
-
* for driver API compatibility. */
|
|
176
|
-
export { BatchAuthorizationExpiryError };
|
|
177
|
-
|
|
178
|
-
function isAuthorizationExpiryError(error: unknown): boolean {
|
|
179
|
-
return error instanceof BatchAuthorizationExpiryError;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
168
|
function requireFreshProjection<T extends AssuranceProjectionResult>(
|
|
183
169
|
result: T,
|
|
184
170
|
taskId: string,
|
|
@@ -203,7 +189,7 @@ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null
|
|
|
203
189
|
|
|
204
190
|
const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
205
191
|
completed: "The batch settled every enrollable child; review the commits and the tracker.",
|
|
206
|
-
budget_stopped: "
|
|
192
|
+
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
207
193
|
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
208
194
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
209
195
|
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
@@ -298,35 +284,47 @@ function failPersistedLineage(
|
|
|
298
284
|
return writeBatchRunState(root, record);
|
|
299
285
|
}
|
|
300
286
|
|
|
301
|
-
/**
|
|
302
|
-
*
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
encoding: "utf8",
|
|
317
|
-
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,
|
|
318
302
|
});
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
+
};
|
|
324
312
|
}
|
|
325
313
|
|
|
326
314
|
async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
|
|
327
315
|
if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
|
|
328
316
|
const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
329
|
-
|
|
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()))
|
|
330
328
|
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
331
329
|
}
|
|
332
330
|
const plan = input.registry.children(input.capability);
|
|
@@ -401,7 +399,6 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
401
399
|
base_head: existing?.base_head ?? input.base_head,
|
|
402
400
|
initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
|
|
403
401
|
budget: input.budget,
|
|
404
|
-
expires_at: input.authorization_expires_at,
|
|
405
402
|
},
|
|
406
403
|
});
|
|
407
404
|
if (authorized.issued_at !== input.confirmation_time ||
|
|
@@ -422,7 +419,7 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
422
419
|
function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
|
|
423
420
|
if (existing.plan_digest === input.plan_digest) return existing;
|
|
424
421
|
const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
|
|
425
|
-
|
|
422
|
+
budget: input.budget };
|
|
426
423
|
if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
|
|
427
424
|
input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
|
|
428
425
|
throw new Error("plan_digest mismatch: reconfirmation binding changed");
|
|
@@ -516,12 +513,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
516
513
|
if (existing?.batch_state === "needs_human") {
|
|
517
514
|
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
518
515
|
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
519
|
-
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
520
516
|
const freshAuthorization =
|
|
521
517
|
Number.isFinite(nextConfirmation) &&
|
|
522
|
-
nextConfirmation > priorConfirmation
|
|
523
|
-
Number.isFinite(nextExpiry) &&
|
|
524
|
-
nextExpiry > Date.now();
|
|
518
|
+
nextConfirmation > priorConfirmation;
|
|
525
519
|
if (!freshAuthorization && !reconfirmed) {
|
|
526
520
|
return finalize(
|
|
527
521
|
input.root,
|
|
@@ -573,7 +567,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
573
567
|
existing = writeBatchRunState(input.root, {
|
|
574
568
|
...reparked,
|
|
575
569
|
confirmation_time: input.confirmation_time,
|
|
576
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
577
570
|
budget: input.budget,
|
|
578
571
|
batch_state: "needs_human",
|
|
579
572
|
});
|
|
@@ -592,42 +585,12 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
592
585
|
existing = writeBatchRunState(input.root, {
|
|
593
586
|
...existing,
|
|
594
587
|
confirmation_time: input.confirmation_time,
|
|
595
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
596
588
|
budget: input.budget,
|
|
597
589
|
batch_state: "running",
|
|
598
590
|
children: resumedChildren,
|
|
599
591
|
});
|
|
600
592
|
}
|
|
601
593
|
|
|
602
|
-
// A running batch can outlive its authorization while it waits on a child's
|
|
603
|
-
// reserved foreground Review. The driver owns every batch state transition,
|
|
604
|
-
// so the renewal the Host bound into the capability must be adopted here;
|
|
605
|
-
// otherwise the record keeps the expired stamp and the next child enrollment
|
|
606
|
-
// stops the batch as budget_stopped even though the literal user just
|
|
607
|
-
// re-confirmed it. Renewal requires the same proof the parked path requires:
|
|
608
|
-
// a strictly newer literal-user confirmation and a later, still-valid expiry.
|
|
609
|
-
if (existing?.batch_state === "running") {
|
|
610
|
-
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
611
|
-
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
612
|
-
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
613
|
-
const persistedExpiry = Date.parse(existing.authorization_expires_at);
|
|
614
|
-
const renewedAuthorization =
|
|
615
|
-
Number.isFinite(nextConfirmation) &&
|
|
616
|
-
nextConfirmation > priorConfirmation &&
|
|
617
|
-
Number.isFinite(nextExpiry) &&
|
|
618
|
-
nextExpiry > Date.now() &&
|
|
619
|
-
nextExpiry > persistedExpiry;
|
|
620
|
-
if (renewedAuthorization) {
|
|
621
|
-
validateRunAuthorization(input, existing);
|
|
622
|
-
existing = writeBatchRunState(input.root, {
|
|
623
|
-
...existing,
|
|
624
|
-
confirmation_time: input.confirmation_time,
|
|
625
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
626
|
-
budget: input.budget,
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
594
|
// review-2(6th round): an enrolled or settled child from an interrupted
|
|
632
595
|
// run must be driven to its own terminal settlement and commit before any
|
|
633
596
|
// new child is selected, or startBatch would double-claim the task or
|
|
@@ -693,7 +656,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
693
656
|
plan_digest: input.plan_digest,
|
|
694
657
|
base_head: input.base_head,
|
|
695
658
|
confirmation_time: input.confirmation_time,
|
|
696
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
697
659
|
budget: input.budget,
|
|
698
660
|
now: input.now,
|
|
699
661
|
});
|
|
@@ -707,17 +669,15 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
707
669
|
persist();
|
|
708
670
|
}
|
|
709
671
|
|
|
710
|
-
let head = record.commits.length
|
|
711
|
-
? record.commits[record.commits.length - 1]!
|
|
712
|
-
: record.base_head;
|
|
713
|
-
|
|
714
672
|
// Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
|
|
715
|
-
const
|
|
716
|
-
|
|
673
|
+
const lineage = reconcileLineage(input.root, record);
|
|
674
|
+
record = lineage.record;
|
|
675
|
+
if (lineage.failure) {
|
|
717
676
|
record.batch_state = "failed";
|
|
718
677
|
persist();
|
|
719
|
-
return finalize(input.root, record,
|
|
678
|
+
return finalize(input.root, record, lineage.failure, "");
|
|
720
679
|
}
|
|
680
|
+
let head = expectedBatchHead(record);
|
|
721
681
|
|
|
722
682
|
while (record.batch_state === "running") {
|
|
723
683
|
const child = nextEnrollableChild(record);
|
|
@@ -780,9 +740,8 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
780
740
|
);
|
|
781
741
|
persist();
|
|
782
742
|
} else {
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
|
|
743
|
+
// An adopted claim is not a new enrollment.
|
|
744
|
+
if (budgetStopReason(record)) {
|
|
786
745
|
record.batch_state = record.children.some(
|
|
787
746
|
(c) => c.state === "needs_human" || c.state === "skipped_blocked",
|
|
788
747
|
) ? "needs_human" : "budget_stopped";
|
|
@@ -799,7 +758,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
799
758
|
binding: {
|
|
800
759
|
batch_id: record.batch_id, plan_digest: record.plan_digest,
|
|
801
760
|
base_head: record.base_head, initiative_slug: record.initiative_slug,
|
|
802
|
-
budget: record.budget,
|
|
761
|
+
budget: record.budget,
|
|
803
762
|
},
|
|
804
763
|
});
|
|
805
764
|
for (const committed of record.children) {
|
|
@@ -821,17 +780,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
821
780
|
persist();
|
|
822
781
|
} catch (error) {
|
|
823
782
|
const message = error instanceof Error ? error.message : String(error);
|
|
824
|
-
// Only a typed BatchAuthorizationExpiryError is an intentional
|
|
825
|
-
// budget stop; free-form message matching misclassified
|
|
826
|
-
// infrastructure and validation failures as intentional stops.
|
|
827
|
-
if (isAuthorizationExpiryError(error)) {
|
|
828
|
-
record.children = record.children.map((c) =>
|
|
829
|
-
c.task_id === child.task_id ? { ...c, state: "pending", reason: message } : c,
|
|
830
|
-
);
|
|
831
|
-
record.batch_state = "budget_stopped";
|
|
832
|
-
persist();
|
|
833
|
-
break;
|
|
834
|
-
}
|
|
835
783
|
// review round 10: external HEAD movement after the pre-loop check
|
|
836
784
|
// (including between children) fails the batch, it never parks.
|
|
837
785
|
if (isLineageBreakError(message)) {
|
|
@@ -869,8 +817,8 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
|
869
817
|
switch (record.batch_state) {
|
|
870
818
|
case "budget_stopped":
|
|
871
819
|
return (
|
|
872
|
-
budgetStopReason(record
|
|
873
|
-
"
|
|
820
|
+
budgetStopReason(record) ??
|
|
821
|
+
"the child budget stopped new enrollments"
|
|
874
822
|
);
|
|
875
823
|
case "failed": {
|
|
876
824
|
const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
|
|
@@ -888,10 +836,9 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
|
888
836
|
/**
|
|
889
837
|
* review-1: resume the interrupted child first. A persisted enrolled child
|
|
890
838
|
* is driven through the Kernel obligation surface to its own terminal
|
|
891
|
-
* settlement and commit before any new enrollment
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
* 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.
|
|
895
842
|
*/
|
|
896
843
|
export async function resumeBatch(
|
|
897
844
|
input: StartBatchInput,
|
|
@@ -915,8 +862,8 @@ export async function resumeBatch(
|
|
|
915
862
|
return replayTerminal(input.root, existing);
|
|
916
863
|
|
|
917
864
|
// An enrolled-but-unsettled child must reach its own Kernel terminal
|
|
918
|
-
// settlement before anything else
|
|
919
|
-
//
|
|
865
|
+
// settlement before anything else: its claim is already held and the
|
|
866
|
+
// Kernel owns its remaining obligations.
|
|
920
867
|
// review-1(2nd round): an `enrolled` child is verified against a fresh
|
|
921
868
|
// Kernel projection; a projection that finds no active claim means the
|
|
922
869
|
// persisted `enrolled` flag predates a successful enrollment, so the child
|
|
@@ -933,10 +880,11 @@ export async function resumeBatch(
|
|
|
933
880
|
// defers to driveInterruptedChild's lookupBatchCommit verification,
|
|
934
881
|
// which adopts a verified own commit (crash before committed-persist)
|
|
935
882
|
// instead of misjudging it as external drift.
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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, "");
|
|
940
888
|
}
|
|
941
889
|
const fresh = requireFreshProjection(
|
|
942
890
|
await input.kernel.projectTask(input.root, driven.task_id),
|
|
@@ -1067,9 +1015,7 @@ async function driveInterruptedChild(
|
|
|
1067
1015
|
record.batch_state = "running";
|
|
1068
1016
|
persist();
|
|
1069
1017
|
}
|
|
1070
|
-
let head = record
|
|
1071
|
-
? record.commits[record.commits.length - 1]!
|
|
1072
|
-
: record.base_head;
|
|
1018
|
+
let head = expectedBatchHead(record);
|
|
1073
1019
|
while (child.state === "enrolled" || child.state === "settled") {
|
|
1074
1020
|
if (child.state === "settled") {
|
|
1075
1021
|
// review-2(2nd round): a persisted settled child must not replay
|
|
@@ -1120,6 +1066,13 @@ async function driveInterruptedChild(
|
|
|
1120
1066
|
persist();
|
|
1121
1067
|
throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
|
|
1122
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
|
+
}
|
|
1123
1076
|
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
1124
1077
|
const intentPath = planChild?.intent_path ?? undefined;
|
|
1125
1078
|
const doCommit = async () => {
|
|
@@ -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) => ({
|
|
@@ -248,7 +270,7 @@ export function parseBatchRunState(raw: string, batchId: string): BatchRunStateR
|
|
|
248
270
|
validateBatchId(batchId);
|
|
249
271
|
const parsed: unknown = JSON.parse(raw);
|
|
250
272
|
validateRecordShape(parsed, batchId);
|
|
251
|
-
return parsed;
|
|
273
|
+
return withoutRetiredClock(parsed);
|
|
252
274
|
}
|
|
253
275
|
|
|
254
276
|
export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
|
|
@@ -300,7 +322,7 @@ export function replaceBatchRunState(root: string, expected: Buffer, next: Batch
|
|
|
300
322
|
return withKernelStoreLock(root, () => {
|
|
301
323
|
if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
|
|
302
324
|
validate();
|
|
303
|
-
const stored = { ...next, updated_at: new Date().toISOString() };
|
|
325
|
+
const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
|
|
304
326
|
writeFileAtomically(root, path, canonicalBytes(stored));
|
|
305
327
|
return stored;
|
|
306
328
|
});
|
|
@@ -315,10 +337,10 @@ export function writeBatchRunState(
|
|
|
315
337
|
return withKernelStoreLock(root, () => {
|
|
316
338
|
const existing = existsSync(join(root, path)) ? readSecureProjectFile(root, path) : null;
|
|
317
339
|
if (existing !== null && existing === canonicalBytes(record)) return record;
|
|
318
|
-
const stored
|
|
340
|
+
const stored = withoutRetiredClock({
|
|
319
341
|
...record,
|
|
320
342
|
updated_at: new Date().toISOString(),
|
|
321
|
-
};
|
|
343
|
+
});
|
|
322
344
|
// review-4: secure directory + atomic no-symlink write.
|
|
323
345
|
ensureSecureDirectory(root, join(".imm", "state", "batches"));
|
|
324
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
|
|
|
@@ -137,7 +137,9 @@ function fileFingerprint(root: string, relativePath: string): string {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
function dirtyPaths(root: string): string[] | null {
|
|
140
|
-
|
|
140
|
+
// --no-renames, like the task-path derivations: rename detection would list
|
|
141
|
+
// only the destination and hide the source path's deletion.
|
|
142
|
+
const tracked = git(root, ["diff", "--name-only", "--no-renames", "-z", "HEAD", "--"]);
|
|
141
143
|
const untracked = git(root, [
|
|
142
144
|
"ls-files",
|
|
143
145
|
"--others",
|
|
@@ -390,6 +392,44 @@ function taskPathMatchesScope(path: string, scope: string[]): boolean {
|
|
|
390
392
|
return scope.some((scopePath) => pathMatchesScope(path, scopePath));
|
|
391
393
|
}
|
|
392
394
|
|
|
395
|
+
/**
|
|
396
|
+
* A staged in-scope path that still equals the Enrollment baseline predates the
|
|
397
|
+
* task. Dropping it silently let QA attest workspace bytes that the Review
|
|
398
|
+
* revision did not carry, so it is a hard stop instead: reverting and reapplying
|
|
399
|
+
* yields the same bytes, and only a fresh Enrollment from a clean scope recovers.
|
|
400
|
+
* The task's own planning sidecar is exempt, as it is in dirtyScopePaths:
|
|
401
|
+
* Enrollment requires it tracked, so it is routinely staged in the baseline.
|
|
402
|
+
*/
|
|
403
|
+
function assertNoPreEnrollmentScopeChanges(root: string, scopedStagedPaths: readonly string[], taskId?: string): void {
|
|
404
|
+
const baseline = readEnrollmentBaseline(root);
|
|
405
|
+
if (!baseline) return;
|
|
406
|
+
const current = captureGitWorkspaceSnapshot(root);
|
|
407
|
+
if (!current) throw new Error("enrollment baseline cannot be compared because Git is unavailable");
|
|
408
|
+
const withheld = [...new Set(scopedStagedPaths)]
|
|
409
|
+
.filter((path) => !isOwnPlanningSidecar(path, taskId))
|
|
410
|
+
.filter((path) => baseline.dirty_files[path] !== undefined && baseline.dirty_files[path] === current.dirty_files[path])
|
|
411
|
+
.sort(comparePaths);
|
|
412
|
+
if (withheld.length > 0)
|
|
413
|
+
throw new Error(
|
|
414
|
+
`task scope contains staged changes that predate Enrollment and cannot become task work: ${withheld.join(", ")}; stop the task and re-enroll from a clean scope`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* In-scope paths that are already staged, modified or untracked relative to
|
|
420
|
+
* HEAD. Enrollment refuses these: the baseline would record them as user dirt
|
|
421
|
+
* and they could never be delivered by the task.
|
|
422
|
+
*/
|
|
423
|
+
export function dirtyScopePaths(projectRoot: string, scopeHint: unknown, taskId?: string): string[] {
|
|
424
|
+
const root = realpathSync(resolve(projectRoot));
|
|
425
|
+
const scope = assertCanonicalTaskScope(scopeHint);
|
|
426
|
+
const paths = dirtyPaths(root);
|
|
427
|
+
if (!paths) throw new Error("cannot inspect the task scope outside a committed Git workspace");
|
|
428
|
+
return paths.filter(
|
|
429
|
+
(path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && taskPathMatchesScope(path, scope),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
393
433
|
function taskSnapshotOnce(root: string, scope: string[], taskId?: string): GitTaskSnapshot {
|
|
394
434
|
const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim();
|
|
395
435
|
const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
|
|
@@ -425,12 +465,10 @@ function taskSnapshotOnce(root: string, scope: string[], taskId?: string): GitTa
|
|
|
425
465
|
if (uncommittedInScope.length > 0)
|
|
426
466
|
throw new Error(`task scope contains unstaged or untracked changes: ${uncommittedInScope.join(", ")}`);
|
|
427
467
|
|
|
428
|
-
const baseline = readEnrollmentBaseline(root);
|
|
429
|
-
const current = baseline ? captureGitWorkspaceSnapshot(root) : null;
|
|
430
468
|
const taskPaths = [...new Set(stagedPaths)]
|
|
431
469
|
.filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope))
|
|
432
|
-
.filter((path) => !(baseline && current && baseline.dirty_files[path] === current.dirty_files[path] && baseline.dirty_files[path] !== undefined))
|
|
433
470
|
.sort(comparePaths);
|
|
471
|
+
assertNoPreEnrollmentScopeChanges(root, taskPaths, taskId);
|
|
434
472
|
const stagedFiles: Record<string, GitTaskIndexEntry> = {};
|
|
435
473
|
for (const path of taskPaths) {
|
|
436
474
|
const current = indexEntry(root, path);
|
|
@@ -559,10 +597,8 @@ function taskRevisionSnapshotOnce(
|
|
|
559
597
|
"untracked task revision paths",
|
|
560
598
|
);
|
|
561
599
|
assertNoEnvelopeEscape(root, stagedPaths, scope, taskId);
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
const scopedStagedPaths = stagedPaths.filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope))
|
|
565
|
-
.filter((path) => !(baseline && current && baseline.dirty_files[path] === current.dirty_files[path] && baseline.dirty_files[path] !== undefined));
|
|
600
|
+
const scopedStagedPaths = stagedPaths.filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope));
|
|
601
|
+
assertNoPreEnrollmentScopeChanges(root, scopedStagedPaths, taskId);
|
|
566
602
|
const scopedUnstagedPaths = unstagedPaths.filter((path) => taskPathMatchesScope(path, scope));
|
|
567
603
|
const scopedUntrackedPaths = untrackedPaths.filter((path) => taskPathMatchesScope(path, scope));
|
|
568
604
|
assertNoCaseFoldCollisions(
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// A native confirmation is bounded on both Hosts. Claude bounded its MCP
|
|
2
|
-
// elicitation with IMMUNE_BRAIN_BATCH_TIMEOUT_MS and Pi waited indefinitely; the
|
|
3
|
-
// bound, its default, and the "this deadline expired rather than the user
|
|
4
|
-
// cancelling" distinction are shared here. Each Host still owns its transport:
|
|
5
|
-
// this module only produces the signal to hand it and reports expiry.
|
|
6
|
-
|
|
7
|
-
export const CONFIRMATION_TIMEOUT_DEFAULT_MS = 60_000;
|
|
8
|
-
export const CONFIRMATION_TIMEOUT_ENV = "IMMUNE_BRAIN_BATCH_TIMEOUT_MS";
|
|
9
|
-
|
|
10
|
-
export interface ConfirmationDeadline {
|
|
11
|
-
/** The caller's signal, combined with this deadline. */
|
|
12
|
-
signal: AbortSignal;
|
|
13
|
-
/** True when this deadline fired and the caller's own signal did not. */
|
|
14
|
-
timedOut(): boolean;
|
|
15
|
-
clear(): void;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function startConfirmationDeadline(input: {
|
|
19
|
-
env?: Record<string, string | undefined>;
|
|
20
|
-
signal?: AbortSignal;
|
|
21
|
-
}): ConfirmationDeadline {
|
|
22
|
-
const configured = Number(input.env?.[CONFIRMATION_TIMEOUT_ENV]);
|
|
23
|
-
const timeoutMs = Number.isFinite(configured) && configured > 0 ? configured : CONFIRMATION_TIMEOUT_DEFAULT_MS;
|
|
24
|
-
const controller = new AbortController();
|
|
25
|
-
const timer = setTimeout(() => {
|
|
26
|
-
controller.abort(new Error("native confirmation timed out waiting for user interaction"));
|
|
27
|
-
}, timeoutMs);
|
|
28
|
-
return {
|
|
29
|
-
signal: input.signal ? AbortSignal.any([input.signal, controller.signal]) : controller.signal,
|
|
30
|
-
timedOut: () => controller.signal.aborted && !input.signal?.aborted,
|
|
31
|
-
clear: () => clearTimeout(timer),
|
|
32
|
-
};
|
|
33
|
-
}
|