immune-brain 4.5.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -39
- package/README.zh-CN.md +160 -40
- 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 +42 -45
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
- package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
- package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
- package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
- package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
- package/plugins/immune-brain/dist/BASELINE.md +4 -4
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
- package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
- package/plugins/immune-brain/dist/imm-planner.md +60 -20
- package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
- package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
- package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
- package/plugins/immune-brain/dist/registry.yaml +9 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
- package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
- package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
- package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
- 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 +6 -5
- 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 +170 -17
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
- 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/loop_contract.ts +22 -22
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
- 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 +78 -8
- package/plugins/immune-brain/skills/BASELINE.md +4 -4
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
- package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/registry.yaml +9 -9
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
- 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,
|
|
@@ -25,12 +24,10 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
|
|
|
25
24
|
import type { BatchPlanChild } from "./types";
|
|
26
25
|
import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
|
|
27
26
|
import {
|
|
27
|
+
createDefaultBatchGitPort,
|
|
28
28
|
type BatchRunnerGitPort,
|
|
29
|
-
type BatchGitPreflightResult,
|
|
30
|
-
runBatchGitPreflight,
|
|
31
|
-
commitBatchChild,
|
|
32
|
-
lookupBatchCommit,
|
|
33
29
|
} from "./batch_git";
|
|
30
|
+
import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
|
|
34
31
|
import {
|
|
35
32
|
type BatchRunStateRecord,
|
|
36
33
|
type BatchChildRun,
|
|
@@ -43,7 +40,9 @@ import {
|
|
|
43
40
|
writeBatchRunReport,
|
|
44
41
|
} from "./batch_state";
|
|
45
42
|
|
|
46
|
-
/** Kernel-facing ports the driver needs. Host adapters supply these.
|
|
43
|
+
/** Kernel-facing ports the driver needs. Host adapters supply these. Git
|
|
44
|
+
* operations are not Kernel-port members: the runner drives them through
|
|
45
|
+
* BatchRunnerGitPort alone (D5 of docs/specs/deepen-authority-seams.spec.md). */
|
|
47
46
|
export interface BatchRunnerKernelPort {
|
|
48
47
|
/** Enroll one child through the batch-derived capability. */
|
|
49
48
|
enrollTask(input: {
|
|
@@ -57,31 +56,6 @@ export interface BatchRunnerKernelPort {
|
|
|
57
56
|
}): Promise<{ record_revision: string }>;
|
|
58
57
|
/** Drive one enrolled child toward its own Kernel terminal settlement. */
|
|
59
58
|
advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
|
|
60
|
-
/** Scope-bound commit after Kernel reports a child done. */
|
|
61
|
-
commitChild?(
|
|
62
|
-
root: string,
|
|
63
|
-
taskId: string,
|
|
64
|
-
batchId: string,
|
|
65
|
-
head: string,
|
|
66
|
-
branch?: string,
|
|
67
|
-
intentPath?: string,
|
|
68
|
-
): Promise<{ commit: string }>;
|
|
69
|
-
/** review-3(5th round): read the already-created batch commit for a child,
|
|
70
|
-
* or null when none exists. Lets crash recovery adopt an existing commit
|
|
71
|
-
* instead of replaying the commitChild mutation. */
|
|
72
|
-
lookupBatchCommit?(
|
|
73
|
-
root: string,
|
|
74
|
-
taskId: string,
|
|
75
|
-
batchId: string,
|
|
76
|
-
expectedHead?: string,
|
|
77
|
-
branch?: string,
|
|
78
|
-
): Promise<{ commit: string } | null>;
|
|
79
|
-
/** Optional Git preflight check. */
|
|
80
|
-
gitPreflight?(input: {
|
|
81
|
-
root: string;
|
|
82
|
-
initiative_slug: string;
|
|
83
|
-
base_head: string;
|
|
84
|
-
}): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
|
|
85
59
|
/** review-2(5th round): read-only claim projection for enrollment
|
|
86
60
|
* reconciliation after an interruption between enrollTask and its state
|
|
87
61
|
* persistence. Uses the real AssuranceProjectionResult contract; batch
|
|
@@ -92,15 +66,15 @@ export interface BatchRunnerKernelPort {
|
|
|
92
66
|
* is held under this batch's derived capability (consumed child slot). */
|
|
93
67
|
ownsTaskClaim(taskId: string): boolean;
|
|
94
68
|
/** review-8(4th rework): Kernel-side proof that a replacement parked-batch
|
|
95
|
-
* authorization is genuine
|
|
69
|
+
* authorization is genuine and bound to this exact batch_id,
|
|
96
70
|
* plan_digest, and base_head, validated against the Kernel's authoritative
|
|
97
|
-
* binding state. Throws on fabrication
|
|
71
|
+
* binding state. Throws on fabrication or mismatch; the driver
|
|
98
72
|
* must call this before accepting a parked-batch resume. */
|
|
99
73
|
validateBatchAuthorization(input: {
|
|
100
74
|
registry: BatchAuthorityRegistry;
|
|
101
75
|
capability: object;
|
|
102
76
|
binding: Pick<ValidatedBatchAuthorization,
|
|
103
|
-
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget"
|
|
77
|
+
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget">;
|
|
104
78
|
}): ValidatedBatchAuthorization;
|
|
105
79
|
}
|
|
106
80
|
|
|
@@ -123,13 +97,18 @@ export interface StartBatchInput {
|
|
|
123
97
|
plan_digest: string;
|
|
124
98
|
base_head: string;
|
|
125
99
|
confirmation_time: string;
|
|
126
|
-
|
|
127
|
-
budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
|
|
100
|
+
budget: { max_children: number; qa_failure_limit: number };
|
|
128
101
|
now: string;
|
|
129
102
|
kernel: BatchRunnerKernelPort;
|
|
130
103
|
git?: BatchRunnerGitPort;
|
|
131
104
|
}
|
|
132
105
|
|
|
106
|
+
/** One git seam: the injected BatchRunnerGitPort, or the default adapter over
|
|
107
|
+
* the real repository operations when the Host supplies none. */
|
|
108
|
+
function batchGitPortOf(input: StartBatchInput): BatchRunnerGitPort {
|
|
109
|
+
return input.git ?? createDefaultBatchGitPort();
|
|
110
|
+
}
|
|
111
|
+
|
|
133
112
|
export type { BatchRunReport } from "./batch_state";
|
|
134
113
|
|
|
135
114
|
function dependentsOf(record: BatchRunStateRecord, taskId: string): BatchChildRun[] {
|
|
@@ -155,7 +134,7 @@ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: str
|
|
|
155
134
|
);
|
|
156
135
|
}
|
|
157
136
|
|
|
158
|
-
function budgetStopReason(record: BatchRunStateRecord
|
|
137
|
+
function budgetStopReason(record: BatchRunStateRecord): string | null {
|
|
159
138
|
// review-3: budget counts enrollments consumed (children that left
|
|
160
139
|
// pending), not commits, so parked children still consume authorization.
|
|
161
140
|
const enrolledCount = record.children.filter(
|
|
@@ -163,22 +142,9 @@ function budgetStopReason(record: BatchRunStateRecord, now: number): string | nu
|
|
|
163
142
|
).length;
|
|
164
143
|
if (enrolledCount >= record.budget.max_children)
|
|
165
144
|
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
145
|
return null;
|
|
170
146
|
}
|
|
171
147
|
|
|
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
148
|
function requireFreshProjection<T extends AssuranceProjectionResult>(
|
|
183
149
|
result: T,
|
|
184
150
|
taskId: string,
|
|
@@ -203,7 +169,7 @@ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null
|
|
|
203
169
|
|
|
204
170
|
const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
205
171
|
completed: "The batch settled every enrollable child; review the commits and the tracker.",
|
|
206
|
-
budget_stopped: "
|
|
172
|
+
budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
207
173
|
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
208
174
|
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
209
175
|
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
@@ -298,35 +264,47 @@ function failPersistedLineage(
|
|
|
298
264
|
return writeBatchRunState(root, record);
|
|
299
265
|
}
|
|
300
266
|
|
|
301
|
-
/**
|
|
302
|
-
*
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
encoding: "utf8",
|
|
317
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
267
|
+
/** A HEAD that fast-forwards on the batch branch is the user's own work: adopt
|
|
268
|
+
* it as the new expected head and record it. Any other movement is the fatal
|
|
269
|
+
* lineage break, returned as the failure message with the record unchanged. */
|
|
270
|
+
function reconcileLineage(
|
|
271
|
+
root: string,
|
|
272
|
+
record: BatchRunStateRecord,
|
|
273
|
+
): { record: BatchRunStateRecord; failure: string | null } {
|
|
274
|
+
if (!existsSync(join(root, ".git"))) return { record, failure: null };
|
|
275
|
+
const expected = expectedBatchHead(record);
|
|
276
|
+
const lineage = classifyBatchLineage({
|
|
277
|
+
root,
|
|
278
|
+
branch: record.branch ?? "",
|
|
279
|
+
expectedHead: expected,
|
|
280
|
+
childCommits: record.commits,
|
|
281
|
+
batchId: record.batch_id,
|
|
318
282
|
});
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
283
|
+
if (lineage.kind === "broken") return { record, failure: lineage.message };
|
|
284
|
+
if (lineage.kind === "equal") return { record, failure: null };
|
|
285
|
+
return {
|
|
286
|
+
record: writeBatchRunState(root, {
|
|
287
|
+
...record,
|
|
288
|
+
adopted_heads: [...(record.adopted_heads ?? []), { from: expected, to: lineage.head }],
|
|
289
|
+
}),
|
|
290
|
+
failure: null,
|
|
291
|
+
};
|
|
324
292
|
}
|
|
325
293
|
|
|
326
294
|
async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
|
|
327
295
|
if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
|
|
328
296
|
const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
329
|
-
|
|
297
|
+
const live = head.stdout.trim();
|
|
298
|
+
const expected = expectedBatchHead(record);
|
|
299
|
+
const adoptable = () =>
|
|
300
|
+
classifyBatchLineage({
|
|
301
|
+
root: input.root,
|
|
302
|
+
branch: record.branch ?? "",
|
|
303
|
+
expectedHead: expected,
|
|
304
|
+
childCommits: record.commits,
|
|
305
|
+
batchId: record.batch_id,
|
|
306
|
+
}).kind === "fast_forward";
|
|
307
|
+
if (head.status !== 0 || (live !== expected && !ownUnpersistedBatchHead(input.root, record, live) && !adoptable()))
|
|
330
308
|
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
331
309
|
}
|
|
332
310
|
const plan = input.registry.children(input.capability);
|
|
@@ -342,16 +320,10 @@ async function validatePersistedRun(input: StartBatchInput, record: BatchRunStat
|
|
|
342
320
|
if (child.commit !== null) throw new Error("uncommitted batch child has a commit");
|
|
343
321
|
continue;
|
|
344
322
|
}
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: await lookupBatchCommit({
|
|
350
|
-
root: input.root,
|
|
351
|
-
taskId: child.task_id,
|
|
352
|
-
batchId: record.batch_id,
|
|
353
|
-
branch: record.branch,
|
|
354
|
-
});
|
|
323
|
+
const git = batchGitPortOf(input);
|
|
324
|
+
const evidence = await git.lookupBatchCommit(
|
|
325
|
+
input.root, child.task_id, record.batch_id, undefined, record.branch,
|
|
326
|
+
);
|
|
355
327
|
if (!evidence || evidence.commit !== child.commit) {
|
|
356
328
|
// Distinguish an unreachable recorded commit (external HEAD regression)
|
|
357
329
|
// from a fabricated record: reachability is only checkable in a real repo.
|
|
@@ -401,7 +373,6 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
401
373
|
base_head: existing?.base_head ?? input.base_head,
|
|
402
374
|
initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
|
|
403
375
|
budget: input.budget,
|
|
404
|
-
expires_at: input.authorization_expires_at,
|
|
405
376
|
},
|
|
406
377
|
});
|
|
407
378
|
if (authorized.issued_at !== input.confirmation_time ||
|
|
@@ -422,7 +393,7 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
422
393
|
function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
|
|
423
394
|
if (existing.plan_digest === input.plan_digest) return existing;
|
|
424
395
|
const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
|
|
425
|
-
|
|
396
|
+
budget: input.budget };
|
|
426
397
|
if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
|
|
427
398
|
input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
|
|
428
399
|
throw new Error("plan_digest mismatch: reconfirmation binding changed");
|
|
@@ -516,12 +487,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
516
487
|
if (existing?.batch_state === "needs_human") {
|
|
517
488
|
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
518
489
|
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
519
|
-
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
520
490
|
const freshAuthorization =
|
|
521
491
|
Number.isFinite(nextConfirmation) &&
|
|
522
|
-
nextConfirmation > priorConfirmation
|
|
523
|
-
Number.isFinite(nextExpiry) &&
|
|
524
|
-
nextExpiry > Date.now();
|
|
492
|
+
nextConfirmation > priorConfirmation;
|
|
525
493
|
if (!freshAuthorization && !reconfirmed) {
|
|
526
494
|
return finalize(
|
|
527
495
|
input.root,
|
|
@@ -573,7 +541,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
573
541
|
existing = writeBatchRunState(input.root, {
|
|
574
542
|
...reparked,
|
|
575
543
|
confirmation_time: input.confirmation_time,
|
|
576
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
577
544
|
budget: input.budget,
|
|
578
545
|
batch_state: "needs_human",
|
|
579
546
|
});
|
|
@@ -592,42 +559,12 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
592
559
|
existing = writeBatchRunState(input.root, {
|
|
593
560
|
...existing,
|
|
594
561
|
confirmation_time: input.confirmation_time,
|
|
595
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
596
562
|
budget: input.budget,
|
|
597
563
|
batch_state: "running",
|
|
598
564
|
children: resumedChildren,
|
|
599
565
|
});
|
|
600
566
|
}
|
|
601
567
|
|
|
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
568
|
// review-2(6th round): an enrolled or settled child from an interrupted
|
|
632
569
|
// run must be driven to its own terminal settlement and commit before any
|
|
633
570
|
// new child is selected, or startBatch would double-claim the task or
|
|
@@ -657,23 +594,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
657
594
|
}
|
|
658
595
|
|
|
659
596
|
// Mandatory batch branch preflight: run before any child is enrolled.
|
|
660
|
-
const preflightResult = input.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
})
|
|
666
|
-
: input.kernel.gitPreflight
|
|
667
|
-
? await input.kernel.gitPreflight({
|
|
668
|
-
root: input.root,
|
|
669
|
-
initiative_slug: input.initiative_slug,
|
|
670
|
-
base_head: input.base_head,
|
|
671
|
-
})
|
|
672
|
-
: runBatchGitPreflight({
|
|
673
|
-
root: input.root,
|
|
674
|
-
initiative_slug: input.initiative_slug,
|
|
675
|
-
base_head: input.base_head,
|
|
676
|
-
});
|
|
597
|
+
const preflightResult = await batchGitPortOf(input).preflight({
|
|
598
|
+
root: input.root,
|
|
599
|
+
initiative_slug: input.initiative_slug,
|
|
600
|
+
base_head: input.base_head,
|
|
601
|
+
});
|
|
677
602
|
|
|
678
603
|
if (!preflightResult.ok) {
|
|
679
604
|
const rejected = prepareBatchRunState({ ...input, now: input.now });
|
|
@@ -693,7 +618,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
693
618
|
plan_digest: input.plan_digest,
|
|
694
619
|
base_head: input.base_head,
|
|
695
620
|
confirmation_time: input.confirmation_time,
|
|
696
|
-
authorization_expires_at: input.authorization_expires_at,
|
|
697
621
|
budget: input.budget,
|
|
698
622
|
now: input.now,
|
|
699
623
|
});
|
|
@@ -707,17 +631,15 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
707
631
|
persist();
|
|
708
632
|
}
|
|
709
633
|
|
|
710
|
-
let head = record.commits.length
|
|
711
|
-
? record.commits[record.commits.length - 1]!
|
|
712
|
-
: record.base_head;
|
|
713
|
-
|
|
714
634
|
// Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
|
|
715
|
-
const
|
|
716
|
-
|
|
635
|
+
const lineage = reconcileLineage(input.root, record);
|
|
636
|
+
record = lineage.record;
|
|
637
|
+
if (lineage.failure) {
|
|
717
638
|
record.batch_state = "failed";
|
|
718
639
|
persist();
|
|
719
|
-
return finalize(input.root, record,
|
|
640
|
+
return finalize(input.root, record, lineage.failure, "");
|
|
720
641
|
}
|
|
642
|
+
let head = expectedBatchHead(record);
|
|
721
643
|
|
|
722
644
|
while (record.batch_state === "running") {
|
|
723
645
|
const child = nextEnrollableChild(record);
|
|
@@ -780,9 +702,8 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
780
702
|
);
|
|
781
703
|
persist();
|
|
782
704
|
} else {
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
|
|
705
|
+
// An adopted claim is not a new enrollment.
|
|
706
|
+
if (budgetStopReason(record)) {
|
|
786
707
|
record.batch_state = record.children.some(
|
|
787
708
|
(c) => c.state === "needs_human" || c.state === "skipped_blocked",
|
|
788
709
|
) ? "needs_human" : "budget_stopped";
|
|
@@ -799,7 +720,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
799
720
|
binding: {
|
|
800
721
|
batch_id: record.batch_id, plan_digest: record.plan_digest,
|
|
801
722
|
base_head: record.base_head, initiative_slug: record.initiative_slug,
|
|
802
|
-
budget: record.budget,
|
|
723
|
+
budget: record.budget,
|
|
803
724
|
},
|
|
804
725
|
});
|
|
805
726
|
for (const committed of record.children) {
|
|
@@ -821,17 +742,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
821
742
|
persist();
|
|
822
743
|
} catch (error) {
|
|
823
744
|
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
745
|
// review round 10: external HEAD movement after the pre-loop check
|
|
836
746
|
// (including between children) fails the batch, it never parks.
|
|
837
747
|
if (isLineageBreakError(message)) {
|
|
@@ -869,8 +779,8 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
|
869
779
|
switch (record.batch_state) {
|
|
870
780
|
case "budget_stopped":
|
|
871
781
|
return (
|
|
872
|
-
budgetStopReason(record
|
|
873
|
-
"
|
|
782
|
+
budgetStopReason(record) ??
|
|
783
|
+
"the child budget stopped new enrollments"
|
|
874
784
|
);
|
|
875
785
|
case "failed": {
|
|
876
786
|
const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
|
|
@@ -888,10 +798,9 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
|
888
798
|
/**
|
|
889
799
|
* review-1: resume the interrupted child first. A persisted enrolled child
|
|
890
800
|
* 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.
|
|
801
|
+
* settlement and commit before any new enrollment, so an interrupted run can
|
|
802
|
+
* never falsely complete or enroll a sibling while a child claim is still
|
|
803
|
+
* open. Never replays a committed mutation.
|
|
895
804
|
*/
|
|
896
805
|
export async function resumeBatch(
|
|
897
806
|
input: StartBatchInput,
|
|
@@ -915,8 +824,8 @@ export async function resumeBatch(
|
|
|
915
824
|
return replayTerminal(input.root, existing);
|
|
916
825
|
|
|
917
826
|
// An enrolled-but-unsettled child must reach its own Kernel terminal
|
|
918
|
-
// settlement before anything else
|
|
919
|
-
//
|
|
827
|
+
// settlement before anything else: its claim is already held and the
|
|
828
|
+
// Kernel owns its remaining obligations.
|
|
920
829
|
// review-1(2nd round): an `enrolled` child is verified against a fresh
|
|
921
830
|
// Kernel projection; a projection that finds no active claim means the
|
|
922
831
|
// persisted `enrolled` flag predates a successful enrollment, so the child
|
|
@@ -933,10 +842,11 @@ export async function resumeBatch(
|
|
|
933
842
|
// defers to driveInterruptedChild's lookupBatchCommit verification,
|
|
934
843
|
// which adopts a verified own commit (crash before committed-persist)
|
|
935
844
|
// instead of misjudging it as external drift.
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
845
|
+
const drivenLineage = reconcileLineage(input.root, existing);
|
|
846
|
+
existing = drivenLineage.record;
|
|
847
|
+
if (drivenLineage.failure) {
|
|
848
|
+
const failed = failPersistedLineage(input.root, existing, drivenLineage.failure);
|
|
849
|
+
return finalize(input.root, failed, drivenLineage.failure, "");
|
|
940
850
|
}
|
|
941
851
|
const fresh = requireFreshProjection(
|
|
942
852
|
await input.kernel.projectTask(input.root, driven.task_id),
|
|
@@ -1067,9 +977,7 @@ async function driveInterruptedChild(
|
|
|
1067
977
|
record.batch_state = "running";
|
|
1068
978
|
persist();
|
|
1069
979
|
}
|
|
1070
|
-
let head = record
|
|
1071
|
-
? record.commits[record.commits.length - 1]!
|
|
1072
|
-
: record.base_head;
|
|
980
|
+
let head = expectedBatchHead(record);
|
|
1073
981
|
while (child.state === "enrolled" || child.state === "settled") {
|
|
1074
982
|
if (child.state === "settled") {
|
|
1075
983
|
// review-2(2nd round): a persisted settled child must not replay
|
|
@@ -1081,23 +989,9 @@ async function driveInterruptedChild(
|
|
|
1081
989
|
// review-1(6th round): a lookup failure must fail closed, not fall
|
|
1082
990
|
// through to commitChild, which could replay an existing commit.
|
|
1083
991
|
try {
|
|
1084
|
-
existing = input.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
? await input.kernel.lookupBatchCommit(
|
|
1088
|
-
input.root,
|
|
1089
|
-
child.task_id,
|
|
1090
|
-
input.batch_id,
|
|
1091
|
-
head,
|
|
1092
|
-
record.branch,
|
|
1093
|
-
)
|
|
1094
|
-
: await lookupBatchCommit({
|
|
1095
|
-
root: input.root,
|
|
1096
|
-
taskId: child.task_id,
|
|
1097
|
-
batchId: input.batch_id,
|
|
1098
|
-
expectedHead: head,
|
|
1099
|
-
branch: record.branch,
|
|
1100
|
-
});
|
|
992
|
+
existing = await batchGitPortOf(input).lookupBatchCommit(
|
|
993
|
+
input.root, child.task_id, input.batch_id, head, record.branch,
|
|
994
|
+
);
|
|
1101
995
|
} catch (error: unknown) {
|
|
1102
996
|
const message =
|
|
1103
997
|
error instanceof Error ? error.message : String(error);
|
|
@@ -1120,24 +1014,19 @@ async function driveInterruptedChild(
|
|
|
1120
1014
|
persist();
|
|
1121
1015
|
throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
|
|
1122
1016
|
}
|
|
1017
|
+
if (!existing) {
|
|
1018
|
+
// No own commit exists yet: a fast-forward since settlement is the
|
|
1019
|
+
// user's work. Adopt it so this child's commit parents on it; a broken
|
|
1020
|
+
// lineage is left for commitChild to refuse with its own message.
|
|
1021
|
+
record = reconcileLineage(input.root, record).record;
|
|
1022
|
+
head = expectedBatchHead(record);
|
|
1023
|
+
}
|
|
1123
1024
|
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
1124
1025
|
const intentPath = planChild?.intent_path ?? undefined;
|
|
1125
|
-
const doCommit = async () =>
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
if (input.kernel.commitChild) {
|
|
1130
|
-
return input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
|
|
1131
|
-
}
|
|
1132
|
-
return commitBatchChild({
|
|
1133
|
-
root: input.root,
|
|
1134
|
-
taskId: child.task_id,
|
|
1135
|
-
batchId: input.batch_id,
|
|
1136
|
-
expectedHead: head,
|
|
1137
|
-
branch: record.branch,
|
|
1138
|
-
intentPath,
|
|
1139
|
-
});
|
|
1140
|
-
};
|
|
1026
|
+
const doCommit = async () =>
|
|
1027
|
+
batchGitPortOf(input).commitChild(
|
|
1028
|
+
input.root, child.task_id, input.batch_id, head, record.branch, intentPath,
|
|
1029
|
+
);
|
|
1141
1030
|
if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
|
|
1142
1031
|
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
1143
1032
|
const adopted =
|
|
@@ -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));
|