immune-brain 3.6.6 → 3.6.7
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 +24 -3
- package/README.zh-CN.md +23 -3
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
- package/plugins/immune-brain/dist/imm-loop.md +22 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
- package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
// Serial batch run driver for unattended Initiative batch runs.
|
|
2
|
+
// Every batch and child state transition lives here and in
|
|
3
|
+
// runtime/kernel/batch_authority.ts; Host adapters are callers only and this
|
|
4
|
+
// module imports no Host adapter (Invariant H-1).
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
BatchAuthorizationExpiryError,
|
|
10
|
+
type BatchAuthorityRegistry,
|
|
11
|
+
type ValidatedBatchAuthorization,
|
|
12
|
+
computeBatchPlanDigest,
|
|
13
|
+
} from "../kernel/batch_authority";
|
|
14
|
+
import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
15
|
+
import type { BatchPlanChild } from "./types";
|
|
16
|
+
import {
|
|
17
|
+
type BatchRunnerGitPort,
|
|
18
|
+
type BatchGitPreflightResult,
|
|
19
|
+
runBatchGitPreflight,
|
|
20
|
+
commitBatchChild,
|
|
21
|
+
lookupBatchCommit,
|
|
22
|
+
} from "./batch_git";
|
|
23
|
+
import {
|
|
24
|
+
type BatchRunStateRecord,
|
|
25
|
+
type BatchChildRun,
|
|
26
|
+
type BatchRunReport,
|
|
27
|
+
isTerminalBatchState,
|
|
28
|
+
prepareBatchRunState,
|
|
29
|
+
readBatchRunState,
|
|
30
|
+
writeBatchRunState,
|
|
31
|
+
writeBatchRunReport,
|
|
32
|
+
} from "./batch_state";
|
|
33
|
+
|
|
34
|
+
/** Kernel-facing ports the driver needs. Host adapters supply these. */
|
|
35
|
+
export interface BatchRunnerKernelPort {
|
|
36
|
+
/** Enroll one child through the batch-derived capability. */
|
|
37
|
+
enrollTask(input: {
|
|
38
|
+
root: string;
|
|
39
|
+
task_id: string;
|
|
40
|
+
batch: {
|
|
41
|
+
registry: BatchAuthorityRegistry;
|
|
42
|
+
capability: object;
|
|
43
|
+
binding: { batch_id: string; expected_head: string };
|
|
44
|
+
};
|
|
45
|
+
}): Promise<{ record_revision: string }>;
|
|
46
|
+
/** Drive one enrolled child toward its own Kernel terminal settlement. */
|
|
47
|
+
advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
|
|
48
|
+
/** Scope-bound commit after Kernel reports a child done. */
|
|
49
|
+
commitChild?(
|
|
50
|
+
root: string,
|
|
51
|
+
taskId: string,
|
|
52
|
+
batchId: string,
|
|
53
|
+
head: string,
|
|
54
|
+
branch?: string,
|
|
55
|
+
intentPath?: string,
|
|
56
|
+
): Promise<{ commit: string }>;
|
|
57
|
+
/** review-3(5th round): read the already-created batch commit for a child,
|
|
58
|
+
* or null when none exists. Lets crash recovery adopt an existing commit
|
|
59
|
+
* instead of replaying the commitChild mutation. */
|
|
60
|
+
lookupBatchCommit?(
|
|
61
|
+
root: string,
|
|
62
|
+
taskId: string,
|
|
63
|
+
batchId: string,
|
|
64
|
+
expectedHead?: string,
|
|
65
|
+
branch?: string,
|
|
66
|
+
): Promise<{ commit: string } | null>;
|
|
67
|
+
/** Optional Git preflight check. */
|
|
68
|
+
gitPreflight?(input: {
|
|
69
|
+
root: string;
|
|
70
|
+
initiative_slug: string;
|
|
71
|
+
base_head: string;
|
|
72
|
+
}): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
|
|
73
|
+
/** review-2(5th round): read-only claim projection for enrollment
|
|
74
|
+
* reconciliation after an interruption between enrollTask and its state
|
|
75
|
+
* persistence. Uses the real AssuranceProjectionResult contract; batch
|
|
76
|
+
* ownership of the claim is verified separately through the authoritative
|
|
77
|
+
* batch registry, because the Kernel claim carries no batch id. */
|
|
78
|
+
projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
|
|
79
|
+
/** Authoritative batch ownership check: true when the task's Kernel claim
|
|
80
|
+
* is held under this batch's derived capability (consumed child slot). */
|
|
81
|
+
ownsTaskClaim(taskId: string): boolean;
|
|
82
|
+
/** review-8(4th rework): Kernel-side proof that a replacement parked-batch
|
|
83
|
+
* authorization is genuine, unexpired, and bound to this exact batch_id,
|
|
84
|
+
* plan_digest, and base_head, validated against the Kernel's authoritative
|
|
85
|
+
* binding state. Throws on fabrication, mismatch, or expiry; the driver
|
|
86
|
+
* must call this before accepting a parked-batch resume. */
|
|
87
|
+
validateBatchAuthorization(input: {
|
|
88
|
+
registry: BatchAuthorityRegistry;
|
|
89
|
+
capability: object;
|
|
90
|
+
binding: Pick<ValidatedBatchAuthorization,
|
|
91
|
+
"batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget" | "expires_at">;
|
|
92
|
+
}): ValidatedBatchAuthorization;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type BatchChildAdvanceResult =
|
|
96
|
+
| { state: "completed" }
|
|
97
|
+
| { state: "stopped" }
|
|
98
|
+
| { state: "failed"; reason: string }
|
|
99
|
+
| { state: "rework"; operation: "qa" | "review"; summary: string }
|
|
100
|
+
| { state: "blocked"; reason: string }
|
|
101
|
+
| { state: "review_ready"; operation_id: string };
|
|
102
|
+
|
|
103
|
+
export interface StartBatchInput {
|
|
104
|
+
root: string;
|
|
105
|
+
batch_id: string;
|
|
106
|
+
initiative_slug: string;
|
|
107
|
+
registry: BatchAuthorityRegistry;
|
|
108
|
+
capability: object;
|
|
109
|
+
children: BatchPlanChild[];
|
|
110
|
+
plan_digest: string;
|
|
111
|
+
base_head: string;
|
|
112
|
+
confirmation_time: string;
|
|
113
|
+
authorization_expires_at: string;
|
|
114
|
+
budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
|
|
115
|
+
now: string;
|
|
116
|
+
kernel: BatchRunnerKernelPort;
|
|
117
|
+
git?: BatchRunnerGitPort;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type { BatchRunReport } from "./batch_state";
|
|
121
|
+
|
|
122
|
+
function dependentsOf(record: BatchRunStateRecord, taskId: string): BatchChildRun[] {
|
|
123
|
+
return record.children.filter((child) => child.blocked_by.includes(taskId));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function skipDependents(record: BatchRunStateRecord, taskId: string, reason: string): void {
|
|
127
|
+
// review-1(5th round): traverse the full transitive dependency closure —
|
|
128
|
+
// parking A must skip B *and* C in A -> B -> C, not only direct dependents.
|
|
129
|
+
const skip = new Set<string>([taskId]);
|
|
130
|
+
const queue = [taskId];
|
|
131
|
+
while (queue.length > 0) {
|
|
132
|
+
const current = queue.shift()!;
|
|
133
|
+
for (const dependent of dependentsOf(record, current)) {
|
|
134
|
+
if (skip.has(dependent.task_id)) continue;
|
|
135
|
+
skip.add(dependent.task_id);
|
|
136
|
+
queue.push(dependent.task_id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
record.children = record.children.map((child) =>
|
|
140
|
+
skip.has(child.task_id) && child.state === "pending"
|
|
141
|
+
? { ...child, state: "skipped_blocked", reason } : child,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function budgetStopReason(record: BatchRunStateRecord, now: number): string | null {
|
|
146
|
+
// review-3: budget counts enrollments consumed (children that left
|
|
147
|
+
// pending), not commits, so parked children still consume authorization.
|
|
148
|
+
const enrolledCount = record.children.filter(
|
|
149
|
+
(child) => child.state !== "pending" && child.state !== "skipped_blocked",
|
|
150
|
+
).length;
|
|
151
|
+
if (enrolledCount >= record.budget.max_children)
|
|
152
|
+
return `max_children budget exhausted (${record.budget.max_children})`;
|
|
153
|
+
const deadline = Date.parse(record.budget.deadline_at);
|
|
154
|
+
if (!Number.isNaN(deadline) && now >= deadline)
|
|
155
|
+
return `deadline_at reached (${record.budget.deadline_at})`;
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** review-7(3rd rework): the typed expiry marker now lives at the Kernel
|
|
160
|
+
* boundary (kernel/batch_authority.ts) so a real enrollment-time expiry is
|
|
161
|
+
* structurally classifiable as an intentional budget stop; re-exported here
|
|
162
|
+
* for driver API compatibility. */
|
|
163
|
+
export { BatchAuthorizationExpiryError };
|
|
164
|
+
|
|
165
|
+
function isAuthorizationExpiryError(error: unknown): boolean {
|
|
166
|
+
return error instanceof BatchAuthorizationExpiryError;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function requireFreshProjection(
|
|
170
|
+
result: AssuranceProjectionResult,
|
|
171
|
+
taskId: string,
|
|
172
|
+
): AssuranceProjectionResult & { error: null } {
|
|
173
|
+
if (result.error !== null)
|
|
174
|
+
throw new Error(`cannot reconcile Kernel projection for ${taskId}: ${result.error}`);
|
|
175
|
+
return result as AssuranceProjectionResult & { error: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Next pending child whose direct dependents are all committed. */
|
|
179
|
+
function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null {
|
|
180
|
+
return (
|
|
181
|
+
record.children.find(
|
|
182
|
+
(child) =>
|
|
183
|
+
child.state === "pending" &&
|
|
184
|
+
record.children.every(
|
|
185
|
+
(other) => !child.blocked_by.includes(other.task_id) || other.state === "committed",
|
|
186
|
+
),
|
|
187
|
+
) ?? null
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
|
|
192
|
+
completed: "The batch settled every enrollable child; review the commits and the tracker.",
|
|
193
|
+
budget_stopped: "Budget, deadline, or authorization expiry stopped new enrollments; re-confirm to continue under a new authorization.",
|
|
194
|
+
failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
|
|
195
|
+
rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
|
|
196
|
+
needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
|
|
197
|
+
running: "The batch is still running; no terminal report is due yet.",
|
|
198
|
+
prepared: "The batch is prepared but not started.",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function reportFor(
|
|
202
|
+
record: BatchRunStateRecord,
|
|
203
|
+
reason: string | null,
|
|
204
|
+
nextAction: string,
|
|
205
|
+
): BatchRunReport {
|
|
206
|
+
return {
|
|
207
|
+
contract: "assurance_kernel/batch_run_report/v1",
|
|
208
|
+
batch_id: record.batch_id,
|
|
209
|
+
initiative_slug: record.initiative_slug,
|
|
210
|
+
batch_state: record.batch_state,
|
|
211
|
+
children: record.children,
|
|
212
|
+
commits: record.commits,
|
|
213
|
+
reason,
|
|
214
|
+
next_action: nextAction || (TERMINAL_NEXT_ACTIONS[record.batch_state] ?? "Inspect the batch run state."),
|
|
215
|
+
created_at: record.updated_at,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** review-3(6th round): persist the single run report when the batch
|
|
220
|
+
* stops — including a needs_human park, which ends the current run even
|
|
221
|
+
* though the batch remains resumable after a human decision. */
|
|
222
|
+
function finalize(
|
|
223
|
+
root: string,
|
|
224
|
+
record: BatchRunStateRecord,
|
|
225
|
+
reason: string | null,
|
|
226
|
+
nextAction: string,
|
|
227
|
+
): BatchRunReport {
|
|
228
|
+
const report = reportFor(record, reason, nextAction);
|
|
229
|
+
if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human") {
|
|
230
|
+
writeBatchRunReport(root, report);
|
|
231
|
+
}
|
|
232
|
+
return report;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** review round 8: only lineage breaks (external branch switch / HEAD
|
|
236
|
+
* regression) on a persisted record fail the batch with a persisted report;
|
|
237
|
+
* fabricated records keep throwing with zero writes. */
|
|
238
|
+
function isLineageBreakError(message: string): boolean {
|
|
239
|
+
return message.includes("batch_head_lineage_broken");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** review round 9: a lineage-broken persisted batch may hold in-flight
|
|
243
|
+
* children; failed batches reject enrolled/settled children, so transition
|
|
244
|
+
* them to needs_human and skip their pending dependents before persisting. */
|
|
245
|
+
function failPersistedLineage(
|
|
246
|
+
root: string,
|
|
247
|
+
existing: BatchRunStateRecord,
|
|
248
|
+
message: string,
|
|
249
|
+
): BatchRunStateRecord {
|
|
250
|
+
const children = existing.children.map((c) =>
|
|
251
|
+
c.state === "enrolled" || c.state === "settled"
|
|
252
|
+
? { ...c, state: "needs_human" as const, reason: message }
|
|
253
|
+
: c,
|
|
254
|
+
);
|
|
255
|
+
const record: BatchRunStateRecord = { ...existing, batch_state: "failed", children };
|
|
256
|
+
for (const child of record.children) {
|
|
257
|
+
if (child.state === "needs_human") {
|
|
258
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return writeBatchRunState(root, record);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** review round 11: detect external HEAD movement or branch switch against a
|
|
265
|
+
* persisted record's expected lineage; returns the failure message or null. */
|
|
266
|
+
function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): string | null {
|
|
267
|
+
if (!existsSync(join(root, ".git"))) return null;
|
|
268
|
+
const head = record.commits.length
|
|
269
|
+
? record.commits[record.commits.length - 1]!
|
|
270
|
+
: record.base_head;
|
|
271
|
+
const headCheck = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
274
|
+
});
|
|
275
|
+
if (headCheck.status === 0 && headCheck.stdout.trim() && headCheck.stdout.trim() !== head) {
|
|
276
|
+
return `batch_head_lineage_broken: current HEAD ${headCheck.stdout.trim()} does not match expected batch head ${head}`;
|
|
277
|
+
}
|
|
278
|
+
const branchCheck = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
|
|
279
|
+
encoding: "utf8",
|
|
280
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
281
|
+
});
|
|
282
|
+
const currentBranch = branchCheck.stdout.trim();
|
|
283
|
+
if (branchCheck.status !== 0 || currentBranch !== record.branch) {
|
|
284
|
+
return `batch_head_lineage_broken: current branch ${currentBranch} does not match expected batch branch ${record.branch}`;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
|
|
290
|
+
const plan = input.registry.children(input.capability);
|
|
291
|
+
if (record.plan_digest !== computeBatchPlanDigest(plan) ||
|
|
292
|
+
record.children.length !== plan.length || record.children.some((child, index) => {
|
|
293
|
+
const expected = plan[index]!;
|
|
294
|
+
return child.task_id !== expected.task_id ||
|
|
295
|
+
JSON.stringify(child.blocked_by) !== JSON.stringify(expected.blocked_by);
|
|
296
|
+
})) throw new Error("plan_digest mismatch: persisted children do not match the authorized plan");
|
|
297
|
+
const commits: string[] = [];
|
|
298
|
+
for (const child of record.children) {
|
|
299
|
+
if (child.state !== "committed") {
|
|
300
|
+
if (child.commit !== null) throw new Error("uncommitted batch child has a commit");
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const evidence = input.git?.lookupBatchCommit
|
|
304
|
+
? await input.git.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
|
|
305
|
+
: input.kernel.lookupBatchCommit
|
|
306
|
+
? await input.kernel.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
|
|
307
|
+
: await lookupBatchCommit({
|
|
308
|
+
root: input.root,
|
|
309
|
+
taskId: child.task_id,
|
|
310
|
+
batchId: record.batch_id,
|
|
311
|
+
branch: record.branch,
|
|
312
|
+
});
|
|
313
|
+
if (!evidence || evidence.commit !== child.commit) {
|
|
314
|
+
// Distinguish an unreachable recorded commit (external HEAD regression)
|
|
315
|
+
// from a fabricated record: reachability is only checkable in a real repo.
|
|
316
|
+
if (
|
|
317
|
+
evidence === null && typeof child.commit === "string" && child.commit.length > 0 &&
|
|
318
|
+
existsSync(join(input.root, ".git"))
|
|
319
|
+
) {
|
|
320
|
+
const reach = spawnSync(
|
|
321
|
+
"git",
|
|
322
|
+
["-C", input.root, "merge-base", "--is-ancestor", child.commit, "HEAD"],
|
|
323
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
324
|
+
);
|
|
325
|
+
if (reach.status !== 0) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`batch_head_lineage_broken: recorded commit ${child.commit} for ${child.task_id} is no longer reachable from HEAD`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`persisted batch commit lacks evidence for ${child.task_id}`);
|
|
332
|
+
}
|
|
333
|
+
commits.push(evidence.commit);
|
|
334
|
+
}
|
|
335
|
+
if (JSON.stringify([...record.commits].sort()) !== JSON.stringify(commits.sort()))
|
|
336
|
+
throw new Error("persisted batch commit list does not match child evidence");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** review-6: restate a terminal record and ensure its report exists. */
|
|
340
|
+
function replayTerminal(
|
|
341
|
+
root: string,
|
|
342
|
+
existing: BatchRunStateRecord,
|
|
343
|
+
): BatchRunReport {
|
|
344
|
+
return finalize(
|
|
345
|
+
root,
|
|
346
|
+
existing,
|
|
347
|
+
`terminal state already reached: ${existing.batch_state}`,
|
|
348
|
+
"",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): void {
|
|
353
|
+
const authorized = input.kernel.validateBatchAuthorization({
|
|
354
|
+
registry: input.registry,
|
|
355
|
+
capability: input.capability,
|
|
356
|
+
binding: {
|
|
357
|
+
batch_id: input.batch_id,
|
|
358
|
+
plan_digest: existing?.plan_digest ?? input.plan_digest,
|
|
359
|
+
base_head: existing?.base_head ?? input.base_head,
|
|
360
|
+
initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
|
|
361
|
+
budget: input.budget,
|
|
362
|
+
expires_at: input.authorization_expires_at,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
if (authorized.issued_at !== input.confirmation_time ||
|
|
366
|
+
(existing && Date.parse(authorized.issued_at) <= Date.parse(existing.confirmation_time)))
|
|
367
|
+
throw new Error("the parked batch requires a fresh literal-user confirmation");
|
|
368
|
+
const children = input.children.map((child) => {
|
|
369
|
+
const { intent_path, intent_revision, intent_content_hash } = child;
|
|
370
|
+
if (intent_path === null || intent_revision === null || intent_content_hash === null)
|
|
371
|
+
throw new Error(`batch child ${child.task_id} has no complete intent identity`);
|
|
372
|
+
return { ...child, intent_path, intent_revision, intent_content_hash };
|
|
373
|
+
});
|
|
374
|
+
if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
|
|
375
|
+
input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
|
|
376
|
+
throw new Error("batch run input does not match the authorized plan or base_head");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
|
|
380
|
+
// Validation failure before the first enrollment: zero writes, rejected.
|
|
381
|
+
// reportFor only builds the report object; finalize would persist it and
|
|
382
|
+
// the spec forbids any write on a pre-enrollment rejection.
|
|
383
|
+
if (!input.children.length) {
|
|
384
|
+
const rejected = prepareBatchRunState({ ...input, children: [], now: input.now });
|
|
385
|
+
return reportFor(
|
|
386
|
+
{ ...rejected, batch_state: "rejected" },
|
|
387
|
+
"batch plan is empty",
|
|
388
|
+
"Provide a non-empty enrollable child plan.",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let existing = readBatchRunState(input.root, input.batch_id);
|
|
393
|
+
if (existing) {
|
|
394
|
+
try {
|
|
395
|
+
await validatePersistedRun(input, existing);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
398
|
+
if (isLineageBreakError(message)) {
|
|
399
|
+
if (isTerminalBatchState(existing.batch_state)) return replayTerminal(input.root, existing);
|
|
400
|
+
const failed = failPersistedLineage(input.root, existing, message);
|
|
401
|
+
return finalize(input.root, failed, message, "");
|
|
402
|
+
}
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (existing && isTerminalBatchState(existing.batch_state)) {
|
|
407
|
+
// Idempotent terminal replay: no state mutation, ensure the report.
|
|
408
|
+
return replayTerminal(input.root, existing);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (existing?.batch_state === "needs_human") {
|
|
412
|
+
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
413
|
+
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
414
|
+
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
415
|
+
const freshAuthorization =
|
|
416
|
+
Number.isFinite(nextConfirmation) &&
|
|
417
|
+
nextConfirmation > priorConfirmation &&
|
|
418
|
+
Number.isFinite(nextExpiry) &&
|
|
419
|
+
nextExpiry > Date.now();
|
|
420
|
+
if (!freshAuthorization) {
|
|
421
|
+
return finalize(
|
|
422
|
+
input.root,
|
|
423
|
+
existing,
|
|
424
|
+
"the parked batch requires a fresh literal-user confirmation",
|
|
425
|
+
"Resolve the parked child, then re-confirm the batch to continue.",
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
validateRunAuthorization(input, existing);
|
|
430
|
+
prepareBatchRunState({ ...input, now: input.now });
|
|
431
|
+
const remapped = await Promise.all(
|
|
432
|
+
existing.children.map(async (child) => {
|
|
433
|
+
if (child.state !== "needs_human") return child;
|
|
434
|
+
const fresh = requireFreshProjection(
|
|
435
|
+
await input.kernel.projectTask(input.root, child.task_id),
|
|
436
|
+
child.task_id,
|
|
437
|
+
);
|
|
438
|
+
if (
|
|
439
|
+
fresh.projection.lifecycle === "done" &&
|
|
440
|
+
fresh.projection.completion_ready
|
|
441
|
+
)
|
|
442
|
+
return { ...child, state: "settled" as const, reason: null };
|
|
443
|
+
if (fresh.error === null && fresh.claim?.task_id === child.task_id) {
|
|
444
|
+
return input.kernel.ownsTaskClaim(child.task_id)
|
|
445
|
+
? { ...child, state: "enrolled" as const, reason: null }
|
|
446
|
+
: child;
|
|
447
|
+
}
|
|
448
|
+
return { ...child, state: "pending" as const, reason: null };
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
// A parked child whose Kernel claim was re-bound to a foreign batch
|
|
452
|
+
// keeps the batch parked: resuming would select an independent sibling
|
|
453
|
+
// while that claim is still open.
|
|
454
|
+
if (remapped.some((child) => child.state === "needs_human")) {
|
|
455
|
+
// review-1(7th round): a re-parked foreign-claim child re-marks its
|
|
456
|
+
// transitive dependents skipped_blocked at re-park time, healing a
|
|
457
|
+
// prior park site that missed the marking. Only pending dependents
|
|
458
|
+
// are touched; already-skipped and terminal children stay as-is.
|
|
459
|
+
const reparked: BatchRunStateRecord = { ...existing, children: remapped };
|
|
460
|
+
for (const parked of remapped) {
|
|
461
|
+
if (parked.state === "needs_human")
|
|
462
|
+
skipDependents(
|
|
463
|
+
reparked,
|
|
464
|
+
parked.task_id,
|
|
465
|
+
`dependency ${parked.task_id} parked`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
existing = writeBatchRunState(input.root, {
|
|
469
|
+
...reparked,
|
|
470
|
+
confirmation_time: input.confirmation_time,
|
|
471
|
+
authorization_expires_at: input.authorization_expires_at,
|
|
472
|
+
budget: input.budget,
|
|
473
|
+
batch_state: "needs_human",
|
|
474
|
+
});
|
|
475
|
+
return finalize(
|
|
476
|
+
input.root,
|
|
477
|
+
existing,
|
|
478
|
+
"a parked child's Kernel claim is held by a foreign batch",
|
|
479
|
+
"Resolve the foreign claim, then re-confirm the batch to continue.",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const resumedChildren = remapped.map((child) =>
|
|
483
|
+
child.state === "skipped_blocked"
|
|
484
|
+
? { ...child, state: "pending" as const, reason: null }
|
|
485
|
+
: child,
|
|
486
|
+
);
|
|
487
|
+
existing = writeBatchRunState(input.root, {
|
|
488
|
+
...existing,
|
|
489
|
+
confirmation_time: input.confirmation_time,
|
|
490
|
+
authorization_expires_at: input.authorization_expires_at,
|
|
491
|
+
budget: input.budget,
|
|
492
|
+
batch_state: "running",
|
|
493
|
+
consecutive_qa_failures: 0,
|
|
494
|
+
children: resumedChildren,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// A running batch can outlive its authorization while it waits on a child's
|
|
499
|
+
// reserved foreground Review. The driver owns every batch state transition,
|
|
500
|
+
// so the renewal the Host bound into the capability must be adopted here;
|
|
501
|
+
// otherwise the record keeps the expired stamp and the next child enrollment
|
|
502
|
+
// stops the batch as budget_stopped even though the literal user just
|
|
503
|
+
// re-confirmed it. Renewal requires the same proof the parked path requires:
|
|
504
|
+
// a strictly newer literal-user confirmation and a later, still-valid expiry.
|
|
505
|
+
if (existing?.batch_state === "running") {
|
|
506
|
+
const nextConfirmation = Date.parse(input.confirmation_time);
|
|
507
|
+
const nextExpiry = Date.parse(input.authorization_expires_at);
|
|
508
|
+
const priorConfirmation = Date.parse(existing.confirmation_time);
|
|
509
|
+
const persistedExpiry = Date.parse(existing.authorization_expires_at);
|
|
510
|
+
const renewedAuthorization =
|
|
511
|
+
Number.isFinite(nextConfirmation) &&
|
|
512
|
+
nextConfirmation > priorConfirmation &&
|
|
513
|
+
Number.isFinite(nextExpiry) &&
|
|
514
|
+
nextExpiry > Date.now() &&
|
|
515
|
+
nextExpiry > persistedExpiry;
|
|
516
|
+
if (renewedAuthorization) {
|
|
517
|
+
validateRunAuthorization(input, existing);
|
|
518
|
+
existing = writeBatchRunState(input.root, {
|
|
519
|
+
...existing,
|
|
520
|
+
confirmation_time: input.confirmation_time,
|
|
521
|
+
authorization_expires_at: input.authorization_expires_at,
|
|
522
|
+
budget: input.budget,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// review-2(6th round): an enrolled or settled child from an interrupted
|
|
528
|
+
// run must be driven to its own terminal settlement and commit before any
|
|
529
|
+
// new child is selected, or startBatch would double-claim the task or
|
|
530
|
+
// falsely mark the batch completed while a Kernel claim is still open.
|
|
531
|
+
// resumeBatch drives that child to a terminal transition and then falls
|
|
532
|
+
// through to this function's normal loop, so the delegation terminates.
|
|
533
|
+
if (existing) {
|
|
534
|
+
const interruptedChild = existing.children.find(
|
|
535
|
+
(child) => child.state === "enrolled" || child.state === "settled",
|
|
536
|
+
);
|
|
537
|
+
if (interruptedChild) {
|
|
538
|
+
return await resumeBatch(input, (root, taskId) =>
|
|
539
|
+
input.kernel.projectTask(root, taskId),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!existing) {
|
|
545
|
+
try {
|
|
546
|
+
validateRunAuthorization(input, null);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
return reportFor(
|
|
549
|
+
{ ...prepareBatchRunState(input), batch_state: "rejected" },
|
|
550
|
+
error instanceof Error ? error.message : String(error),
|
|
551
|
+
"Correct the authorization or plan, then re-confirm the batch.",
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Mandatory batch branch preflight: run before any child is enrolled.
|
|
556
|
+
const preflightResult = input.git?.preflight
|
|
557
|
+
? await input.git.preflight({
|
|
558
|
+
root: input.root,
|
|
559
|
+
initiative_slug: input.initiative_slug,
|
|
560
|
+
base_head: input.base_head,
|
|
561
|
+
})
|
|
562
|
+
: input.kernel.gitPreflight
|
|
563
|
+
? await input.kernel.gitPreflight({
|
|
564
|
+
root: input.root,
|
|
565
|
+
initiative_slug: input.initiative_slug,
|
|
566
|
+
base_head: input.base_head,
|
|
567
|
+
})
|
|
568
|
+
: runBatchGitPreflight({
|
|
569
|
+
root: input.root,
|
|
570
|
+
initiative_slug: input.initiative_slug,
|
|
571
|
+
base_head: input.base_head,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (!preflightResult.ok) {
|
|
575
|
+
const rejected = prepareBatchRunState({ ...input, now: input.now });
|
|
576
|
+
return reportFor(
|
|
577
|
+
{ ...rejected, batch_state: "rejected" },
|
|
578
|
+
preflightResult.reason,
|
|
579
|
+
preflightResult.message || "Correct the preflight condition and re-confirm.",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
let record: BatchRunStateRecord =
|
|
584
|
+
existing ??
|
|
585
|
+
prepareBatchRunState({
|
|
586
|
+
batch_id: input.batch_id,
|
|
587
|
+
initiative_slug: input.initiative_slug,
|
|
588
|
+
children: input.children,
|
|
589
|
+
plan_digest: input.plan_digest,
|
|
590
|
+
base_head: input.base_head,
|
|
591
|
+
confirmation_time: input.confirmation_time,
|
|
592
|
+
authorization_expires_at: input.authorization_expires_at,
|
|
593
|
+
budget: input.budget,
|
|
594
|
+
now: input.now,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const persist = (): void => {
|
|
598
|
+
record = writeBatchRunState(input.root, record);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
if (record.batch_state === "prepared") {
|
|
602
|
+
record.batch_state = "running";
|
|
603
|
+
persist();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
let head = record.commits.length
|
|
607
|
+
? record.commits[record.commits.length - 1]!
|
|
608
|
+
: record.base_head;
|
|
609
|
+
|
|
610
|
+
// Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
|
|
611
|
+
const driftMessage = externalHeadDriftMessage(input.root, record);
|
|
612
|
+
if (driftMessage) {
|
|
613
|
+
record.batch_state = "failed";
|
|
614
|
+
persist();
|
|
615
|
+
return finalize(input.root, record, driftMessage, "");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
while (record.batch_state === "running") {
|
|
619
|
+
const child = nextEnrollableChild(record);
|
|
620
|
+
if (!child) {
|
|
621
|
+
record.batch_state = record.children.some(
|
|
622
|
+
(c) => c.state === "needs_human" || c.state === "skipped_blocked",
|
|
623
|
+
)
|
|
624
|
+
? "needs_human"
|
|
625
|
+
: "completed";
|
|
626
|
+
persist();
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Enroll through the batch-derived capability.
|
|
631
|
+
// review-1(2nd round): the child is marked enrolled only after
|
|
632
|
+
// enrollTask succeeds, so an interruption before enrollment leaves the
|
|
633
|
+
// child pending and resume never fabricates a Kernel claim.
|
|
634
|
+
// review-2(5th round): a crash between durable enrollTask and the
|
|
635
|
+
// enrolled-persist leaves the child persisted as pending. Re-enrolling
|
|
636
|
+
// would double-claim, so reconcile against a fresh Kernel projection
|
|
637
|
+
// first: a pending child that already holds this batch's claim is
|
|
638
|
+
// adopted as enrolled without a second enrollment mutation.
|
|
639
|
+
// review-2(5th round): a crash between durable enrollTask and the
|
|
640
|
+
// enrolled-persist leaves the child persisted as pending. Re-enrolling
|
|
641
|
+
// would double-claim, so reconcile against a fresh Kernel projection
|
|
642
|
+
// first: a pending child that already holds this batch's claim is
|
|
643
|
+
// adopted as enrolled without a second enrollment mutation.
|
|
644
|
+
// review-1(6th round): a pending child whose claim is held by another
|
|
645
|
+
// batch must not be adopted (claim theft) and must not be re-enrolled
|
|
646
|
+
// (claim fight). Park it for human resolution instead.
|
|
647
|
+
let adoptedClaim = false;
|
|
648
|
+
let claimState: "none" | "ours" | "foreign" = "none";
|
|
649
|
+
const fresh = requireFreshProjection(
|
|
650
|
+
await input.kernel.projectTask(input.root, child.task_id),
|
|
651
|
+
child.task_id,
|
|
652
|
+
);
|
|
653
|
+
if (fresh.claim !== null && fresh.claim.task_id === child.task_id) {
|
|
654
|
+
claimState = input.kernel.ownsTaskClaim(child.task_id) ? "ours" : "foreign";
|
|
655
|
+
}
|
|
656
|
+
if (claimState === "ours") {
|
|
657
|
+
adoptedClaim = true;
|
|
658
|
+
} else if (claimState === "foreign") {
|
|
659
|
+
record.children = record.children.map((c) =>
|
|
660
|
+
c.task_id === child.task_id
|
|
661
|
+
? { ...c, state: "needs_human", reason: "claim held by another batch" }
|
|
662
|
+
: c,
|
|
663
|
+
);
|
|
664
|
+
// review-7: a park takes the parked child's whole dependent subtree
|
|
665
|
+
// out of the run, including on this foreign-claim path.
|
|
666
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
667
|
+
record.batch_state = "needs_human";
|
|
668
|
+
persist();
|
|
669
|
+
return finalize(input.root, record, "claim held by another batch", "needs-human-attention");
|
|
670
|
+
}
|
|
671
|
+
if (adoptedClaim) {
|
|
672
|
+
record.children = record.children.map((c) =>
|
|
673
|
+
c.task_id === child.task_id
|
|
674
|
+
? { ...c, state: "enrolled", reason: "adopted existing batch claim after interruption" }
|
|
675
|
+
: c,
|
|
676
|
+
);
|
|
677
|
+
persist();
|
|
678
|
+
} else {
|
|
679
|
+
// Re-read the clock after projection; an adopted claim is not a new enrollment.
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
|
|
682
|
+
record.batch_state = record.children.some(
|
|
683
|
+
(c) => c.state === "needs_human" || c.state === "skipped_blocked",
|
|
684
|
+
) ? "needs_human" : "budget_stopped";
|
|
685
|
+
persist();
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
// A new capability has no in-memory consumption history. Restore only
|
|
690
|
+
// slots backed by this batch's independently queried commit evidence.
|
|
691
|
+
await validatePersistedRun(input, record);
|
|
692
|
+
const { issued_at: _issuedAt, ...binding } = input.kernel.validateBatchAuthorization({
|
|
693
|
+
registry: input.registry,
|
|
694
|
+
capability: input.capability,
|
|
695
|
+
binding: {
|
|
696
|
+
batch_id: record.batch_id, plan_digest: record.plan_digest,
|
|
697
|
+
base_head: record.base_head, initiative_slug: record.initiative_slug,
|
|
698
|
+
budget: record.budget, expires_at: record.authorization_expires_at,
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
for (const committed of record.children) {
|
|
702
|
+
if (committed.state === "committed" && !input.registry.isChildConsumed(input.capability, committed.task_id))
|
|
703
|
+
input.registry.consumeChild(input.capability, binding, committed.task_id);
|
|
704
|
+
}
|
|
705
|
+
await input.kernel.enrollTask({
|
|
706
|
+
root: input.root,
|
|
707
|
+
task_id: child.task_id,
|
|
708
|
+
batch: {
|
|
709
|
+
registry: input.registry,
|
|
710
|
+
capability: input.capability,
|
|
711
|
+
binding: { batch_id: input.batch_id, expected_head: head },
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
record.children = record.children.map((c) =>
|
|
715
|
+
c.task_id === child.task_id ? { ...c, state: "enrolled" } : c,
|
|
716
|
+
);
|
|
717
|
+
persist();
|
|
718
|
+
} catch (error) {
|
|
719
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
720
|
+
// Only a typed BatchAuthorizationExpiryError is an intentional
|
|
721
|
+
// budget stop; free-form message matching misclassified
|
|
722
|
+
// infrastructure and validation failures as intentional stops.
|
|
723
|
+
if (isAuthorizationExpiryError(error)) {
|
|
724
|
+
record.children = record.children.map((c) =>
|
|
725
|
+
c.task_id === child.task_id ? { ...c, state: "pending", reason: message } : c,
|
|
726
|
+
);
|
|
727
|
+
record.batch_state = "budget_stopped";
|
|
728
|
+
persist();
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
// review round 10: external HEAD movement after the pre-loop check
|
|
732
|
+
// (including between children) fails the batch, it never parks.
|
|
733
|
+
if (isLineageBreakError(message)) {
|
|
734
|
+
record.children = record.children.map((c) =>
|
|
735
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
|
|
736
|
+
);
|
|
737
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
738
|
+
record.batch_state = "failed";
|
|
739
|
+
persist();
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
record.children = record.children.map((c) =>
|
|
743
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
|
|
744
|
+
);
|
|
745
|
+
// A park ends the run immediately: the parked child may still hold
|
|
746
|
+
// the sole Kernel claim, so selecting an independent sibling would
|
|
747
|
+
// fail its claim projection.
|
|
748
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
749
|
+
record.batch_state = "needs_human";
|
|
750
|
+
persist();
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Drive the child through the Kernel obligation surface only; rework
|
|
755
|
+
// below the limit retries the same child in this inner loop.
|
|
756
|
+
let childTerminal = false;
|
|
757
|
+
while (!childTerminal) {
|
|
758
|
+
const terminal = await input.kernel.advanceTask(input.root, child.task_id);
|
|
759
|
+
if (terminal.state === "completed") {
|
|
760
|
+
childTerminal = true;
|
|
761
|
+
// review-6: a successful settlement resets the consecutive-QA-failure
|
|
762
|
+
// counter so separated failures do not park later children.
|
|
763
|
+
record.consecutive_qa_failures = 0;
|
|
764
|
+
record.children = record.children.map((c) =>
|
|
765
|
+
c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c,
|
|
766
|
+
);
|
|
767
|
+
persist();
|
|
768
|
+
// Scope-bound commit; a lineage failure fails the whole batch.
|
|
769
|
+
try {
|
|
770
|
+
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
771
|
+
const intentPath = planChild?.intent_path ?? undefined;
|
|
772
|
+
const { commit } = input.git?.commitChild
|
|
773
|
+
? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
774
|
+
: input.kernel.commitChild
|
|
775
|
+
? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
776
|
+
: await commitBatchChild({
|
|
777
|
+
root: input.root,
|
|
778
|
+
taskId: child.task_id,
|
|
779
|
+
batchId: input.batch_id,
|
|
780
|
+
expectedHead: head,
|
|
781
|
+
branch: record.branch,
|
|
782
|
+
intentPath,
|
|
783
|
+
});
|
|
784
|
+
record.children = record.children.map((c) =>
|
|
785
|
+
c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
|
|
786
|
+
);
|
|
787
|
+
record.commits.push(commit);
|
|
788
|
+
head = commit;
|
|
789
|
+
persist();
|
|
790
|
+
} catch (error) {
|
|
791
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
792
|
+
record.children = record.children.map((c) =>
|
|
793
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
|
|
794
|
+
);
|
|
795
|
+
// review-4: a commit failure is terminal; dependents must be
|
|
796
|
+
// skipped_blocked, not left pending.
|
|
797
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
|
|
798
|
+
record.batch_state = "failed";
|
|
799
|
+
persist();
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
} else if (terminal.state === "stopped") {
|
|
803
|
+
record.children = record.children.map((c) =>
|
|
804
|
+
c.task_id === child.task_id
|
|
805
|
+
? { ...c, state: "needs_human", reason: "Kernel reported the child stopped" }
|
|
806
|
+
: c,
|
|
807
|
+
);
|
|
808
|
+
childTerminal = true;
|
|
809
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
810
|
+
record.batch_state = "needs_human";
|
|
811
|
+
persist();
|
|
812
|
+
} else if (terminal.state === "rework" && terminal.operation === "qa") {
|
|
813
|
+
record.consecutive_qa_failures += 1;
|
|
814
|
+
if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
|
|
815
|
+
record.children = record.children.map((c) =>
|
|
816
|
+
c.task_id === child.task_id
|
|
817
|
+
? { ...c, state: "needs_human", reason: `QA failure limit reached: ${terminal.summary}` }
|
|
818
|
+
: c,
|
|
819
|
+
);
|
|
820
|
+
record.batch_state = "needs_human";
|
|
821
|
+
childTerminal = true;
|
|
822
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
823
|
+
persist();
|
|
824
|
+
} else {
|
|
825
|
+
// Below the limit: the inner loop re-drives the same child.
|
|
826
|
+
persist();
|
|
827
|
+
}
|
|
828
|
+
} else if (terminal.state === "failed" || terminal.state === "blocked" || terminal.state === "rework") {
|
|
829
|
+
const reason = terminal.state === "rework" ? terminal.summary : terminal.reason;
|
|
830
|
+
childTerminal = true;
|
|
831
|
+
record.children = record.children.map((c) =>
|
|
832
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
833
|
+
);
|
|
834
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
835
|
+
record.batch_state = "needs_human";
|
|
836
|
+
persist();
|
|
837
|
+
} else if (terminal.state === "review_ready") {
|
|
838
|
+
// A foreground Review reservation is owned by the calling host
|
|
839
|
+
// turn; the batch pauses here without parking the child.
|
|
840
|
+
record.children = record.children.map((c) =>
|
|
841
|
+
c.task_id === child.task_id
|
|
842
|
+
? { ...c, state: "enrolled", reason: `review reservation ${terminal.operation_id} open` }
|
|
843
|
+
: c,
|
|
844
|
+
);
|
|
845
|
+
persist();
|
|
846
|
+
return reportFor(
|
|
847
|
+
record,
|
|
848
|
+
`child ${child.task_id} holds an open Review reservation`,
|
|
849
|
+
"Submit the reserved foreground Review verdict, then call startBatch again to continue.",
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return finalize(input.root, record, stopReasonFor(record), "");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function stopReasonFor(record: BatchRunStateRecord): string | null {
|
|
859
|
+
switch (record.batch_state) {
|
|
860
|
+
case "budget_stopped":
|
|
861
|
+
return (
|
|
862
|
+
budgetStopReason(record, Date.now()) ??
|
|
863
|
+
"budget, deadline, or authorization expiry stopped new enrollments"
|
|
864
|
+
);
|
|
865
|
+
case "failed": {
|
|
866
|
+
const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
|
|
867
|
+
return failedChild?.reason ?? "a commit or lineage failure stopped the batch";
|
|
868
|
+
}
|
|
869
|
+
case "needs_human":
|
|
870
|
+
return "a parked child needs a human decision";
|
|
871
|
+
case "completed":
|
|
872
|
+
return "all enrollable children committed";
|
|
873
|
+
default:
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* review-1: resume the interrupted child first. A persisted enrolled child
|
|
880
|
+
* is driven through the Kernel obligation surface to its own terminal
|
|
881
|
+
* settlement and commit before any new enrollment or expiry check, so an
|
|
882
|
+
* interrupted run can never falsely complete or enroll a sibling while a
|
|
883
|
+
* child claim is still open. Never replays a committed mutation; refuses an
|
|
884
|
+
* expired authorization by requiring a new literal-user confirmation.
|
|
885
|
+
*/
|
|
886
|
+
export async function resumeBatch(
|
|
887
|
+
input: StartBatchInput,
|
|
888
|
+
projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
|
|
889
|
+
): Promise<BatchRunReport> {
|
|
890
|
+
let existing = readBatchRunState(input.root, input.batch_id);
|
|
891
|
+
if (!existing) return startBatch(input);
|
|
892
|
+
try {
|
|
893
|
+
await validatePersistedRun(input, existing);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
896
|
+
if (isLineageBreakError(message)) {
|
|
897
|
+
if (isTerminalBatchState(existing.batch_state)) return replayTerminal(input.root, existing);
|
|
898
|
+
const failed = failPersistedLineage(input.root, existing, message);
|
|
899
|
+
return finalize(input.root, failed, message, "");
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
if (isTerminalBatchState(existing.batch_state))
|
|
904
|
+
return replayTerminal(input.root, existing);
|
|
905
|
+
if (existing.batch_state === "needs_human") return startBatch(input);
|
|
906
|
+
|
|
907
|
+
// An enrolled-but-unsettled child must reach its own Kernel terminal
|
|
908
|
+
// settlement before anything else, even under an expired authorization:
|
|
909
|
+
// its claim is already held and the Kernel owns its remaining obligations.
|
|
910
|
+
// review-1(2nd round): an `enrolled` child is verified against a fresh
|
|
911
|
+
// Kernel projection; a projection that finds no active claim means the
|
|
912
|
+
// persisted `enrolled` flag predates a successful enrollment, so the child
|
|
913
|
+
// returns to pending instead of being driven without a claim.
|
|
914
|
+
// Pending children are reconciled by the normal loop using its dependency-aware
|
|
915
|
+
// selection rule. Only a persisted in-flight child needs this recovery path.
|
|
916
|
+
const driven = existing.children.find(
|
|
917
|
+
(child) => child.state === "enrolled" || child.state === "settled",
|
|
918
|
+
);
|
|
919
|
+
if (driven) {
|
|
920
|
+
if (driven.state === "enrolled") {
|
|
921
|
+
// review round 11: verify external HEAD/branch lineage before Kernel
|
|
922
|
+
// advancement of an enrolled child. review round 12: a settled child
|
|
923
|
+
// defers to driveInterruptedChild's lookupBatchCommit verification,
|
|
924
|
+
// which adopts a verified own commit (crash before committed-persist)
|
|
925
|
+
// instead of misjudging it as external drift.
|
|
926
|
+
const drivenDrift = externalHeadDriftMessage(input.root, existing);
|
|
927
|
+
if (drivenDrift) {
|
|
928
|
+
const failed = failPersistedLineage(input.root, existing, drivenDrift);
|
|
929
|
+
return finalize(input.root, failed, drivenDrift, "");
|
|
930
|
+
}
|
|
931
|
+
const fresh = requireFreshProjection(
|
|
932
|
+
await input.kernel.projectTask(input.root, driven.task_id),
|
|
933
|
+
driven.task_id,
|
|
934
|
+
);
|
|
935
|
+
const holdsClaim =
|
|
936
|
+
fresh.claim !== null &&
|
|
937
|
+
fresh.claim.task_id === driven.task_id &&
|
|
938
|
+
input.kernel.ownsTaskClaim(driven.task_id);
|
|
939
|
+
if (holdsClaim === false && fresh.claim !== null && fresh.claim.task_id === driven.task_id && !input.kernel.ownsTaskClaim(driven.task_id)) {
|
|
940
|
+
// review-2(6th round): the claim on this child is held by
|
|
941
|
+
// another batch. Never advance a foreign claim; park for human.
|
|
942
|
+
// review-7: skip the parked child's dependent subtree too.
|
|
943
|
+
skipDependents(existing, driven.task_id, `dependency ${driven.task_id} parked`);
|
|
944
|
+
existing = writeBatchRunState(input.root, {
|
|
945
|
+
...existing,
|
|
946
|
+
batch_state: "needs_human",
|
|
947
|
+
children: existing.children.map((c) =>
|
|
948
|
+
c.task_id === driven.task_id
|
|
949
|
+
? { ...c, state: "needs_human", reason: "claim held by another batch" }
|
|
950
|
+
: c,
|
|
951
|
+
),
|
|
952
|
+
});
|
|
953
|
+
return finalize(input.root, existing, "claim held by another batch", "needs-human-attention");
|
|
954
|
+
}
|
|
955
|
+
if (!holdsClaim) {
|
|
956
|
+
// review-1(4th round): a claimless projection still carries a
|
|
957
|
+
// projection body when the task is a terminal owner. If the fresh
|
|
958
|
+
// projection shows the child already reached its Kernel terminal
|
|
959
|
+
// settlement (lifecycle done), the persisted `enrolled` flag
|
|
960
|
+
// predates a crash between advanceTask and state persistence:
|
|
961
|
+
// settle the child here so resume continues at the commit
|
|
962
|
+
// obligation instead of re-enrolling completed work.
|
|
963
|
+
const settledRemotely =
|
|
964
|
+
fresh.projection.lifecycle === "done" &&
|
|
965
|
+
fresh.projection.completion_ready === true;
|
|
966
|
+
if (settledRemotely) {
|
|
967
|
+
existing = writeBatchRunState(input.root, {
|
|
968
|
+
...existing,
|
|
969
|
+
children: existing.children.map((c) =>
|
|
970
|
+
c.task_id === driven.task_id
|
|
971
|
+
? { ...c, state: "settled", reason: "crash after settlement; resuming at commit" }
|
|
972
|
+
: c,
|
|
973
|
+
),
|
|
974
|
+
});
|
|
975
|
+
} else {
|
|
976
|
+
// No Kernel claim and no terminal settlement: the enrollment
|
|
977
|
+
// never completed. Re-mark the child pending so the normal
|
|
978
|
+
// loop re-enrolls it through the batch-derived capability.
|
|
979
|
+
existing = writeBatchRunState(input.root, {
|
|
980
|
+
...existing,
|
|
981
|
+
children: existing.children.map((c) =>
|
|
982
|
+
c.task_id === driven.task_id
|
|
983
|
+
? { ...c, state: "pending", reason: "enrollment did not complete; re-enrolling" }
|
|
984
|
+
: c,
|
|
985
|
+
),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// review-2(3rd round): re-read the child from the persisted record so
|
|
991
|
+
// the re-marked pending child is never driven with the stale enrolled
|
|
992
|
+
// object captured before the state write.
|
|
993
|
+
const current =
|
|
994
|
+
existing.children.find((c) => c.task_id === driven.task_id) ?? driven;
|
|
995
|
+
if (current.state === "settled" || current.state === "enrolled") {
|
|
996
|
+
try {
|
|
997
|
+
const driven = await driveInterruptedChild(input, current);
|
|
998
|
+
if (driven) return driven;
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
if (error instanceof BatchCommitAbortError) {
|
|
1001
|
+
const failed = readBatchRunState(input.root, input.batch_id)!;
|
|
1002
|
+
return finalize(input.root, failed, error.message, "");
|
|
1003
|
+
}
|
|
1004
|
+
throw error;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// review-3(2nd round): driveInterruptedChild persisted its own
|
|
1008
|
+
// transitions; reload the record so the final state below reflects
|
|
1009
|
+
// the persisted child states, not the stale pre-recovery snapshot.
|
|
1010
|
+
existing = readBatchRunState(input.root, input.batch_id)!;
|
|
1011
|
+
if (isTerminalBatchState(existing.batch_state))
|
|
1012
|
+
return replayTerminal(input.root, existing);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// No interrupted child remains: continue with the normal serial loop.
|
|
1016
|
+
return startBatch(input);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Drive one interrupted child (enrolled or settled) to its own terminal
|
|
1021
|
+
* settlement and commit. Returns a report when the child reached a state the
|
|
1022
|
+
* driver must stop on (review_ready, failure, park), or null to continue the
|
|
1023
|
+
* outer run afterwards.
|
|
1024
|
+
*/
|
|
1025
|
+
class BatchCommitAbortError extends Error {}
|
|
1026
|
+
|
|
1027
|
+
async function driveInterruptedChild(
|
|
1028
|
+
input: StartBatchInput,
|
|
1029
|
+
child: BatchChildRun,
|
|
1030
|
+
): Promise<BatchRunReport | null> {
|
|
1031
|
+
let record = readBatchRunState(input.root, input.batch_id)!;
|
|
1032
|
+
const persist = (): void => {
|
|
1033
|
+
record = writeBatchRunState(input.root, record);
|
|
1034
|
+
};
|
|
1035
|
+
if (record.batch_state === "prepared") {
|
|
1036
|
+
record.batch_state = "running";
|
|
1037
|
+
persist();
|
|
1038
|
+
}
|
|
1039
|
+
let head = record.commits.length
|
|
1040
|
+
? record.commits[record.commits.length - 1]!
|
|
1041
|
+
: record.base_head;
|
|
1042
|
+
while (child.state === "enrolled" || child.state === "settled") {
|
|
1043
|
+
if (child.state === "settled") {
|
|
1044
|
+
// review-2(2nd round): a persisted settled child must not replay
|
|
1045
|
+
// Kernel advancement; proceed directly to its scope-bound commit.
|
|
1046
|
+
// review-3(5th round): a crash after commitChild created the commit
|
|
1047
|
+
// but before committed-persist replays commitChild on resume. Adopt
|
|
1048
|
+
// the existing batch commit idempotently instead of mutating again.
|
|
1049
|
+
let existing: { commit: string } | null = null;
|
|
1050
|
+
// review-1(6th round): a lookup failure must fail closed, not fall
|
|
1051
|
+
// through to commitChild, which could replay an existing commit.
|
|
1052
|
+
try {
|
|
1053
|
+
existing = input.git?.lookupBatchCommit
|
|
1054
|
+
? await input.git.lookupBatchCommit(input.root, child.task_id, input.batch_id, head, record.branch)
|
|
1055
|
+
: input.kernel.lookupBatchCommit
|
|
1056
|
+
? await input.kernel.lookupBatchCommit(
|
|
1057
|
+
input.root,
|
|
1058
|
+
child.task_id,
|
|
1059
|
+
input.batch_id,
|
|
1060
|
+
head,
|
|
1061
|
+
record.branch,
|
|
1062
|
+
)
|
|
1063
|
+
: await lookupBatchCommit({
|
|
1064
|
+
root: input.root,
|
|
1065
|
+
taskId: child.task_id,
|
|
1066
|
+
batchId: input.batch_id,
|
|
1067
|
+
expectedHead: head,
|
|
1068
|
+
branch: record.branch,
|
|
1069
|
+
});
|
|
1070
|
+
} catch (error: unknown) {
|
|
1071
|
+
const message =
|
|
1072
|
+
error instanceof Error ? error.message : String(error);
|
|
1073
|
+
record.children = record.children.map((c) =>
|
|
1074
|
+
c.task_id === child.task_id
|
|
1075
|
+
? { ...c, state: "needs_human", reason: `commit lookup failed: ${message}` }
|
|
1076
|
+
: c,
|
|
1077
|
+
);
|
|
1078
|
+
// review-1(7th round): the commit-lookup park is a park site too;
|
|
1079
|
+
// its transitive dependents become skipped_blocked like every
|
|
1080
|
+
// other park, never left pending.
|
|
1081
|
+
skipDependents(
|
|
1082
|
+
record,
|
|
1083
|
+
child.task_id,
|
|
1084
|
+
`dependency ${child.task_id} failed to commit`,
|
|
1085
|
+
);
|
|
1086
|
+
const isLineageError =
|
|
1087
|
+
message.includes("batch_head_lineage_broken") || message.includes("lineage");
|
|
1088
|
+
record.batch_state = isLineageError ? "failed" : "needs_human";
|
|
1089
|
+
persist();
|
|
1090
|
+
throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
|
|
1091
|
+
}
|
|
1092
|
+
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
1093
|
+
const intentPath = planChild?.intent_path ?? undefined;
|
|
1094
|
+
const doCommit = async () => {
|
|
1095
|
+
if (input.git?.commitChild) {
|
|
1096
|
+
return input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
|
|
1097
|
+
}
|
|
1098
|
+
if (input.kernel.commitChild) {
|
|
1099
|
+
return input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
|
|
1100
|
+
}
|
|
1101
|
+
return commitBatchChild({
|
|
1102
|
+
root: input.root,
|
|
1103
|
+
taskId: child.task_id,
|
|
1104
|
+
batchId: input.batch_id,
|
|
1105
|
+
expectedHead: head,
|
|
1106
|
+
branch: record.branch,
|
|
1107
|
+
intentPath,
|
|
1108
|
+
});
|
|
1109
|
+
};
|
|
1110
|
+
const adopted =
|
|
1111
|
+
existing ??
|
|
1112
|
+
(await doCommit().catch((error: unknown) => {
|
|
1113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1114
|
+
record.children = record.children.map((c) =>
|
|
1115
|
+
c.task_id === child.task_id
|
|
1116
|
+
? { ...c, state: "needs_human", reason: message }
|
|
1117
|
+
: c,
|
|
1118
|
+
);
|
|
1119
|
+
// review-4: dependents are skipped_blocked, not left pending.
|
|
1120
|
+
skipDependents(
|
|
1121
|
+
record,
|
|
1122
|
+
child.task_id,
|
|
1123
|
+
`dependency ${child.task_id} failed to commit`,
|
|
1124
|
+
);
|
|
1125
|
+
record.batch_state = "failed";
|
|
1126
|
+
persist();
|
|
1127
|
+
throw new BatchCommitAbortError(message);
|
|
1128
|
+
}));
|
|
1129
|
+
const { commit } = adopted;
|
|
1130
|
+
record.children = record.children.map((c) =>
|
|
1131
|
+
c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
|
|
1132
|
+
);
|
|
1133
|
+
record.commits.push(commit);
|
|
1134
|
+
head = commit;
|
|
1135
|
+
persist();
|
|
1136
|
+
child = { ...child, state: "committed", commit };
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const terminal = await input.kernel.advanceTask(input.root, child.task_id);
|
|
1140
|
+
if (terminal.state === "completed") {
|
|
1141
|
+
record.children = record.children.map((c) =>
|
|
1142
|
+
c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c,
|
|
1143
|
+
);
|
|
1144
|
+
record.consecutive_qa_failures = 0;
|
|
1145
|
+
persist();
|
|
1146
|
+
try {
|
|
1147
|
+
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
1148
|
+
const intentPath = planChild?.intent_path ?? undefined;
|
|
1149
|
+
const { commit } = input.git?.commitChild
|
|
1150
|
+
? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
1151
|
+
: input.kernel.commitChild
|
|
1152
|
+
? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
1153
|
+
: await commitBatchChild({
|
|
1154
|
+
root: input.root,
|
|
1155
|
+
taskId: child.task_id,
|
|
1156
|
+
batchId: input.batch_id,
|
|
1157
|
+
expectedHead: head,
|
|
1158
|
+
branch: record.branch,
|
|
1159
|
+
intentPath,
|
|
1160
|
+
});
|
|
1161
|
+
record.children = record.children.map((c) =>
|
|
1162
|
+
c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
|
|
1163
|
+
);
|
|
1164
|
+
record.commits.push(commit);
|
|
1165
|
+
head = commit;
|
|
1166
|
+
persist();
|
|
1167
|
+
child = { ...child, state: "committed", commit };
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1170
|
+
record.children = record.children.map((c) =>
|
|
1171
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
|
|
1172
|
+
);
|
|
1173
|
+
// review-4: dependents are skipped_blocked, not left pending.
|
|
1174
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
|
|
1175
|
+
record.batch_state = "failed";
|
|
1176
|
+
persist();
|
|
1177
|
+
return finalize(input.root, record, message, "");
|
|
1178
|
+
}
|
|
1179
|
+
} else if (terminal.state === "review_ready") {
|
|
1180
|
+
record.children = record.children.map((c) =>
|
|
1181
|
+
c.task_id === child.task_id
|
|
1182
|
+
? { ...c, reason: `review reservation ${terminal.operation_id} open` }
|
|
1183
|
+
: c,
|
|
1184
|
+
);
|
|
1185
|
+
persist();
|
|
1186
|
+
// A foreground Review reservation stays with the calling host turn.
|
|
1187
|
+
return reportFor(
|
|
1188
|
+
record,
|
|
1189
|
+
`child ${child.task_id} holds an open Review reservation`,
|
|
1190
|
+
"Submit the reserved foreground Review verdict, then call startBatch again to continue.",
|
|
1191
|
+
);
|
|
1192
|
+
} else if (terminal.state === "rework" && terminal.operation === "qa") {
|
|
1193
|
+
// review-3(3rd round): interrupted-child rework applies the same
|
|
1194
|
+
// consecutive-failure limit as startBatch instead of parking on the
|
|
1195
|
+
// first rework; below the limit the loop re-drives the same child.
|
|
1196
|
+
record.consecutive_qa_failures += 1;
|
|
1197
|
+
if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
|
|
1198
|
+
const reason = `QA failure limit reached: ${terminal.summary}`;
|
|
1199
|
+
record.children = record.children.map((c) =>
|
|
1200
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
1201
|
+
);
|
|
1202
|
+
record.batch_state = "needs_human";
|
|
1203
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
1204
|
+
persist();
|
|
1205
|
+
return finalize(input.root, record, reason, "");
|
|
1206
|
+
}
|
|
1207
|
+
persist();
|
|
1208
|
+
} else {
|
|
1209
|
+
// stopped | failed | blocked: park and stop.
|
|
1210
|
+
const reason =
|
|
1211
|
+
terminal.state === "stopped" ? "Kernel reported the child stopped"
|
|
1212
|
+
: terminal.state === "rework" ? terminal.summary : terminal.reason;
|
|
1213
|
+
record.children = record.children.map((c) =>
|
|
1214
|
+
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
1215
|
+
);
|
|
1216
|
+
record.batch_state = "needs_human";
|
|
1217
|
+
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
1218
|
+
persist();
|
|
1219
|
+
return finalize(input.root, record, reason, "");
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
// Child committed; continue the outer run afterwards.
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|