immune-brain 4.3.0 → 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/README.zh-CN.md +37 -0
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +10 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +6 -1
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +11 -6
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +357 -154
- package/plugins/immune-brain/dist/imm-loop.md +35 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +160 -12
- package/plugins/immune-brain/runtime/assurance/qa.ts +57 -42
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +27 -8
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +9 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +38 -8
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +84 -146
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +11 -0
|
@@ -23,6 +23,7 @@ export const TOOLS = [
|
|
|
23
23
|
{ name: "advance_assurance", description: "Advance frozen Assurance through deterministic QA and Review reservation.", privileged: false },
|
|
24
24
|
{ name: "submit_review", description: "Submit the Parent-mediated Review verdict bound to the correlated receipt.", privileged: false },
|
|
25
25
|
{ name: "request_authorization", description: "Apply exact literal-user authorization.", privileged: true },
|
|
26
|
+
{ name: "revise_intent", description: "Apply a compatible TaskIntent revision.", privileged: false },
|
|
26
27
|
{ name: "approve_breaking_intent_revision", description: "Approve a breaking TaskIntent revision.", privileged: true },
|
|
27
28
|
{ name: "stop", description: "Stop the active task with literal-user authority.", privileged: true },
|
|
28
29
|
{ name: "start_unattended_batch", description: "Start an unattended serial batch run for an Initiative after native confirmation.", privileged: true },
|
|
@@ -42,7 +43,7 @@ export function listMcpTools() {
|
|
|
42
43
|
? { initiative_slug: { type: "string" } }
|
|
43
44
|
: {
|
|
44
45
|
task_id: { type: "string" },
|
|
45
|
-
...(tool.name === "approve_breaking_intent_revision" ? { next_intent: { type: "object" } } : {}),
|
|
46
|
+
...(tool.name === "approve_breaking_intent_revision" || tool.name === "revise_intent" ? { next_intent: { type: "object" } } : {}),
|
|
46
47
|
...(tool.name === "stop" ? { reason: { type: "string" } } : {}),
|
|
47
48
|
...(tool.name === "submit_review" ? { verdict: { type: "object" } } : {}),
|
|
48
49
|
...(tool.name === "resolve_finding" ? { finding_id: { type: "string" } } : {}),
|
|
@@ -57,7 +58,9 @@ export function listMcpTools() {
|
|
|
57
58
|
? ["task_id", "finding_id"]
|
|
58
59
|
: tool.name === "refute_finding"
|
|
59
60
|
? ["task_id", "finding_id", "attestation_id"]
|
|
60
|
-
:
|
|
61
|
+
: tool.name === "revise_intent"
|
|
62
|
+
? ["task_id", "next_intent"]
|
|
63
|
+
: ["task_id"],
|
|
61
64
|
},
|
|
62
65
|
annotations: tool.privileged ? privilegedAnnotations() : { readOnlyHint: tool.name === "status" },
|
|
63
66
|
}));
|
|
@@ -162,6 +165,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
162
165
|
if (!Object.hasOwn(args, "verdict")) throw new Error("verdict is required");
|
|
163
166
|
return runtime.submitReview(taskId, args.verdict);
|
|
164
167
|
}
|
|
168
|
+
if (name === "revise_intent") {
|
|
169
|
+
if (!Object.hasOwn(args, "next_intent")) throw new Error("next_intent is required");
|
|
170
|
+
return runtime.reviseIntent(taskId, args.next_intent);
|
|
171
|
+
}
|
|
165
172
|
if (name === "resolve_finding") {
|
|
166
173
|
// Structural only. Which findings may be resolved, and when, stays
|
|
167
174
|
// the reducer's decision; duplicating it here would create a second
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/plugin_versioning.ts from the root package.json.
|
|
2
|
-
export const PLUGIN_VERSION = "4.
|
|
2
|
+
export const PLUGIN_VERSION = "4.4.1" as const;
|
|
@@ -6,6 +6,12 @@ same-boundary follow-up) in the current Parent conversation. New helpers or
|
|
|
6
6
|
tests inside an approved directory or glob do not require a revision. Keep
|
|
7
7
|
every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
|
|
8
8
|
|
|
9
|
+
On a batch `handoff.role=executor`, implement only its named enrolled child;
|
|
10
|
+
the batch stays `running` with that child `enrolled`. Return to the Parent after
|
|
11
|
+
scoped implementation, staging and focused diagnostics. Enrollment itself does
|
|
12
|
+
not start QA; the Parent owns the subsequent Kernel Assurance and foreground
|
|
13
|
+
Review handoff under the same still-valid batch authorization.
|
|
14
|
+
|
|
9
15
|
Before handoff, run the permitted diagnostic checks and return commands and
|
|
10
16
|
outcomes to the Parent as structured diagnostic evidence. The read-only Loop
|
|
11
17
|
runtime action only constructs the dispatch envelope; it does not store
|
|
@@ -20,11 +20,11 @@ import { readBackendClaim } from "../kernel/backend_claim";
|
|
|
20
20
|
import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
|
|
21
21
|
import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
22
22
|
import { readTaskIntent } from "../kernel/intent";
|
|
23
|
-
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw } from "../kernel/storage";
|
|
23
|
+
import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
|
|
24
24
|
import { pathMatchesScope } from "../workspace_scope";
|
|
25
|
-
import { projectBatchPlan } from "./batch_plan";
|
|
25
|
+
import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
|
|
26
26
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
27
|
-
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
27
|
+
import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
|
|
28
28
|
import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
|
|
29
29
|
import type {
|
|
30
30
|
BatchPlanBudget,
|
|
@@ -139,7 +139,13 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
|
|
|
139
139
|
return { corrupt: true, path: file };
|
|
140
140
|
}
|
|
141
141
|
if (isTerminalBatchState(candidate.batch_state)) continue;
|
|
142
|
-
|
|
142
|
+
try {
|
|
143
|
+
const record = readBatchRunState(root, file.slice(0, -5));
|
|
144
|
+
if (record) return { corrupt: false, record };
|
|
145
|
+
} catch {
|
|
146
|
+
// Reuse the state owner's full validation before offering a renewal.
|
|
147
|
+
}
|
|
148
|
+
return { corrupt: true, path: file };
|
|
143
149
|
}
|
|
144
150
|
return null;
|
|
145
151
|
}
|
|
@@ -338,7 +344,11 @@ async function projectPlanSurface(input: {
|
|
|
338
344
|
let budget: BatchPlanBudget;
|
|
339
345
|
|
|
340
346
|
if (isResuming && existingBatch) {
|
|
341
|
-
|
|
347
|
+
// Candidate only: renewal uses the same default window as a fresh plan,
|
|
348
|
+
// while preserving the confirmed count limits and the persisted record.
|
|
349
|
+
budget = { ...existingBatch.budget };
|
|
350
|
+
if (Date.parse(budget.deadline_at) <= Date.parse(now))
|
|
351
|
+
budget.deadline_at = new Date(Date.parse(now) + DEFAULT_DEADLINE_MS).toISOString();
|
|
342
352
|
try {
|
|
343
353
|
recoveryChildren = existingBatch.children.map((c) => {
|
|
344
354
|
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
@@ -465,6 +475,11 @@ export async function projectBatchPreflight(
|
|
|
465
475
|
|
|
466
476
|
// 1. Active workspace claim (pre-confirmation).
|
|
467
477
|
const activeTaskId = readActiveClaimTaskId(root);
|
|
478
|
+
if (activeTaskId) {
|
|
479
|
+
const authority = reconcileKernelAuthority(root, activeTaskId);
|
|
480
|
+
if (authority.state === "authority_conflict")
|
|
481
|
+
return reject("kernel_authority_conflict", authority.diagnostic ?? "unverifiable workspace authority");
|
|
482
|
+
}
|
|
468
483
|
const ownClaim =
|
|
469
484
|
isResuming && activeTaskId !== null && isOwnBatchClaim(root, activeRecord!, activeTaskId, batchBranch);
|
|
470
485
|
if (activeTaskId && !ownClaim)
|
|
@@ -678,9 +693,9 @@ export async function authorizeBatch<HostRejection>(
|
|
|
678
693
|
// so its expiry is the deadline the literal user confirmed rather than a fixed
|
|
679
694
|
// window that lapses while a child is parked on a foreground Review.
|
|
680
695
|
const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
:
|
|
696
|
+
const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
697
|
+
if (branchBefore.status !== 0)
|
|
698
|
+
return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
|
|
684
699
|
|
|
685
700
|
// ADR-0005 Decision 1: a resume of an intact, still-binding authorization
|
|
686
701
|
// reuses it instead of opening a second native gate. Anything that no longer
|
|
@@ -688,12 +703,14 @@ export async function authorizeBatch<HostRejection>(
|
|
|
688
703
|
const reuseBlockers: string[] = [];
|
|
689
704
|
if (isResuming && existingBatch) {
|
|
690
705
|
if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
|
|
706
|
+
if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
|
|
691
707
|
if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
|
|
692
708
|
if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
|
|
693
709
|
if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
|
|
694
710
|
if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
|
|
695
711
|
}
|
|
696
712
|
const reuseAuthorization = isResuming && reuseBlockers.length === 0;
|
|
713
|
+
const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
|
|
697
714
|
|
|
698
715
|
const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
|
|
699
716
|
const facts: BatchConfirmationFacts = {
|
|
@@ -748,6 +765,19 @@ export async function authorizeBatch<HostRejection>(
|
|
|
748
765
|
if (finalClaimTaskId && !finalOwnClaim)
|
|
749
766
|
return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
|
|
750
767
|
|
|
768
|
+
// Confirmation cannot adopt a changed record or extend its displayed window
|
|
769
|
+
// after the user answers. Re-read the clock and branch after the async checks.
|
|
770
|
+
if (isResuming) {
|
|
771
|
+
const current = findExistingActiveBatch(root, initiativeSlug);
|
|
772
|
+
if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
|
|
773
|
+
return { outcome: "rejected", rejection: batchRejection("plan_changed") };
|
|
774
|
+
}
|
|
775
|
+
const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
|
|
776
|
+
if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
|
|
777
|
+
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
|
|
778
|
+
if (Date.parse(expiresAt) <= Date.now())
|
|
779
|
+
return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
|
|
780
|
+
|
|
751
781
|
const binding: BatchAuthorizationBinding = {
|
|
752
782
|
batch_id: batchId,
|
|
753
783
|
initiative_slug: initiativeSlug,
|
|
@@ -13,6 +13,7 @@ export type BatchReasonKey =
|
|
|
13
13
|
| "invalid_slug"
|
|
14
14
|
| "batch_state_unreadable"
|
|
15
15
|
| "claim_already_active"
|
|
16
|
+
| "kernel_authority_conflict"
|
|
16
17
|
| "git_head_unreadable"
|
|
17
18
|
| "branch_already_exists"
|
|
18
19
|
| "git_status_unreadable"
|
|
@@ -59,6 +60,11 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
59
60
|
reason: (detail: string) => `an active workspace claim already exists for task: ${detail}`,
|
|
60
61
|
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
61
62
|
},
|
|
63
|
+
kernel_authority_conflict: {
|
|
64
|
+
state: "rejected",
|
|
65
|
+
reason: (detail: string) => `Kernel authority conflict: ${detail}`,
|
|
66
|
+
recovery_action: "resolve the Kernel storage conflict before resuming in the current Host",
|
|
67
|
+
},
|
|
62
68
|
git_head_unreadable: {
|
|
63
69
|
state: "rejected",
|
|
64
70
|
reason: (detail: string) => detail,
|
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
computeBatchPlanDigest,
|
|
13
13
|
} from "../kernel/batch_authority";
|
|
14
14
|
import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
15
|
+
import type { TaskRecord } from "../kernel/types";
|
|
16
|
+
import { deriveAssuranceRecovery, type AssuranceRecovery, type TaskRecordRead } from "../assurance/coordinator";
|
|
17
|
+
import type { QaCheckDiagnostic } from "../assurance/qa";
|
|
18
|
+
|
|
19
|
+
/** Failed atomic QA attempts observed through Kernel history, including Parent-driven QA. */
|
|
20
|
+
export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { findings: NonNullable<TaskRecordRead["record"]>["findings"] }) {
|
|
21
|
+
const failed = record.history.filter((event) => event.type === "request_rework" && event.authority?.authority_kind === "qa");
|
|
22
|
+
return { qa_failure_count: failed.length, last_qa_failure_at: failed.at(-1)?.at ?? null,
|
|
23
|
+
recovery_findings: record.findings.filter(f => f.status === "open").map(f => ({ id: f.id, kind: f.kind, status: f.status, acceptance_id: f.acceptance_id })) };
|
|
24
|
+
}
|
|
15
25
|
import type { BatchPlanChild } from "./types";
|
|
16
26
|
import {
|
|
17
27
|
type BatchRunnerGitPort,
|
|
@@ -75,7 +85,7 @@ export interface BatchRunnerKernelPort {
|
|
|
75
85
|
* persistence. Uses the real AssuranceProjectionResult contract; batch
|
|
76
86
|
* ownership of the claim is verified separately through the authoritative
|
|
77
87
|
* batch registry, because the Kernel claim carries no batch id. */
|
|
78
|
-
projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
|
|
88
|
+
projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult & { qa_failure_count?: number; last_qa_failure_at?: string | null; recovery_findings?: NonNullable<TaskRecordRead["record"]>["findings"] }>;
|
|
79
89
|
/** Authoritative batch ownership check: true when the task's Kernel claim
|
|
80
90
|
* is held under this batch's derived capability (consumed child slot). */
|
|
81
91
|
ownsTaskClaim(taskId: string): boolean;
|
|
@@ -92,13 +102,14 @@ export interface BatchRunnerKernelPort {
|
|
|
92
102
|
}): ValidatedBatchAuthorization;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
|
-
export type BatchChildAdvanceResult =
|
|
105
|
+
export type BatchChildAdvanceResult = { diagnostics?: QaCheckDiagnostic[]; environment_failure?: boolean; recovery?: AssuranceRecovery } & (
|
|
96
106
|
| { state: "completed" }
|
|
97
107
|
| { state: "stopped" }
|
|
98
108
|
| { state: "failed"; reason: string }
|
|
99
109
|
| { state: "rework"; operation: "qa" | "review"; summary: string }
|
|
100
110
|
| { state: "blocked"; reason: string }
|
|
101
|
-
| { state: "review_ready"; operation_id: string }
|
|
111
|
+
| { state: "review_ready"; operation_id: string }
|
|
112
|
+
| { state: "review_preparation_failed"; operation: "review"; operation_id: string; reason: string });
|
|
102
113
|
|
|
103
114
|
export interface StartBatchInput {
|
|
104
115
|
root: string;
|
|
@@ -166,13 +177,13 @@ function isAuthorizationExpiryError(error: unknown): boolean {
|
|
|
166
177
|
return error instanceof BatchAuthorizationExpiryError;
|
|
167
178
|
}
|
|
168
179
|
|
|
169
|
-
function requireFreshProjection(
|
|
170
|
-
result:
|
|
180
|
+
function requireFreshProjection<T extends AssuranceProjectionResult>(
|
|
181
|
+
result: T,
|
|
171
182
|
taskId: string,
|
|
172
|
-
):
|
|
183
|
+
): T & { error: null } {
|
|
173
184
|
if (result.error !== null)
|
|
174
185
|
throw new Error(`cannot reconcile Kernel projection for ${taskId}: ${result.error}`);
|
|
175
|
-
return result as
|
|
186
|
+
return result as T & { error: null };
|
|
176
187
|
}
|
|
177
188
|
|
|
178
189
|
/** Next pending child whose direct dependents are all committed. */
|
|
@@ -216,6 +227,29 @@ function reportFor(
|
|
|
216
227
|
};
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
/** Derived observation only: Parent must re-read Kernel ownership before execution. */
|
|
231
|
+
function executorHandoff(
|
|
232
|
+
record: BatchRunStateRecord,
|
|
233
|
+
taskId: string,
|
|
234
|
+
fresh: AssuranceProjectionResult & { error: null; recovery_findings?: NonNullable<TaskRecordRead["record"]>["findings"] },
|
|
235
|
+
failure: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[] } = {},
|
|
236
|
+
): BatchRunReport {
|
|
237
|
+
const recovery = deriveAssuranceRecovery(taskId, fresh, failure, fresh.recovery_findings);
|
|
238
|
+
return {
|
|
239
|
+
...reportFor(record, null, recovery?.next_action ?? (fresh.projection.artifact_state === "frozen"
|
|
240
|
+
? "The child is frozen with pending QA. Call advance_assurance once; follow its diagnostics before retrying, then submit any reserved Review before batch continuation."
|
|
241
|
+
: "Route the enrolled child to foreground Executor, implement and stage its scoped work, then call Kernel advance_assurance. Submit any reserved Review verdict before continuing start_unattended_batch with the same Initiative.")),
|
|
242
|
+
...(recovery ? { recovery } : {}),
|
|
243
|
+
...(failure.diagnostics?.length ? { diagnostics: failure.diagnostics } : {}),
|
|
244
|
+
handoff: recovery?.category === "authorization" ? undefined : {
|
|
245
|
+
role: "executor", task_id: taskId,
|
|
246
|
+
run_id: fresh.projection.run_id ?? null,
|
|
247
|
+
record_revision: fresh.projection.record_revision,
|
|
248
|
+
next_obligation: fresh.projection.next_obligation,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
219
253
|
/** review-3(6th round): persist the single run report when the batch
|
|
220
254
|
* stops — including a needs_human park, which ends the current run even
|
|
221
255
|
* though the batch remains resumable after a human decision. */
|
|
@@ -224,8 +258,9 @@ function finalize(
|
|
|
224
258
|
record: BatchRunStateRecord,
|
|
225
259
|
reason: string | null,
|
|
226
260
|
nextAction: string,
|
|
261
|
+
details: Pick<BatchRunReport, "recovery" | "diagnostics"> = {},
|
|
227
262
|
): BatchRunReport {
|
|
228
|
-
const report = reportFor(record, reason, nextAction);
|
|
263
|
+
const report = { ...reportFor(record, reason, nextAction), ...details };
|
|
229
264
|
if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human") {
|
|
230
265
|
writeBatchRunReport(root, report);
|
|
231
266
|
}
|
|
@@ -525,7 +560,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
525
560
|
authorization_expires_at: input.authorization_expires_at,
|
|
526
561
|
budget: input.budget,
|
|
527
562
|
batch_state: "running",
|
|
528
|
-
consecutive_qa_failures: 0,
|
|
529
563
|
children: resumedChildren,
|
|
530
564
|
});
|
|
531
565
|
}
|
|
@@ -786,105 +820,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
786
820
|
break;
|
|
787
821
|
}
|
|
788
822
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if (terminal.state === "completed") {
|
|
795
|
-
childTerminal = true;
|
|
796
|
-
// review-6: a successful settlement resets the consecutive-QA-failure
|
|
797
|
-
// counter so separated failures do not park later children.
|
|
798
|
-
record.consecutive_qa_failures = 0;
|
|
799
|
-
record.children = record.children.map((c) =>
|
|
800
|
-
c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c,
|
|
801
|
-
);
|
|
802
|
-
persist();
|
|
803
|
-
// Scope-bound commit; a lineage failure fails the whole batch.
|
|
804
|
-
try {
|
|
805
|
-
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
806
|
-
const intentPath = planChild?.intent_path ?? undefined;
|
|
807
|
-
const { commit } = input.git?.commitChild
|
|
808
|
-
? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
809
|
-
: input.kernel.commitChild
|
|
810
|
-
? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
811
|
-
: await commitBatchChild({
|
|
812
|
-
root: input.root,
|
|
813
|
-
taskId: child.task_id,
|
|
814
|
-
batchId: input.batch_id,
|
|
815
|
-
expectedHead: head,
|
|
816
|
-
branch: record.branch,
|
|
817
|
-
intentPath,
|
|
818
|
-
});
|
|
819
|
-
record.children = record.children.map((c) =>
|
|
820
|
-
c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
|
|
821
|
-
);
|
|
822
|
-
record.commits.push(commit);
|
|
823
|
-
head = commit;
|
|
824
|
-
persist();
|
|
825
|
-
} catch (error) {
|
|
826
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
827
|
-
record.children = record.children.map((c) =>
|
|
828
|
-
c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
|
|
829
|
-
);
|
|
830
|
-
// review-4: a commit failure is terminal; dependents must be
|
|
831
|
-
// skipped_blocked, not left pending.
|
|
832
|
-
skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
|
|
833
|
-
record.batch_state = "failed";
|
|
834
|
-
persist();
|
|
835
|
-
break;
|
|
836
|
-
}
|
|
837
|
-
} else if (terminal.state === "stopped") {
|
|
838
|
-
record.children = record.children.map((c) =>
|
|
839
|
-
c.task_id === child.task_id
|
|
840
|
-
? { ...c, state: "needs_human", reason: "Kernel reported the child stopped" }
|
|
841
|
-
: c,
|
|
842
|
-
);
|
|
843
|
-
childTerminal = true;
|
|
844
|
-
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
845
|
-
record.batch_state = "needs_human";
|
|
846
|
-
persist();
|
|
847
|
-
} else if (terminal.state === "rework" && terminal.operation === "qa") {
|
|
848
|
-
record.consecutive_qa_failures += 1;
|
|
849
|
-
if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
|
|
850
|
-
record.children = record.children.map((c) =>
|
|
851
|
-
c.task_id === child.task_id
|
|
852
|
-
? { ...c, state: "needs_human", reason: `QA failure limit reached: ${terminal.summary}` }
|
|
853
|
-
: c,
|
|
854
|
-
);
|
|
855
|
-
record.batch_state = "needs_human";
|
|
856
|
-
childTerminal = true;
|
|
857
|
-
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
858
|
-
persist();
|
|
859
|
-
} else {
|
|
860
|
-
// Below the limit: the inner loop re-drives the same child.
|
|
861
|
-
persist();
|
|
862
|
-
}
|
|
863
|
-
} else if (terminal.state === "failed" || terminal.state === "blocked" || terminal.state === "rework") {
|
|
864
|
-
const reason = terminal.state === "rework" ? terminal.summary : terminal.reason;
|
|
865
|
-
childTerminal = true;
|
|
866
|
-
record.children = record.children.map((c) =>
|
|
867
|
-
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
868
|
-
);
|
|
869
|
-
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
870
|
-
record.batch_state = "needs_human";
|
|
871
|
-
persist();
|
|
872
|
-
} else if (terminal.state === "review_ready") {
|
|
873
|
-
// A foreground Review reservation is owned by the calling host
|
|
874
|
-
// turn; the batch pauses here without parking the child.
|
|
875
|
-
record.children = record.children.map((c) =>
|
|
876
|
-
c.task_id === child.task_id
|
|
877
|
-
? { ...c, state: "enrolled", reason: `review reservation ${terminal.operation_id} open` }
|
|
878
|
-
: c,
|
|
879
|
-
);
|
|
880
|
-
persist();
|
|
881
|
-
return reportFor(
|
|
882
|
-
record,
|
|
883
|
-
`child ${child.task_id} holds an open Review reservation`,
|
|
884
|
-
"Submit the reserved foreground Review verdict, then call startBatch again to continue.",
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
823
|
+
const enrolled = requireFreshProjection(
|
|
824
|
+
await input.kernel.projectTask(input.root, child.task_id), child.task_id,
|
|
825
|
+
);
|
|
826
|
+
return executorHandoff(record, child.task_id, enrolled);
|
|
827
|
+
|
|
888
828
|
}
|
|
889
829
|
|
|
890
830
|
return finalize(input.root, record, stopReasonFor(record), "");
|
|
@@ -987,6 +927,26 @@ export async function resumeBatch(
|
|
|
987
927
|
});
|
|
988
928
|
return finalize(input.root, existing, "claim held by another batch", "needs-human-attention");
|
|
989
929
|
}
|
|
930
|
+
if (holdsClaim) {
|
|
931
|
+
const failures = Math.max(existing.consecutive_qa_failures, fresh.qa_failure_count ?? 0);
|
|
932
|
+
if (failures >= existing.budget.qa_failure_limit && fresh.last_qa_failure_at &&
|
|
933
|
+
Date.parse(fresh.last_qa_failure_at) > Date.parse(existing.confirmation_time)) {
|
|
934
|
+
const reason = "QA failure limit reached; repair and settle the own child through Kernel before resuming the batch";
|
|
935
|
+
skipDependents(existing, driven.task_id, `dependency ${driven.task_id} parked`);
|
|
936
|
+
existing = writeBatchRunState(input.root, {
|
|
937
|
+
...existing, batch_state: "needs_human", consecutive_qa_failures: failures,
|
|
938
|
+
children: existing.children.map((c) => c.task_id === driven.task_id ? { ...c, state: "needs_human", reason } : c),
|
|
939
|
+
});
|
|
940
|
+
return finalize(input.root, existing, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.",
|
|
941
|
+
{ recovery: deriveAssuranceRecovery(driven.task_id, fresh, {}, fresh.recovery_findings) ?? undefined });
|
|
942
|
+
}
|
|
943
|
+
if (failures !== existing.consecutive_qa_failures)
|
|
944
|
+
existing = writeBatchRunState(input.root, { ...existing, consecutive_qa_failures: failures });
|
|
945
|
+
// A failed preparation can leave frozen inputs with run_qa pending.
|
|
946
|
+
// The Parent owns that attempt; batch re-entry never blindly reruns it.
|
|
947
|
+
if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen")
|
|
948
|
+
return executorHandoff(existing, driven.task_id, fresh);
|
|
949
|
+
}
|
|
990
950
|
if (!holdsClaim) {
|
|
991
951
|
// review-1(4th round): a claimless projection still carries a
|
|
992
952
|
// projection body when the task is a terminal owner. If the fresh
|
|
@@ -1001,6 +961,7 @@ export async function resumeBatch(
|
|
|
1001
961
|
if (settledRemotely) {
|
|
1002
962
|
existing = writeBatchRunState(input.root, {
|
|
1003
963
|
...existing,
|
|
964
|
+
consecutive_qa_failures: 0,
|
|
1004
965
|
children: existing.children.map((c) =>
|
|
1005
966
|
c.task_id === driven.task_id
|
|
1006
967
|
? { ...c, state: "settled", reason: "crash after settlement; resuming at commit" }
|
|
@@ -1178,39 +1139,9 @@ async function driveInterruptedChild(
|
|
|
1178
1139
|
);
|
|
1179
1140
|
record.consecutive_qa_failures = 0;
|
|
1180
1141
|
persist();
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const { commit } = input.git?.commitChild
|
|
1185
|
-
? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
1186
|
-
: input.kernel.commitChild
|
|
1187
|
-
? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
|
|
1188
|
-
: await commitBatchChild({
|
|
1189
|
-
root: input.root,
|
|
1190
|
-
taskId: child.task_id,
|
|
1191
|
-
batchId: input.batch_id,
|
|
1192
|
-
expectedHead: head,
|
|
1193
|
-
branch: record.branch,
|
|
1194
|
-
intentPath,
|
|
1195
|
-
});
|
|
1196
|
-
record.children = record.children.map((c) =>
|
|
1197
|
-
c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
|
|
1198
|
-
);
|
|
1199
|
-
record.commits.push(commit);
|
|
1200
|
-
head = commit;
|
|
1201
|
-
persist();
|
|
1202
|
-
child = { ...child, state: "committed", commit };
|
|
1203
|
-
} catch (error) {
|
|
1204
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1205
|
-
record.children = record.children.map((c) =>
|
|
1206
|
-
c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
|
|
1207
|
-
);
|
|
1208
|
-
// review-4: dependents are skipped_blocked, not left pending.
|
|
1209
|
-
skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
|
|
1210
|
-
record.batch_state = "failed";
|
|
1211
|
-
persist();
|
|
1212
|
-
return finalize(input.root, record, message, "");
|
|
1213
|
-
}
|
|
1142
|
+
// Use the same lookup/commit path for new settlement and crash recovery.
|
|
1143
|
+
child = { ...child, state: "settled", reason: null };
|
|
1144
|
+
continue;
|
|
1214
1145
|
} else if (terminal.state === "review_ready") {
|
|
1215
1146
|
record.children = record.children.map((c) =>
|
|
1216
1147
|
c.task_id === child.task_id
|
|
@@ -1227,24 +1158,31 @@ async function driveInterruptedChild(
|
|
|
1227
1158
|
} else if (terminal.state === "rework" && terminal.operation === "qa") {
|
|
1228
1159
|
// review-3(3rd round): interrupted-child rework applies the same
|
|
1229
1160
|
// consecutive-failure limit as startBatch instead of parking on the
|
|
1230
|
-
// first rework; below the limit
|
|
1161
|
+
// first rework; below the limit implementation returns to the Parent.
|
|
1231
1162
|
record.consecutive_qa_failures += 1;
|
|
1232
1163
|
if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
|
|
1233
|
-
const reason =
|
|
1164
|
+
const reason = "QA failure limit reached";
|
|
1234
1165
|
record.children = record.children.map((c) =>
|
|
1235
1166
|
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
1236
1167
|
);
|
|
1237
1168
|
record.batch_state = "needs_human";
|
|
1238
1169
|
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
1239
1170
|
persist();
|
|
1240
|
-
|
|
1171
|
+
const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
|
|
1172
|
+
return finalize(input.root, record, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.",
|
|
1173
|
+
{ recovery: deriveAssuranceRecovery(child.task_id, fresh, terminal, fresh.recovery_findings) ?? undefined, diagnostics: terminal.diagnostics });
|
|
1241
1174
|
}
|
|
1242
1175
|
persist();
|
|
1176
|
+
const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
|
|
1177
|
+
return executorHandoff(record, child.task_id, fresh, terminal);
|
|
1178
|
+
} else if (terminal.state === "rework" || terminal.environment_failure || terminal.state === "review_preparation_failed" || terminal.recovery?.category === "repair") {
|
|
1179
|
+
// Ordinary own-claim repair is a Parent handoff, not a new user gate.
|
|
1180
|
+
const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
|
|
1181
|
+
return executorHandoff(record, child.task_id, fresh, terminal);
|
|
1243
1182
|
} else {
|
|
1244
1183
|
// stopped | failed | blocked: park and stop.
|
|
1245
1184
|
const reason =
|
|
1246
|
-
terminal.state === "stopped" ? "Kernel reported the child stopped"
|
|
1247
|
-
: terminal.state === "rework" ? terminal.summary : terminal.reason;
|
|
1185
|
+
terminal.state === "stopped" ? "Kernel reported the child stopped" : terminal.reason;
|
|
1248
1186
|
record.children = record.children.map((c) =>
|
|
1249
1187
|
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
1250
1188
|
);
|
|
@@ -315,6 +315,17 @@ export interface BatchRunReport {
|
|
|
315
315
|
children: BatchChildRun[];
|
|
316
316
|
commits: string[];
|
|
317
317
|
reason: string | null;
|
|
318
|
+
/** Non-authoritative foreground implementation obligation, never persisted as readiness. */
|
|
319
|
+
handoff?: {
|
|
320
|
+
role: "executor";
|
|
321
|
+
task_id: string;
|
|
322
|
+
run_id: string | null;
|
|
323
|
+
record_revision: string;
|
|
324
|
+
next_obligation: import("../kernel/types").AssuranceObligation;
|
|
325
|
+
};
|
|
326
|
+
/** Safe observation only; never an execution-readiness certificate. */
|
|
327
|
+
recovery?: import("../assurance/coordinator").AssuranceRecovery;
|
|
328
|
+
diagnostics?: import("../assurance/qa").QaCheckDiagnostic[];
|
|
318
329
|
/** The single next action for the operator. */
|
|
319
330
|
next_action: string;
|
|
320
331
|
created_at: string;
|