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.
@@ -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
- : ["task_id"],
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.3.0" as const;
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
@@ -19,7 +19,7 @@ import type {
19
19
  } from "./types";
20
20
 
21
21
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
22
- const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
22
+ export const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
23
23
  const DEFAULT_QA_FAILURE_LIMIT = 2;
24
24
 
25
25
  /**
@@ -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
- return { corrupt: false, record: candidate };
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
- budget = existingBatch.budget;
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 expiresAt = isResuming && !isExistingExpired && existingBatch!.batch_state === "running"
682
- ? existingBatch!.authorization_expires_at
683
- : budget.deadline_at;
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: AssuranceProjectionResult,
180
+ function requireFreshProjection<T extends AssuranceProjectionResult>(
181
+ result: T,
171
182
  taskId: string,
172
- ): AssuranceProjectionResult & { error: null } {
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 AssuranceProjectionResult & { error: null };
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
- // Drive the child through the Kernel obligation surface only; rework
790
- // below the limit retries the same child in this inner loop.
791
- let childTerminal = false;
792
- while (!childTerminal) {
793
- const terminal = await input.kernel.advanceTask(input.root, child.task_id);
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
- try {
1182
- const planChild = input.children.find((c) => c.task_id === child.task_id);
1183
- const intentPath = planChild?.intent_path ?? undefined;
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 the loop re-drives the same child.
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 = `QA failure limit reached: ${terminal.summary}`;
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
- return finalize(input.root, record, reason, "");
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;