immune-brain 5.1.0 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +17 -5
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +165 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +542 -79
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
- package/plugins/immune-brain/dist/imm-planner.md +4 -1
- package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
- package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +10 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +89 -54
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +76 -25
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
- package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
- package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +10 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +51 -14
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +8 -12
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +2 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +44 -6
|
@@ -279,12 +279,15 @@ function releaseHandoffs(
|
|
|
279
279
|
record: BatchLaneRunStateRecord,
|
|
280
280
|
lanes: BatchLaneGitPort,
|
|
281
281
|
): BatchLaneHandoff[] {
|
|
282
|
+
const lineage = classifyBatchLineage({ root: input.root, branch: record.branch ?? "",
|
|
283
|
+
expectedHead: expectedBatchHead(record), childCommits: record.commits, batchId: record.batch_id });
|
|
284
|
+
if (lineage.kind === "broken") return [];
|
|
282
285
|
const handoffs: BatchLaneHandoff[] = [];
|
|
283
286
|
for (const child of record.children) {
|
|
284
287
|
if (child.state !== "integrated" || !child.lane) continue;
|
|
285
288
|
const facts = lanes.inspectLane(input.root, child.lane.path);
|
|
286
|
-
if (!facts.exists || !facts.same_repository || !facts.clean || facts.branch !== child.lane.branch) continue;
|
|
287
|
-
if (!lanes.auditReachable({ root: input.root, head:
|
|
289
|
+
if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || !facts.clean || facts.branch !== child.lane.branch || facts.active_claim_task_id !== null) continue;
|
|
290
|
+
if (!lanes.auditReachable({ root: input.root, head: lineage.head, task_id: child.task_id })) continue;
|
|
288
291
|
handoffs.push({ role: "lane-steward", action: "release", task_id: child.task_id, lane_branch: child.lane.branch });
|
|
289
292
|
}
|
|
290
293
|
return handoffs;
|
|
@@ -342,7 +345,7 @@ function scheduleView(input: StartBatchInput, record: BatchLaneRunStateRecord) {
|
|
|
342
345
|
}));
|
|
343
346
|
}
|
|
344
347
|
|
|
345
|
-
/**
|
|
348
|
+
/** Recovery preserves the parked run and requires fresh literal-user authorization. */
|
|
346
349
|
const LANE_NEEDS_HUMAN_NEXT_ACTION =
|
|
347
350
|
"The lane batch has stopped on a parked child and its Lane is kept. Resolve the child by hand; continuing needs a new Batch Authorization.";
|
|
348
351
|
|
|
@@ -351,6 +354,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
|
351
354
|
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
352
355
|
failed: "A lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
353
356
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
357
|
+
superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
|
|
354
358
|
needs_human: LANE_NEEDS_HUMAN_NEXT_ACTION,
|
|
355
359
|
running: "The batch is still running; no terminal report is due yet.",
|
|
356
360
|
prepared: "The batch is prepared but not started.",
|
|
@@ -379,8 +383,13 @@ function laneReport(
|
|
|
379
383
|
}
|
|
380
384
|
|
|
381
385
|
/** A store-condition rejection of a persisted lane batch: its persisted lane children, commits and parallelism, never an empty serial plan. */
|
|
382
|
-
export function laneRejectionReport(record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
|
|
383
|
-
|
|
386
|
+
export function laneRejectionReport(input: StartBatchInput, record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
|
|
387
|
+
// A rejected tick grants no execution or admission authority. Only already
|
|
388
|
+
// integrated, unoccupied Lanes with surviving audit evidence may be released.
|
|
389
|
+
const handoffs = releaseHandoffs(input, record, laneGitOf(input));
|
|
390
|
+
return laneReport({ ...record, batch_state: "rejected" }, reason,
|
|
391
|
+
handoffs.length ? `${nextAction}. Check each release handoff with the Lane Steward.` : nextAction,
|
|
392
|
+
{ handoffs });
|
|
384
393
|
}
|
|
385
394
|
|
|
386
395
|
function finalizeLane(
|
|
@@ -535,13 +544,31 @@ export async function runLaneBatch(
|
|
|
535
544
|
return finalizeLane(input.root, record, `terminal state already reached: ${record.batch_state}`, "", {
|
|
536
545
|
handoffs: record.batch_state === "completed" ? releaseHandoffs(input, record, lanes) : [],
|
|
537
546
|
});
|
|
538
|
-
if (record.batch_state === "needs_human")
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
record,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
547
|
+
if (record.batch_state === "needs_human") {
|
|
548
|
+
const invalid = validateNewAuthorization(input);
|
|
549
|
+
if (invalid || Date.parse(input.confirmation_time) <= Date.parse(record.confirmation_time))
|
|
550
|
+
return laneReport(record, invalid ?? "the parked batch requires a fresh literal-user confirmation", LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
551
|
+
const resumed: BatchLaneChildRun[] = [];
|
|
552
|
+
for (const child of record.children) {
|
|
553
|
+
if (child.state !== "needs_human") { resumed.push(child); continue; }
|
|
554
|
+
if (!child.lane?.run_id)
|
|
555
|
+
return laneReport(record, "parked Lane has no recorded run identity; inspect it without re-enrolling", LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
556
|
+
const facts = lanes.inspectLane(input.root, child.lane.path);
|
|
557
|
+
const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
|
|
558
|
+
if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || facts.branch !== child.lane.branch ||
|
|
559
|
+
fresh.error !== null || fresh.projection.run_id !== child.lane.run_id ||
|
|
560
|
+
fresh.projection.open_user_decision_count > 0 || fresh.projection.replan_required_ids.length > 0)
|
|
561
|
+
return laneReport(record, "parked Lane identity or decision is unresolved", LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
562
|
+
const state = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready ?
|
|
563
|
+
(child.lane.lane_commit ? "lane_committed" : "settled") :
|
|
564
|
+
fresh.projection.lifecycle === "active" && fresh.claim?.task_id === child.task_id &&
|
|
565
|
+
(facts.active_claim_task_id === null || facts.active_claim_task_id === child.task_id) ? "enrolled" : null;
|
|
566
|
+
if (!state) return laneReport(record, "parked child has no recoverable owned run", LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
567
|
+
resumed.push({ ...child, state, reason: null, qa_failures: 0 });
|
|
568
|
+
}
|
|
569
|
+
record = { ...record, batch_state: "running", confirmation_time: input.confirmation_time,
|
|
570
|
+
children: resumed.map(child => child.state === "skipped_blocked" ? { ...child, state: "pending", reason: null } : child) };
|
|
571
|
+
}
|
|
545
572
|
} else {
|
|
546
573
|
const invalid = validateNewAuthorization(input);
|
|
547
574
|
if (invalid) return refuse(input, null, "batch_authorization_invalid", invalid);
|
|
@@ -567,6 +594,8 @@ export async function runLaneBatch(
|
|
|
567
594
|
const persist = (): void => {
|
|
568
595
|
record = writeBatchLaneRunState(input.root, record);
|
|
569
596
|
};
|
|
597
|
+
// A resumed park is persisted only after the existing lineage validation below.
|
|
598
|
+
const resumedPark = persisted?.batch_state === "needs_human" && record.batch_state === "running";
|
|
570
599
|
if (record.batch_state === "prepared") {
|
|
571
600
|
record.batch_state = "running";
|
|
572
601
|
persist();
|
|
@@ -622,6 +651,7 @@ export async function runLaneBatch(
|
|
|
622
651
|
return failLineage(input.root, record, error instanceof Error ? error.message : String(error));
|
|
623
652
|
}
|
|
624
653
|
|
|
654
|
+
if (resumedPark) persist();
|
|
625
655
|
const refusals: NonNullable<BatchLaneRunReport["lane_refusals"]> = [];
|
|
626
656
|
// A parked child ends only itself and its dependents; the batch keeps moving
|
|
627
657
|
// for every scope-disjoint sibling and settles needs_human at the end of the
|
|
@@ -641,7 +671,10 @@ export async function runLaneBatch(
|
|
|
641
671
|
task_id: taskId,
|
|
642
672
|
batch: { registry: input.registry, capability: input.capability, binding: { batch_id: record.batch_id, expected_head: lane.base_head } },
|
|
643
673
|
});
|
|
644
|
-
|
|
674
|
+
const fresh = await input.kernel.projectTask(lane.path, taskId);
|
|
675
|
+
if (fresh.error !== null || !fresh.projection.run_id || fresh.claim?.task_id !== taskId)
|
|
676
|
+
throw new Error("enrolled Lane has no observable owned run");
|
|
677
|
+
record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null, lane: { ...lane, run_id: fresh.projection.run_id } } : c));
|
|
645
678
|
persist();
|
|
646
679
|
return null;
|
|
647
680
|
} catch (error) {
|
|
@@ -669,12 +702,16 @@ export async function runLaneBatch(
|
|
|
669
702
|
parkChild(child.task_id, "batch_lane_lost");
|
|
670
703
|
continue;
|
|
671
704
|
}
|
|
705
|
+
if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
|
|
706
|
+
parkChild(child.task_id, "batch_lane_run_mismatch");
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
672
709
|
const holdsClaim = fresh.claim !== null && fresh.claim.task_id === child.task_id;
|
|
673
710
|
if (child.state === "lane_admitted") {
|
|
674
711
|
// A crash between admission and enrollment adopts an existing claim or enrolls now.
|
|
675
712
|
if (holdsClaim) {
|
|
676
713
|
record.children = record.children.map((c) =>
|
|
677
|
-
c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption" } : c,
|
|
714
|
+
c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption", lane: { ...child.lane!, run_id: fresh.projection.run_id } } : c,
|
|
678
715
|
);
|
|
679
716
|
persist();
|
|
680
717
|
} else {
|
|
@@ -26,7 +26,7 @@ import { pathMatchesScope } from "../workspace_scope";
|
|
|
26
26
|
import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
|
|
27
27
|
import { projectBatchPlan } from "./batch_plan";
|
|
28
28
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
29
|
-
import { isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
|
|
29
|
+
import { BATCH_RUN_STATES, isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
|
|
30
30
|
import type {
|
|
31
31
|
BatchPlanBudget,
|
|
32
32
|
BatchPlanChild,
|
|
@@ -126,7 +126,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
|
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
128
|
* The batch record for this initiative, or a corruption marker. A terminal
|
|
129
|
-
* record (completed, budget_stopped, failed, rejected) is not active: returning
|
|
129
|
+
* record (completed, budget_stopped, failed, rejected, superseded) is not active: returning
|
|
130
130
|
* it would let a settled batch block or be resumed by a later run.
|
|
131
131
|
*/
|
|
132
132
|
export type BatchRecordLookup =
|
|
@@ -150,15 +150,9 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
150
150
|
const candidate = record as AnyBatchRunStateRecord;
|
|
151
151
|
if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
|
|
152
152
|
if (candidate.initiative_slug !== initiativeSlug) continue;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"needs_human",
|
|
157
|
-
"completed",
|
|
158
|
-
"budget_stopped",
|
|
159
|
-
"failed",
|
|
160
|
-
"rejected",
|
|
161
|
-
]);
|
|
153
|
+
// Every state the state owner accepts, so a retired record reads as settled
|
|
154
|
+
// instead of failing this lookup closed as an unknown state.
|
|
155
|
+
const validStates = new Set<string>(BATCH_RUN_STATES);
|
|
162
156
|
if (
|
|
163
157
|
typeof candidate.batch_id !== "string" ||
|
|
164
158
|
typeof candidate.base_head !== "string" ||
|
|
@@ -659,7 +653,9 @@ export async function projectBatchPreflight(
|
|
|
659
653
|
let reconfirmation: ReconfirmationSnapshot | undefined;
|
|
660
654
|
if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
661
655
|
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
662
|
-
catch { return reject("plan_projection_failed",
|
|
656
|
+
catch { return reject("plan_projection_failed", activeRecord.commits.length
|
|
657
|
+
? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child that is still mid-flight must settle first. This rejection neither terminates the old batch nor authorizes the revised plan.`
|
|
658
|
+
: "batch plan reconfirmation is not eligible"); }
|
|
663
659
|
}
|
|
664
660
|
|
|
665
661
|
const projection: BatchPreflightProjection = {
|
|
@@ -34,7 +34,9 @@ export type BatchReasonKey =
|
|
|
34
34
|
| "repository_became_unreadable"
|
|
35
35
|
| "head_moved"
|
|
36
36
|
| "cancelled_before_execution"
|
|
37
|
-
| "batch_run_rejected"
|
|
37
|
+
| "batch_run_rejected"
|
|
38
|
+
| "stale_batch_absent"
|
|
39
|
+
| "stale_batch_in_flight";
|
|
38
40
|
|
|
39
41
|
export interface BatchReasonSpec {
|
|
40
42
|
readonly state: "rejected" | "cancelled" | "blocked";
|
|
@@ -170,6 +172,16 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
170
172
|
reason: (detail: string) => detail || "batch run rejected",
|
|
171
173
|
recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
|
|
172
174
|
},
|
|
175
|
+
stale_batch_absent: {
|
|
176
|
+
state: "rejected",
|
|
177
|
+
reason: (detail: string) => `no retirable batch record to dispose: ${detail}`,
|
|
178
|
+
recovery_action: "start a batch for this Initiative, or inspect the batch records under .imm/state/batches in the current Host",
|
|
179
|
+
},
|
|
180
|
+
stale_batch_in_flight: {
|
|
181
|
+
state: "blocked",
|
|
182
|
+
reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
|
|
183
|
+
recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
|
|
184
|
+
},
|
|
173
185
|
});
|
|
174
186
|
|
|
175
187
|
export interface BatchReason {
|
|
@@ -179,6 +179,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
|
179
179
|
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
180
180
|
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
181
181
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
182
|
+
superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
|
|
182
183
|
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
183
184
|
running: "The batch is still running; no terminal report is due yet.",
|
|
184
185
|
prepared: "The batch is prepared but not started.",
|
|
@@ -449,6 +450,7 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
|
|
|
449
450
|
})();
|
|
450
451
|
if (persisted !== null && isLaneBatchRecord(persisted))
|
|
451
452
|
return laneRejectionReport(
|
|
453
|
+
input,
|
|
452
454
|
persisted,
|
|
453
455
|
reason,
|
|
454
456
|
"settle the reported kernel store condition and retry in the current Host",
|
|
@@ -14,7 +14,14 @@ export type BatchRunState =
|
|
|
14
14
|
| "completed"
|
|
15
15
|
| "budget_stopped"
|
|
16
16
|
| "failed"
|
|
17
|
-
| "rejected"
|
|
17
|
+
| "rejected"
|
|
18
|
+
/**
|
|
19
|
+
* Retired by an explicit literal-user disposition after the batch plan moved
|
|
20
|
+
* past it. Terminal, and not a failure of the batch's own: the record, its
|
|
21
|
+
* children, its commits and its branch stay as evidence, and the retired
|
|
22
|
+
* authorization is never reused for the revised plan.
|
|
23
|
+
*/
|
|
24
|
+
| "superseded";
|
|
18
25
|
|
|
19
26
|
export type BatchChildRunState =
|
|
20
27
|
| "pending"
|
|
@@ -88,6 +95,8 @@ export interface BatchLaneBinding {
|
|
|
88
95
|
base_head: string;
|
|
89
96
|
/** Commit created in the Lane after the Lane Kernel settled the child. */
|
|
90
97
|
lane_commit: string | null;
|
|
98
|
+
/** Kernel run observed at enrollment; absent historical bindings cannot resume a parked child. */
|
|
99
|
+
run_id?: string | null;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export interface BatchLaneChildRun {
|
|
@@ -145,7 +154,7 @@ const CHILD_RUN_STATES: ReadonlySet<string> = new Set([
|
|
|
145
154
|
"skipped_blocked",
|
|
146
155
|
]);
|
|
147
156
|
|
|
148
|
-
const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
|
|
157
|
+
export const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
|
|
149
158
|
"prepared",
|
|
150
159
|
"running",
|
|
151
160
|
"needs_human",
|
|
@@ -153,6 +162,7 @@ const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
|
|
|
153
162
|
"budget_stopped",
|
|
154
163
|
"failed",
|
|
155
164
|
"rejected",
|
|
165
|
+
"superseded",
|
|
156
166
|
]);
|
|
157
167
|
|
|
158
168
|
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
@@ -286,7 +296,8 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
|
|
|
286
296
|
if (
|
|
287
297
|
(record.batch_state === "budget_stopped" ||
|
|
288
298
|
record.batch_state === "failed" ||
|
|
289
|
-
record.batch_state === "rejected"
|
|
299
|
+
record.batch_state === "rejected" ||
|
|
300
|
+
record.batch_state === "superseded") &&
|
|
290
301
|
record.children.some((child) => child.state === "enrolled" || child.state === "settled")
|
|
291
302
|
)
|
|
292
303
|
throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
|
|
@@ -398,6 +409,25 @@ export function replaceBatchRunState(root: string, expected: Buffer, next: Batch
|
|
|
398
409
|
});
|
|
399
410
|
}
|
|
400
411
|
|
|
412
|
+
/** Replace a record of either version only after a locked expected-byte check. */
|
|
413
|
+
export function replaceAnyBatchRunState(
|
|
414
|
+
root: string,
|
|
415
|
+
expected: Buffer,
|
|
416
|
+
next: AnyBatchRunStateRecord,
|
|
417
|
+
validate: () => void,
|
|
418
|
+
): AnyBatchRunStateRecord {
|
|
419
|
+
const path = statePath(next.batch_id);
|
|
420
|
+
if (isLaneBatchRecord(next)) validateLaneRecordShape(next, next.batch_id);
|
|
421
|
+
else validateRecordShape(next, next.batch_id);
|
|
422
|
+
return withKernelStoreLock(root, () => {
|
|
423
|
+
if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
|
|
424
|
+
validate();
|
|
425
|
+
const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
|
|
426
|
+
writeFileAtomically(root, path, canonicalBytes(stored));
|
|
427
|
+
return stored;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
401
431
|
export function writeBatchRunState(
|
|
402
432
|
root: string,
|
|
403
433
|
record: BatchRunStateRecord,
|
|
@@ -525,7 +555,8 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
|
|
|
525
555
|
!lane.branch ||
|
|
526
556
|
typeof lane.base_head !== "string" ||
|
|
527
557
|
!lane.base_head ||
|
|
528
|
-
(lane.lane_commit !== null && typeof lane.lane_commit !== "string")
|
|
558
|
+
(lane.lane_commit !== null && typeof lane.lane_commit !== "string") ||
|
|
559
|
+
(lane.run_id !== undefined && lane.run_id !== null && (typeof lane.run_id !== "string" || !lane.run_id))
|
|
529
560
|
)
|
|
530
561
|
throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid lane`);
|
|
531
562
|
}
|
|
@@ -558,7 +589,8 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
|
|
|
558
589
|
child.state === "settled" ||
|
|
559
590
|
child.state === "lane_committed";
|
|
560
591
|
if (
|
|
561
|
-
(record.batch_state === "budget_stopped" || record.batch_state === "failed" || record.batch_state === "rejected"
|
|
592
|
+
(record.batch_state === "budget_stopped" || record.batch_state === "failed" || record.batch_state === "rejected" ||
|
|
593
|
+
record.batch_state === "superseded") &&
|
|
562
594
|
record.children.some(inFlight)
|
|
563
595
|
)
|
|
564
596
|
throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
|
|
@@ -756,5 +788,11 @@ function canonicalReportBytes(report: PersistableReport): string {
|
|
|
756
788
|
|
|
757
789
|
/** All terminal batch states; needs_human is a park, not terminal. */
|
|
758
790
|
export function isTerminalBatchState(state: BatchRunState): boolean {
|
|
759
|
-
return
|
|
791
|
+
return (
|
|
792
|
+
state === "completed" ||
|
|
793
|
+
state === "budget_stopped" ||
|
|
794
|
+
state === "failed" ||
|
|
795
|
+
state === "rejected" ||
|
|
796
|
+
state === "superseded"
|
|
797
|
+
);
|
|
760
798
|
}
|