immune-brain 5.1.1 → 5.2.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 +13 -6
- package/README.zh-CN.md +12 -5
- 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 +14 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
- package/plugins/immune-brain/dist/BASELINE.md +1 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
- package/plugins/immune-brain/dist/imm-planner.md +63 -37
- package/plugins/immune-brain/dist/imm-run.md +137 -12
- package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
- package/plugins/immune-brain/dist/registry.yaml +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
- package/plugins/immune-brain/hooks/hooks.json +31 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
- package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
- package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/BASELINE.md +1 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
- package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
- package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
- package/plugins/immune-brain/skills/registry.yaml +9 -0
|
@@ -6,18 +6,33 @@
|
|
|
6
6
|
// deletes a Git worktree and never names a workspace tool.
|
|
7
7
|
// Defined by docs/specs/parallel-batch-lanes.spec.md and ADR 0013.
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
9
10
|
import { existsSync, realpathSync } from "node:fs";
|
|
10
11
|
import { isAbsolute, join } from "node:path";
|
|
11
12
|
import { computeBatchPlanDigest } from "../kernel/batch_authority";
|
|
13
|
+
import { readTaskRecordRaw } from "../kernel/storage";
|
|
12
14
|
import { readTaskIntent } from "../kernel/intent";
|
|
13
|
-
import
|
|
15
|
+
import { readTaskTombstone } from "../kernel/backend_claim";
|
|
16
|
+
import {
|
|
17
|
+
readRuntimeContracts,
|
|
18
|
+
runningRuntimeSource,
|
|
19
|
+
RUNTIME_CONTRACTS,
|
|
20
|
+
runtimeContractDifferences,
|
|
21
|
+
} from "../kernel/runtime_contracts";
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { runFinalVerification, type FinalVerificationReport } from "./batch_final_verification";
|
|
24
|
+
import { applyDelegatedIntentRevision, delegatedConfirmationRef, delegatedRevisionRefusal } from "./batch_delegation";
|
|
25
|
+
import { runGithubTrackerOperation } from "../github_issue_tracker";
|
|
26
|
+
import { hasLocalInitiative } from "../local_initiative";
|
|
27
|
+
import type { BatchTrackerPort, StartBatchInput } from "./batch_runner";
|
|
14
28
|
import {
|
|
15
29
|
BatchIntegrationError,
|
|
16
30
|
findIntegratedCandidate,
|
|
17
31
|
integrateGuardedLaneCommit,
|
|
18
32
|
type IntegrationCheckChild,
|
|
19
33
|
} from "./batch_integration";
|
|
20
|
-
import { classifyBatchLineage, expectedBatchHead, findExistingActiveBatch, readActiveClaimTaskId } from "./batch_preflight";
|
|
34
|
+
import { classifyBatchLineage, expectedBatchHead, findExistingActiveBatch, porcelainEntries, readActiveClaimTaskId } from "./batch_preflight";
|
|
35
|
+
import { restoreProvableLaneLeaks } from "./batch_leak_restore";
|
|
21
36
|
import { startableChildren } from "./batch_schedule";
|
|
22
37
|
import {
|
|
23
38
|
type AnyBatchRunStateRecord,
|
|
@@ -67,11 +82,12 @@ export function resolveLaneParallel(
|
|
|
67
82
|
initiativeSlug: string,
|
|
68
83
|
maxParallel: number | undefined,
|
|
69
84
|
laneOffers: unknown,
|
|
85
|
+
laneInstruction?: unknown,
|
|
70
86
|
): number | undefined {
|
|
71
|
-
if (maxParallel !== undefined || laneOffers === undefined) return maxParallel;
|
|
87
|
+
if (maxParallel !== undefined || (laneOffers === undefined && laneInstruction === undefined)) return maxParallel;
|
|
72
88
|
const lookup = findExistingActiveBatch(root, initiativeSlug);
|
|
73
89
|
if (lookup === null || lookup.corrupt || !isLaneBatchRecord(lookup.record))
|
|
74
|
-
throw new Error("lane_offers requires max_parallel unless a recorded lane batch is being resumed
|
|
90
|
+
throw new Error(`${laneOffers !== undefined ? "lane_offers" : "lane_instruction"} requires max_parallel unless a recorded lane batch is being resumed`);
|
|
75
91
|
return lookup.record.max_parallel;
|
|
76
92
|
}
|
|
77
93
|
|
|
@@ -109,6 +125,119 @@ export function parseLaneOffers(value: unknown): LaneOffer[] | undefined {
|
|
|
109
125
|
});
|
|
110
126
|
}
|
|
111
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The production tracker port both Hosts hand a lane batch: the same
|
|
130
|
+
* `mark-terminal` projection a single task runs after settlement. A Local
|
|
131
|
+
* Initiative has no tracker, so it gets none.
|
|
132
|
+
*/
|
|
133
|
+
export function createBatchTrackerPort(root: string, initiativeSlug: string): BatchTrackerPort | undefined {
|
|
134
|
+
if (hasLocalInitiative(root, initiativeSlug)) return undefined;
|
|
135
|
+
return { markTerminal: (trackerRoot, input) => runGithubTrackerOperation(trackerRoot, { op: "mark-terminal", ...input }) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface LaneInstructionRequest {
|
|
139
|
+
task_id: string;
|
|
140
|
+
kind: "instruction" | "correction";
|
|
141
|
+
text: string;
|
|
142
|
+
/** The Lane session's state as the Parent just observed it from its supervised session. */
|
|
143
|
+
session_state: "idle" | "working" | "blocked" | "done";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const MAX_INSTRUCTION_LENGTH = 4000;
|
|
147
|
+
|
|
148
|
+
/** Untrusted tool input, shape-checked here and decided against the record below. */
|
|
149
|
+
export function parseLaneInstruction(value: unknown): LaneInstructionRequest | undefined {
|
|
150
|
+
if (value === undefined || value === null) return undefined;
|
|
151
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error("invalid lane_instruction: expected an object");
|
|
152
|
+
const { task_id: taskId, kind = "instruction", text, session_state: sessionState, ...rest } = value as Record<string, unknown>;
|
|
153
|
+
if (Object.keys(rest).length > 0) throw new Error("invalid lane_instruction: unknown field");
|
|
154
|
+
if (typeof taskId !== "string" || !TASK_ID_PATTERN.test(taskId)) throw new Error("invalid lane_instruction: task_id is not a valid task id");
|
|
155
|
+
if (kind !== "instruction" && kind !== "correction") throw new Error("invalid lane_instruction: kind must be instruction or correction");
|
|
156
|
+
if (typeof text !== "string" || !text.trim() || text.length > MAX_INSTRUCTION_LENGTH)
|
|
157
|
+
throw new Error(`invalid lane_instruction: text must be 1 to ${MAX_INSTRUCTION_LENGTH} characters`);
|
|
158
|
+
if (sessionState !== "idle" && sessionState !== "working" && sessionState !== "blocked" && sessionState !== "done")
|
|
159
|
+
throw new Error("invalid lane_instruction: session_state must be idle, working, blocked or done");
|
|
160
|
+
return { task_id: taskId, kind, text, session_state: sessionState };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether the coordinator may send this text to the Lane session now. Text goes
|
|
165
|
+
* only to an idle session of a child that is still running in its Lane. A
|
|
166
|
+
* blocked session shows a dialog only the user may answer (trust, sign-in,
|
|
167
|
+
* permission, a question, a native confirmation), so it never receives input;
|
|
168
|
+
* a working session is not interrupted. Null means allowed.
|
|
169
|
+
*/
|
|
170
|
+
export function decideLaneInstruction(record: BatchLaneRunStateRecord, request: LaneInstructionRequest): string | null {
|
|
171
|
+
const child = record.children.find((c) => c.task_id === request.task_id);
|
|
172
|
+
if (!child || !child.lane) return `${request.task_id} has no Lane in this batch`;
|
|
173
|
+
if (child.state !== "enrolled") return `${request.task_id} is ${child.state}, not running in its Lane`;
|
|
174
|
+
if (request.kind === "correction" && !child.correction_due)
|
|
175
|
+
return `${request.task_id} has not exhausted its rework budget; send an ordinary instruction instead`;
|
|
176
|
+
if (request.kind === "instruction" && child.correction_due)
|
|
177
|
+
return `${request.task_id} waits for a design-level correction; send it with kind correction`;
|
|
178
|
+
if (request.session_state === "blocked")
|
|
179
|
+
return `${request.task_id}'s session is blocked on a dialog only the user may answer; send nothing and tell the user`;
|
|
180
|
+
if (request.session_state !== "idle") return `${request.task_id}'s session is ${request.session_state}; text goes only to an idle session`;
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** How many design-level coordinator corrections one child may receive. */
|
|
185
|
+
export const MAX_COORDINATOR_CORRECTIONS = 2;
|
|
186
|
+
/** The park reason once a child exhausts its budget after its last correction. */
|
|
187
|
+
export const CORRECTION_LIMIT_REACHED = "batch_correction_limit_reached";
|
|
188
|
+
|
|
189
|
+
export const LANE_CONTRACT_MISMATCH = "batch_lane_contract_mismatch";
|
|
190
|
+
export const RUNTIME_CONTRACT_MISMATCH = "batch_runtime_contract_mismatch";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The runtime a Lane Executor will load, when the coordinator can know it: an
|
|
194
|
+
* explicit path, else the repository's own plugin source when the repository
|
|
195
|
+
* is the Immune-Brain package itself (a Host loading the package from the
|
|
196
|
+
* repository runs that source in every Lane, which is cut from this head).
|
|
197
|
+
* Null when nothing can be compared.
|
|
198
|
+
*/
|
|
199
|
+
function laneRuntimeDir(root: string, explicit: string | undefined): string | null {
|
|
200
|
+
if (explicit) return explicit;
|
|
201
|
+
try {
|
|
202
|
+
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: unknown };
|
|
203
|
+
return manifest.name === "immune-brain" ? root : null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Batch-start preflight for lane mode: the coordinator reads every Lane's
|
|
211
|
+
* Kernel state with its own runtime, so the runtime a Lane Executor loads must
|
|
212
|
+
* use the same contract identifiers. The comparison is on those identifiers,
|
|
213
|
+
* never on a plugin version number. A refusal names both sources; a match
|
|
214
|
+
* returns null and adds nothing, no confirmation included.
|
|
215
|
+
*/
|
|
216
|
+
export function laneRuntimeContractRefusal(root: string, executorRuntime?: string): string | null {
|
|
217
|
+
const dir = laneRuntimeDir(root, executorRuntime);
|
|
218
|
+
if (!dir) return null;
|
|
219
|
+
let lane: ReturnType<typeof readRuntimeContracts>;
|
|
220
|
+
try {
|
|
221
|
+
lane = readRuntimeContracts(dir);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return `${RUNTIME_CONTRACT_MISMATCH}: ${error instanceof Error ? error.message : String(error)}`;
|
|
224
|
+
}
|
|
225
|
+
if (!lane) return executorRuntime ? `${RUNTIME_CONTRACT_MISMATCH}: no runtime contract manifest under ${executorRuntime}` : null;
|
|
226
|
+
const differences = runtimeContractDifferences(RUNTIME_CONTRACTS, lane.contracts);
|
|
227
|
+
if (differences.length === 0) return null;
|
|
228
|
+
return `${RUNTIME_CONTRACT_MISMATCH}: coordinator runtime ${runningRuntimeSource()} and Lane runtime ${lane.path} disagree (${differences.join("; ")}); load the same runtime on both sides before starting the batch`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A Lane whose Kernel state exists but that this runtime refuses to read is a
|
|
233
|
+
* contract mismatch, not a lost Lane: one side was upgraded under the batch.
|
|
234
|
+
*/
|
|
235
|
+
function laneUnreadableReason(error: string): string {
|
|
236
|
+
return /contract must equal|unknown field|schema version .* incompatible|unknown contract/i.test(error)
|
|
237
|
+
? `${LANE_CONTRACT_MISMATCH}: ${error}`
|
|
238
|
+
: "batch_lane_lost";
|
|
239
|
+
}
|
|
240
|
+
|
|
112
241
|
/** The branch the Lane for a child must sit on. */
|
|
113
242
|
export function laneBranchName(initiativeSlug: string, taskId: string): string {
|
|
114
243
|
return `imm-lane/${initiativeSlug}/${taskId}`;
|
|
@@ -360,11 +489,104 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
|
360
489
|
prepared: "The batch is prepared but not started.",
|
|
361
490
|
};
|
|
362
491
|
|
|
492
|
+
/**
|
|
493
|
+
* ADR 0018 / #198: once a child is integrated, its TaskIntent on the batch
|
|
494
|
+
* branch may differ from the identity the batch authorized. When it equals the
|
|
495
|
+
* last revision applied under this batch's own delegation, the recorded
|
|
496
|
+
* identity is replaced and `plan_digest` resealed, with both digests recorded.
|
|
497
|
+
* Any other difference is left alone, so it still fails closed as `plan_changed`.
|
|
498
|
+
* Commits and their evidence are never touched.
|
|
499
|
+
*/
|
|
500
|
+
function resealDelegatedRevision(input: StartBatchInput, record: BatchLaneRunStateRecord, taskId: string): boolean {
|
|
501
|
+
const identities = record.intent_identities;
|
|
502
|
+
const recorded = identities?.[taskId];
|
|
503
|
+
if (!identities || !recorded) return false;
|
|
504
|
+
let integrated: { revision: number; content_hash: string };
|
|
505
|
+
try {
|
|
506
|
+
const read = readTaskIntent(input.root, taskId, recorded.intent_path);
|
|
507
|
+
integrated = { revision: read.intent.revision, content_hash: read.content_hash };
|
|
508
|
+
} catch {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
if (integrated.revision === recorded.intent_revision && integrated.content_hash === recorded.intent_content_hash) return false;
|
|
512
|
+
const delegated = [...(record.delegated_revisions ?? [])].reverse().find((r) => r.task_id === taskId);
|
|
513
|
+
if (!delegated || delegated.to_revision !== integrated.revision || delegated.intent_content_hash !== integrated.content_hash) return false;
|
|
514
|
+
const next = { ...identities, [taskId]: { ...recorded, intent_revision: integrated.revision, intent_content_hash: integrated.content_hash } };
|
|
515
|
+
// Every child needs a recorded identity, or no digest can be recomputed.
|
|
516
|
+
if (record.children.some((child) => !next[child.task_id])) return false;
|
|
517
|
+
let toDigest: string;
|
|
518
|
+
try {
|
|
519
|
+
toDigest = computeBatchPlanDigest(record.children.map((child) => {
|
|
520
|
+
const identity = next[child.task_id]!;
|
|
521
|
+
return { task_id: child.task_id, intent_path: identity.intent_path, intent_revision: identity.intent_revision, intent_content_hash: identity.intent_content_hash, blocked_by: child.blocked_by };
|
|
522
|
+
}));
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
record.reseals = [...(record.reseals ?? []), {
|
|
527
|
+
at: input.now,
|
|
528
|
+
task_id: taskId,
|
|
529
|
+
from_digest: record.plan_digest,
|
|
530
|
+
to_digest: toDigest,
|
|
531
|
+
intent_revision: integrated.revision,
|
|
532
|
+
intent_content_hash: integrated.content_hash,
|
|
533
|
+
}];
|
|
534
|
+
record.intent_identities = next;
|
|
535
|
+
record.plan_digest = toDigest;
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
type TrackerObservations = NonNullable<BatchLaneRunReport["tracker_observations"]>;
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Close the Child Issue of every integrated child not closed yet. Only the
|
|
544
|
+
* coordinator does this, and only after integration: a Lane settlement writes
|
|
545
|
+
* nothing to the tracker, and a parked or lost child is never integrated, so
|
|
546
|
+
* its Issue stays open. The terminal event comes from the child's audit pair,
|
|
547
|
+
* which integration brought onto the batch branch. A tracker failure never
|
|
548
|
+
* changes batch or Kernel state; the next tick retries it, and a confirmed
|
|
549
|
+
* close is recorded so it is never repeated.
|
|
550
|
+
*/
|
|
551
|
+
async function closeIntegratedIssues(
|
|
552
|
+
input: StartBatchInput,
|
|
553
|
+
record: BatchLaneRunStateRecord,
|
|
554
|
+
persist: () => void,
|
|
555
|
+
): Promise<TrackerObservations> {
|
|
556
|
+
const observations: TrackerObservations = [];
|
|
557
|
+
if (!input.tracker) return observations;
|
|
558
|
+
for (const child of record.children) {
|
|
559
|
+
if ((child.state !== "integrated" && child.state !== "released") || child.tracker_closed) continue;
|
|
560
|
+
let status: string;
|
|
561
|
+
let message: string;
|
|
562
|
+
try {
|
|
563
|
+
const tombstone = readTaskTombstone(input.root, child.task_id);
|
|
564
|
+
if (!tombstone || tombstone.lifecycle_status !== "terminal" || tombstone.terminal_lifecycle !== "done") {
|
|
565
|
+
observations.push({ task_id: child.task_id, status: "retryable_failure", message: "integrated child has no done terminal proof on the batch branch" });
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
({ status, message } = await input.tracker.markTerminal(input.root, {
|
|
569
|
+
task_id: child.task_id,
|
|
570
|
+
phase: "done",
|
|
571
|
+
terminal_event_id: tombstone.terminal_event_id,
|
|
572
|
+
}));
|
|
573
|
+
} catch (error) {
|
|
574
|
+
status = "retryable_failure";
|
|
575
|
+
message = error instanceof Error ? error.message : String(error);
|
|
576
|
+
}
|
|
577
|
+
observations.push({ task_id: child.task_id, status, message });
|
|
578
|
+
if (status !== "updated" && status !== "already_current") continue;
|
|
579
|
+
record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, tracker_closed: true } : c));
|
|
580
|
+
persist();
|
|
581
|
+
}
|
|
582
|
+
return observations;
|
|
583
|
+
}
|
|
584
|
+
|
|
363
585
|
function laneReport(
|
|
364
586
|
record: BatchLaneRunStateRecord,
|
|
365
587
|
reason: string | null,
|
|
366
588
|
nextAction: string,
|
|
367
|
-
extra: { handoffs?: BatchLaneHandoff[]; refusals?: BatchLaneRunReport["lane_refusals"] } = {},
|
|
589
|
+
extra: { handoffs?: BatchLaneHandoff[]; refusals?: BatchLaneRunReport["lane_refusals"]; tracker?: TrackerObservations; final?: FinalVerificationReport; instruction?: BatchLaneRunReport["lane_instruction"]; revision?: BatchLaneRunReport["lane_revision"] } = {},
|
|
368
590
|
): BatchLaneRunReport {
|
|
369
591
|
return {
|
|
370
592
|
contract: "assurance_kernel/batch_run_report/v1",
|
|
@@ -377,6 +599,14 @@ function laneReport(
|
|
|
377
599
|
reason,
|
|
378
600
|
handoffs: extra.handoffs ?? [],
|
|
379
601
|
...(extra.refusals?.length ? { lane_refusals: extra.refusals } : {}),
|
|
602
|
+
...(extra.tracker?.length ? { tracker_observations: extra.tracker } : {}),
|
|
603
|
+
...(record.restores?.length ? { restores: record.restores } : {}),
|
|
604
|
+
...(extra.final ? { final_verification: extra.final } : {}),
|
|
605
|
+
...(record.interventions?.length ? { interventions: record.interventions } : {}),
|
|
606
|
+
...(extra.instruction ? { lane_instruction: extra.instruction } : {}),
|
|
607
|
+
...(extra.revision ? { lane_revision: extra.revision } : {}),
|
|
608
|
+
...(record.delegated_revisions?.length ? { delegated_revisions: record.delegated_revisions } : {}),
|
|
609
|
+
...(record.reseals?.length ? { reseals: record.reseals } : {}),
|
|
380
610
|
next_action: nextAction || (TERMINAL_NEXT_ACTIONS[record.batch_state] ?? "Inspect the batch run state."),
|
|
381
611
|
created_at: record.updated_at,
|
|
382
612
|
};
|
|
@@ -540,10 +770,17 @@ export async function runLaneBatch(
|
|
|
540
770
|
if (persisted) {
|
|
541
771
|
record = persisted;
|
|
542
772
|
assertPlanMatches(input, record);
|
|
543
|
-
if (isTerminalBatchState(record.batch_state))
|
|
773
|
+
if (isTerminalBatchState(record.batch_state)) {
|
|
774
|
+
// A close that failed on the completing tick is retried here; a terminal
|
|
775
|
+
// record otherwise stays as it was.
|
|
776
|
+
const tracker = record.batch_state === "completed"
|
|
777
|
+
? await closeIntegratedIssues(input, record, () => { record = writeBatchLaneRunState(input.root, record); })
|
|
778
|
+
: [];
|
|
544
779
|
return finalizeLane(input.root, record, `terminal state already reached: ${record.batch_state}`, "", {
|
|
545
780
|
handoffs: record.batch_state === "completed" ? releaseHandoffs(input, record, lanes) : [],
|
|
781
|
+
tracker,
|
|
546
782
|
});
|
|
783
|
+
}
|
|
547
784
|
if (record.batch_state === "needs_human") {
|
|
548
785
|
const invalid = validateNewAuthorization(input);
|
|
549
786
|
if (invalid || Date.parse(input.confirmation_time) <= Date.parse(record.confirmation_time))
|
|
@@ -572,6 +809,11 @@ export async function runLaneBatch(
|
|
|
572
809
|
} else {
|
|
573
810
|
const invalid = validateNewAuthorization(input);
|
|
574
811
|
if (invalid) return refuse(input, null, "batch_authorization_invalid", invalid);
|
|
812
|
+
const contractRefusal = laneRuntimeContractRefusal(input.root, input.executor_runtime);
|
|
813
|
+
if (contractRefusal) {
|
|
814
|
+
const [code, ...detail] = contractRefusal.split(": ");
|
|
815
|
+
return refuse(input, null, code!, detail.join(": "));
|
|
816
|
+
}
|
|
575
817
|
const preflight = await git.preflight({
|
|
576
818
|
root: input.root,
|
|
577
819
|
initiative_slug: input.initiative_slug,
|
|
@@ -588,12 +830,33 @@ export async function runLaneBatch(
|
|
|
588
830
|
budget: input.budget,
|
|
589
831
|
max_parallel: limit,
|
|
590
832
|
now: input.now,
|
|
833
|
+
...(input.final_verification?.length ? { final_verification: input.final_verification } : {}),
|
|
834
|
+
...(input.revision_delegation ? { revision_delegation: { confirmation_time: input.confirmation_time } } : {}),
|
|
591
835
|
});
|
|
592
836
|
}
|
|
837
|
+
// A fresh confirmation of a persisted batch replaces its grant with that answer.
|
|
838
|
+
if (persisted && input.revision_delegation !== undefined) {
|
|
839
|
+
const { revision_delegation: _prior, ...rest } = record;
|
|
840
|
+
record = input.revision_delegation ? { ...rest, revision_delegation: { confirmation_time: input.confirmation_time } } : rest;
|
|
841
|
+
// Durable at once: a withdrawn grant must never survive to a later reused tick.
|
|
842
|
+
if (record.batch_state !== "prepared") record = writeBatchLaneRunState(input.root, record);
|
|
843
|
+
}
|
|
593
844
|
|
|
594
845
|
const persist = (): void => {
|
|
595
846
|
record = writeBatchLaneRunState(input.root, record);
|
|
596
847
|
};
|
|
848
|
+
// Lane writes that leaked into the coordinator checkout, restored only now,
|
|
849
|
+
// after authorization, and only when every dirty path is provably a Lane's.
|
|
850
|
+
if (persisted) {
|
|
851
|
+
const entries = porcelainEntries(input.root);
|
|
852
|
+
if (entries && entries.length > 0) {
|
|
853
|
+
const outcome = restoreProvableLaneLeaks(input.root, record, entries, input.now);
|
|
854
|
+
if (outcome.kind === "restored") {
|
|
855
|
+
record.restores = [...(record.restores ?? []), outcome.restore];
|
|
856
|
+
persist();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
597
860
|
// A resumed park is persisted only after the existing lineage validation below.
|
|
598
861
|
const resumedPark = persisted?.batch_state === "needs_human" && record.batch_state === "running";
|
|
599
862
|
if (record.batch_state === "prepared") {
|
|
@@ -652,7 +915,71 @@ export async function runLaneBatch(
|
|
|
652
915
|
}
|
|
653
916
|
|
|
654
917
|
if (resumedPark) persist();
|
|
918
|
+
// A coordinator instruction is recorded before the Parent sends it, and only
|
|
919
|
+
// when it may be sent; the report tells the Parent which.
|
|
920
|
+
let instruction: BatchLaneRunReport["lane_instruction"];
|
|
921
|
+
if (input.lane_instruction) {
|
|
922
|
+
const request = input.lane_instruction;
|
|
923
|
+
const refusal = decideLaneInstruction(record, request);
|
|
924
|
+
if (refusal) instruction = { task_id: request.task_id, kind: request.kind, accepted: false, reason: `batch_lane_instruction_refused: ${refusal}` };
|
|
925
|
+
else {
|
|
926
|
+
if (request.kind === "correction")
|
|
927
|
+
record.children = record.children.map((c) => {
|
|
928
|
+
if (c.task_id !== request.task_id) return c;
|
|
929
|
+
const { correction_due: _due, ...rest } = c;
|
|
930
|
+
return { ...rest, corrections: (c.corrections ?? 0) + 1, qa_failures: 0 };
|
|
931
|
+
});
|
|
932
|
+
record.interventions = [...(record.interventions ?? []), {
|
|
933
|
+
at: input.now,
|
|
934
|
+
task_id: request.task_id,
|
|
935
|
+
kind: request.kind,
|
|
936
|
+
text: request.text,
|
|
937
|
+
text_digest: `sha256:${createHash("sha256").update(request.text).digest("hex")}`,
|
|
938
|
+
}];
|
|
939
|
+
persist();
|
|
940
|
+
instruction = { task_id: request.task_id, kind: request.kind, accepted: true, reason: null };
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
// ADR 0018: a breaking revision the coordinator approves under the batch's
|
|
944
|
+
// grant. Out of bounds, or without a grant, it is refused and the Executor's
|
|
945
|
+
// own native gate stays the only path.
|
|
946
|
+
let revision: BatchLaneRunReport["lane_revision"];
|
|
947
|
+
if (input.lane_revision) {
|
|
948
|
+
const request = input.lane_revision;
|
|
949
|
+
const fallback = "the Executor requests approve_breaking_intent_revision through its own native gate for the user to answer";
|
|
950
|
+
const refuseRevision = (reason: string) => { revision = { task_id: request.task_id, accepted: false, reason: `batch_delegation_refused: ${reason}`, fallback }; };
|
|
951
|
+
const child = record.children.find((c) => c.task_id === request.task_id);
|
|
952
|
+
const planChild = input.children.find((c) => c.task_id === request.task_id);
|
|
953
|
+
const grant = record.revision_delegation;
|
|
954
|
+
if (!grant) refuseRevision("this batch's confirmation did not delegate Intent revisions");
|
|
955
|
+
else if (!child?.lane || child.state !== "enrolled") refuseRevision(`${request.task_id} is not running in its Lane`);
|
|
956
|
+
else {
|
|
957
|
+
try {
|
|
958
|
+
const authorized = readTaskIntent(input.root, request.task_id, planChild?.intent_path ?? undefined).intent;
|
|
959
|
+
const current = readTaskRecordRaw(child.lane.path, request.task_id).record?.intent_snapshot;
|
|
960
|
+
if (!current) throw new Error(`the Lane has no TaskRecord for ${request.task_id}`);
|
|
961
|
+
const outOfBounds = delegatedRevisionRefusal(authorized, current, request.next_intent);
|
|
962
|
+
if (outOfBounds) refuseRevision(outOfBounds);
|
|
963
|
+
else {
|
|
964
|
+
const confirmationRef = delegatedConfirmationRef(record.batch_id, grant);
|
|
965
|
+
const applied = await applyDelegatedIntentRevision({
|
|
966
|
+
lane_root: child.lane.path,
|
|
967
|
+
task_id: request.task_id,
|
|
968
|
+
next_intent: request.next_intent,
|
|
969
|
+
confirmation_ref: confirmationRef,
|
|
970
|
+
now: input.now,
|
|
971
|
+
});
|
|
972
|
+
record.delegated_revisions = [...(record.delegated_revisions ?? []), { at: input.now, task_id: request.task_id, ...applied, confirmation_ref: confirmationRef }];
|
|
973
|
+
persist();
|
|
974
|
+
revision = { task_id: request.task_id, accepted: true, reason: null };
|
|
975
|
+
}
|
|
976
|
+
} catch (error) {
|
|
977
|
+
refuseRevision(error instanceof Error ? error.message : String(error));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
655
981
|
const refusals: NonNullable<BatchLaneRunReport["lane_refusals"]> = [];
|
|
982
|
+
|
|
656
983
|
// A parked child ends only itself and its dependents; the batch keeps moving
|
|
657
984
|
// for every scope-disjoint sibling and settles needs_human at the end of the
|
|
658
985
|
// tick, when nothing is in flight and nothing can start.
|
|
@@ -695,11 +1022,11 @@ export async function runLaneBatch(
|
|
|
695
1022
|
let fresh: Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
|
|
696
1023
|
try {
|
|
697
1024
|
fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
|
|
698
|
-
} catch {
|
|
699
|
-
fresh = { error:
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
fresh = { error: error instanceof Error ? error.message : String(error) } as unknown as typeof fresh;
|
|
700
1027
|
}
|
|
701
1028
|
if (fresh.error !== null) {
|
|
702
|
-
parkChild(child.task_id,
|
|
1029
|
+
parkChild(child.task_id, laneUnreadableReason(String(fresh.error)));
|
|
703
1030
|
continue;
|
|
704
1031
|
}
|
|
705
1032
|
if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
|
|
@@ -733,6 +1060,8 @@ export async function runLaneBatch(
|
|
|
733
1060
|
parkChild(child.task_id, "claim held by another batch");
|
|
734
1061
|
continue;
|
|
735
1062
|
}
|
|
1063
|
+
// A child waiting for its coordinator correction gets no further attempt.
|
|
1064
|
+
if (child.correction_due) continue;
|
|
736
1065
|
// An unfrozen child, or one with QA pending the Executor's own attempt,
|
|
737
1066
|
// is the Executor's to move; the batch never reruns that attempt.
|
|
738
1067
|
if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen") continue;
|
|
@@ -754,11 +1083,24 @@ export async function runLaneBatch(
|
|
|
754
1083
|
const failures = child.qa_failures + 1;
|
|
755
1084
|
if (failures >= record.budget.qa_failure_limit) {
|
|
756
1085
|
const reason = "QA failure limit reached";
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1086
|
+
const used = child.corrections ?? 0;
|
|
1087
|
+
if (used < MAX_COORDINATOR_CORRECTIONS) {
|
|
1088
|
+
// Hand the design judgment to the coordinator instead of parking: one
|
|
1089
|
+
// correction, sent to a new Lane session, before any further attempt.
|
|
1090
|
+
record.children = record.children.map((c) =>
|
|
1091
|
+
c.task_id === child.task_id ? { ...c, qa_failures: failures, correction_due: reason } : c,
|
|
1092
|
+
);
|
|
1093
|
+
persist();
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
const parked = `${CORRECTION_LIMIT_REACHED}: ${reason} after ${used} coordinator corrections`;
|
|
1097
|
+
record.children = record.children.map((c) => {
|
|
1098
|
+
if (c.task_id !== child.task_id) return c;
|
|
1099
|
+
const { correction_due: _due, ...rest } = c;
|
|
1100
|
+
return { ...rest, state: "needs_human", reason: parked, qa_failures: failures };
|
|
1101
|
+
});
|
|
760
1102
|
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
761
|
-
parkedMessage ??=
|
|
1103
|
+
parkedMessage ??= parked;
|
|
762
1104
|
persist();
|
|
763
1105
|
continue;
|
|
764
1106
|
}
|
|
@@ -828,6 +1170,9 @@ export async function runLaneBatch(
|
|
|
828
1170
|
record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit, reason: null } : c));
|
|
829
1171
|
record.commits = [...record.commits, commit];
|
|
830
1172
|
persist();
|
|
1173
|
+
// The integration is durable before any reseal; a reseal that cannot be
|
|
1174
|
+
// computed leaves plan_digest as it was, so the change fails closed later.
|
|
1175
|
+
if (resealDelegatedRevision(input, record, child.task_id)) persist();
|
|
831
1176
|
} catch (error) {
|
|
832
1177
|
const message = error instanceof Error ? error.message : String(error);
|
|
833
1178
|
const reason = error instanceof BatchIntegrationError ? error.reason : "batch_integration_conflict";
|
|
@@ -842,6 +1187,9 @@ export async function runLaneBatch(
|
|
|
842
1187
|
}
|
|
843
1188
|
}
|
|
844
1189
|
|
|
1190
|
+
// Close each integrated child's Issue now that its commit is on the batch branch.
|
|
1191
|
+
const tracker = await closeIntegratedIssues(input, record, persist);
|
|
1192
|
+
|
|
845
1193
|
// 3. Admit offered Lanes, then enroll each admitted child into its Lane.
|
|
846
1194
|
const coordinatorReal = lanes.resolveRoot(input.root);
|
|
847
1195
|
for (const offer of input.lane_offers ?? []) {
|
|
@@ -875,16 +1223,39 @@ export async function runLaneBatch(
|
|
|
875
1223
|
|
|
876
1224
|
// 4. Schedule and report. Handoffs are observations, never readiness.
|
|
877
1225
|
if (record.children.every((c) => c.state === "integrated" || c.state === "released")) {
|
|
1226
|
+
// Full verification of the integrated batch branch, before the immutable
|
|
1227
|
+
// completion report is written. A failure is reported, never rolled back.
|
|
1228
|
+
const final = record.final_verification?.length ? await runFinalVerification(input.root, record.final_verification) : undefined;
|
|
878
1229
|
record.batch_state = "completed";
|
|
879
1230
|
persist();
|
|
880
|
-
return finalizeLane(
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1231
|
+
return finalizeLane(
|
|
1232
|
+
input.root,
|
|
1233
|
+
record,
|
|
1234
|
+
final && !final.passed
|
|
1235
|
+
? `all enrollable children integrated; final verification did not pass: ${final.results.filter((r) => !r.passed).map((r) => r.command).join(", ")}`
|
|
1236
|
+
: "all enrollable children integrated",
|
|
1237
|
+
final && !final.passed
|
|
1238
|
+
? "Every child is integrated but the batch did not pass its final verification; inspect the failing commands on the batch branch. Integrated commits were not rolled back."
|
|
1239
|
+
: "",
|
|
1240
|
+
{ handoffs: releaseHandoffs(input, record, lanes), refusals, tracker, instruction, revision, ...(final ? { final } : {}) },
|
|
1241
|
+
);
|
|
884
1242
|
}
|
|
885
1243
|
const handoffs: BatchLaneHandoff[] = [];
|
|
886
1244
|
for (const child of record.children) {
|
|
887
1245
|
if (child.state !== "enrolled" || !child.lane) continue;
|
|
1246
|
+
if (child.correction_due) {
|
|
1247
|
+
// No further Executor attempt until the coordinator's correction is recorded.
|
|
1248
|
+
handoffs.push({
|
|
1249
|
+
role: "coordinator",
|
|
1250
|
+
action: "correct",
|
|
1251
|
+
task_id: child.task_id,
|
|
1252
|
+
reason: child.correction_due,
|
|
1253
|
+
corrections_used: child.corrections ?? 0,
|
|
1254
|
+
corrections_left: MAX_COORDINATOR_CORRECTIONS - (child.corrections ?? 0),
|
|
1255
|
+
lane_path: child.lane.path,
|
|
1256
|
+
});
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
888
1259
|
const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
|
|
889
1260
|
if (fresh.error !== null) continue;
|
|
890
1261
|
handoffs.push({
|
|
@@ -910,7 +1281,7 @@ export async function runLaneBatch(
|
|
|
910
1281
|
record,
|
|
911
1282
|
parkedMessage ?? (parked ? "a parked child needs a human decision" : overBudget ? `max_children budget exhausted (${record.budget.max_children})` : "no child can start"),
|
|
912
1283
|
"",
|
|
913
|
-
{ refusals },
|
|
1284
|
+
{ refusals, tracker, instruction, revision },
|
|
914
1285
|
);
|
|
915
1286
|
}
|
|
916
1287
|
if (!overBudget) {
|
|
@@ -932,15 +1303,17 @@ export async function runLaneBatch(
|
|
|
932
1303
|
? `child ${reviewOpen[0]} holds an open Review reservation`
|
|
933
1304
|
: `children ${reviewOpen.join(", ")} hold open Review reservations`,
|
|
934
1305
|
"Submit each reserved foreground Review verdict in its own Lane, then call start_unattended_batch again to continue.",
|
|
935
|
-
{ handoffs, refusals },
|
|
1306
|
+
{ handoffs, refusals, tracker, instruction, revision },
|
|
936
1307
|
);
|
|
937
1308
|
}
|
|
938
1309
|
return laneReport(
|
|
939
1310
|
record,
|
|
940
1311
|
null,
|
|
941
|
-
handoffs.some((h) => h.role === "
|
|
942
|
-
? "
|
|
943
|
-
:
|
|
944
|
-
|
|
1312
|
+
handoffs.some((h) => h.role === "coordinator")
|
|
1313
|
+
? "For each coordinator handoff, read every round's findings in that Lane, start a new Lane session and send one design-level correction as lane_instruction kind correction; run the other handoffs as usual."
|
|
1314
|
+
: handoffs.some((h) => h.role === "lane-steward" && h.action === "provision")
|
|
1315
|
+
? "Provide a Lane for each provision handoff, then call start_unattended_batch again with lane_offers."
|
|
1316
|
+
: "Run each executor handoff in its Lane. When a Lane finishes, call start_unattended_batch again with the same Initiative.",
|
|
1317
|
+
{ handoffs, refusals, tracker, instruction, revision },
|
|
945
1318
|
);
|
|
946
1319
|
}
|