immune-brain 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1039 -465
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
- package/plugins/immune-brain/dist/imm-loop.md +76 -4
- package/plugins/immune-brain/dist/imm-planner.md +65 -1
- package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
- 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 +20 -8
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +321 -60
- package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +73 -26
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +126 -151
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +34 -4
- package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +31 -4
- package/plugins/immune-brain/skills/BASELINE.md +18 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
|
@@ -12,7 +12,18 @@ 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";
|
|
26
|
+
import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
|
|
16
27
|
import {
|
|
17
28
|
type BatchRunnerGitPort,
|
|
18
29
|
type BatchGitPreflightResult,
|
|
@@ -28,6 +39,7 @@ import {
|
|
|
28
39
|
prepareBatchRunState,
|
|
29
40
|
readBatchRunState,
|
|
30
41
|
writeBatchRunState,
|
|
42
|
+
replaceBatchRunState,
|
|
31
43
|
writeBatchRunReport,
|
|
32
44
|
} from "./batch_state";
|
|
33
45
|
|
|
@@ -75,7 +87,7 @@ export interface BatchRunnerKernelPort {
|
|
|
75
87
|
* persistence. Uses the real AssuranceProjectionResult contract; batch
|
|
76
88
|
* ownership of the claim is verified separately through the authoritative
|
|
77
89
|
* batch registry, because the Kernel claim carries no batch id. */
|
|
78
|
-
projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
|
|
90
|
+
projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult & { qa_failure_count?: number; last_qa_failure_at?: string | null; recovery_findings?: NonNullable<TaskRecordRead["record"]>["findings"] }>;
|
|
79
91
|
/** Authoritative batch ownership check: true when the task's Kernel claim
|
|
80
92
|
* is held under this batch's derived capability (consumed child slot). */
|
|
81
93
|
ownsTaskClaim(taskId: string): boolean;
|
|
@@ -92,13 +104,14 @@ export interface BatchRunnerKernelPort {
|
|
|
92
104
|
}): ValidatedBatchAuthorization;
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
export type BatchChildAdvanceResult =
|
|
107
|
+
export type BatchChildAdvanceResult = { diagnostics?: QaCheckDiagnostic[]; environment_failure?: boolean; recovery?: AssuranceRecovery } & (
|
|
96
108
|
| { state: "completed" }
|
|
97
109
|
| { state: "stopped" }
|
|
98
110
|
| { state: "failed"; reason: string }
|
|
99
111
|
| { state: "rework"; operation: "qa" | "review"; summary: string }
|
|
100
112
|
| { state: "blocked"; reason: string }
|
|
101
|
-
| { state: "review_ready"; operation_id: string }
|
|
113
|
+
| { state: "review_ready"; operation_id: string }
|
|
114
|
+
| { state: "review_preparation_failed"; operation: "review"; operation_id: string; reason: string });
|
|
102
115
|
|
|
103
116
|
export interface StartBatchInput {
|
|
104
117
|
root: string;
|
|
@@ -166,13 +179,13 @@ function isAuthorizationExpiryError(error: unknown): boolean {
|
|
|
166
179
|
return error instanceof BatchAuthorizationExpiryError;
|
|
167
180
|
}
|
|
168
181
|
|
|
169
|
-
function requireFreshProjection(
|
|
170
|
-
result:
|
|
182
|
+
function requireFreshProjection<T extends AssuranceProjectionResult>(
|
|
183
|
+
result: T,
|
|
171
184
|
taskId: string,
|
|
172
|
-
):
|
|
185
|
+
): T & { error: null } {
|
|
173
186
|
if (result.error !== null)
|
|
174
187
|
throw new Error(`cannot reconcile Kernel projection for ${taskId}: ${result.error}`);
|
|
175
|
-
return result as
|
|
188
|
+
return result as T & { error: null };
|
|
176
189
|
}
|
|
177
190
|
|
|
178
191
|
/** Next pending child whose direct dependents are all committed. */
|
|
@@ -216,6 +229,29 @@ function reportFor(
|
|
|
216
229
|
};
|
|
217
230
|
}
|
|
218
231
|
|
|
232
|
+
/** Derived observation only: Parent must re-read Kernel ownership before execution. */
|
|
233
|
+
function executorHandoff(
|
|
234
|
+
record: BatchRunStateRecord,
|
|
235
|
+
taskId: string,
|
|
236
|
+
fresh: AssuranceProjectionResult & { error: null; recovery_findings?: NonNullable<TaskRecordRead["record"]>["findings"] },
|
|
237
|
+
failure: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[] } = {},
|
|
238
|
+
): BatchRunReport {
|
|
239
|
+
const recovery = deriveAssuranceRecovery(taskId, fresh, failure, fresh.recovery_findings);
|
|
240
|
+
return {
|
|
241
|
+
...reportFor(record, null, recovery?.next_action ?? (fresh.projection.artifact_state === "frozen"
|
|
242
|
+
? "The child is frozen with pending QA. Call advance_assurance once; follow its diagnostics before retrying, then submit any reserved Review before batch continuation."
|
|
243
|
+
: "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.")),
|
|
244
|
+
...(recovery ? { recovery } : {}),
|
|
245
|
+
...(failure.diagnostics?.length ? { diagnostics: failure.diagnostics } : {}),
|
|
246
|
+
handoff: recovery?.category === "authorization" ? undefined : {
|
|
247
|
+
role: "executor", task_id: taskId,
|
|
248
|
+
run_id: fresh.projection.run_id ?? null,
|
|
249
|
+
record_revision: fresh.projection.record_revision,
|
|
250
|
+
next_obligation: fresh.projection.next_obligation,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
219
255
|
/** review-3(6th round): persist the single run report when the batch
|
|
220
256
|
* stops — including a needs_human park, which ends the current run even
|
|
221
257
|
* though the batch remains resumable after a human decision. */
|
|
@@ -224,8 +260,9 @@ function finalize(
|
|
|
224
260
|
record: BatchRunStateRecord,
|
|
225
261
|
reason: string | null,
|
|
226
262
|
nextAction: string,
|
|
263
|
+
details: Pick<BatchRunReport, "recovery" | "diagnostics"> = {},
|
|
227
264
|
): BatchRunReport {
|
|
228
|
-
const report = reportFor(record, reason, nextAction);
|
|
265
|
+
const report = { ...reportFor(record, reason, nextAction), ...details };
|
|
229
266
|
if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human") {
|
|
230
267
|
writeBatchRunReport(root, report);
|
|
231
268
|
}
|
|
@@ -287,6 +324,11 @@ function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): st
|
|
|
287
324
|
}
|
|
288
325
|
|
|
289
326
|
async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
|
|
327
|
+
if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
|
|
328
|
+
const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
329
|
+
if (head.status !== 0 || (head.stdout.trim() !== record.base_head && !ownUnpersistedBatchHead(input.root, record, head.stdout.trim())))
|
|
330
|
+
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
331
|
+
}
|
|
290
332
|
const plan = input.registry.children(input.capability);
|
|
291
333
|
if (record.plan_digest !== computeBatchPlanDigest(plan) ||
|
|
292
334
|
record.children.length !== plan.length || record.children.some((child, index) => {
|
|
@@ -349,7 +391,7 @@ function replayTerminal(
|
|
|
349
391
|
);
|
|
350
392
|
}
|
|
351
393
|
|
|
352
|
-
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null):
|
|
394
|
+
function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): ValidatedBatchAuthorization {
|
|
353
395
|
const authorized = input.kernel.validateBatchAuthorization({
|
|
354
396
|
registry: input.registry,
|
|
355
397
|
capability: input.capability,
|
|
@@ -374,6 +416,32 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
|
|
|
374
416
|
if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
|
|
375
417
|
input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
|
|
376
418
|
throw new Error("batch run input does not match the authorized plan or base_head");
|
|
419
|
+
return authorized;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
|
|
423
|
+
if (existing.plan_digest === input.plan_digest) return existing;
|
|
424
|
+
const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
|
|
425
|
+
authorization_expires_at: input.authorization_expires_at, budget: input.budget };
|
|
426
|
+
if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
|
|
427
|
+
input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
|
|
428
|
+
throw new Error("plan_digest mismatch: reconfirmation binding changed");
|
|
429
|
+
const validate = () => {
|
|
430
|
+
const plan = input.registry.children(input.capability);
|
|
431
|
+
if (existing.children.length !== plan.length || plan.some((child, index) => {
|
|
432
|
+
const old = existing.children[index]!, nextChild = input.children[index];
|
|
433
|
+
return !nextChild || old.task_id !== child.task_id || old.slice_id !== nextChild.slice_id ||
|
|
434
|
+
JSON.stringify(old.blocked_by) !== JSON.stringify(child.blocked_by) || JSON.stringify(nextChild.blocked_by) !== JSON.stringify(child.blocked_by);
|
|
435
|
+
})) throw new Error("plan_digest mismatch: reconfirmation topology changed");
|
|
436
|
+
const { issued_at: _issuedAt, ...binding } = validateRunAuthorization(input, { ...existing, plan_digest: input.plan_digest });
|
|
437
|
+
if (binding.branch !== existing.branch) throw new Error("plan_digest mismatch: reconfirmation branch changed");
|
|
438
|
+
prepareBatchRunState(input);
|
|
439
|
+
return input.registry.inspect(input.capability, binding);
|
|
440
|
+
};
|
|
441
|
+
const authority = validate();
|
|
442
|
+
const captured = takeReconfirmation(authority.nonce);
|
|
443
|
+
if (captured.root !== input.root || captured.plan_digest !== input.plan_digest) throw new Error("plan_digest mismatch: captured reconfirmation does not match run input");
|
|
444
|
+
return replaceBatchRunState(input.root, captured.stateBytes, next, () => { captured.assertUnchanged(); validate(); });
|
|
377
445
|
}
|
|
378
446
|
|
|
379
447
|
export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
|
|
@@ -425,7 +493,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
425
493
|
}
|
|
426
494
|
|
|
427
495
|
let existing = readBatchRunState(input.root, input.batch_id);
|
|
496
|
+
const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
|
|
428
497
|
if (existing) {
|
|
498
|
+
existing = applyPlanReconfirmation(input, existing);
|
|
429
499
|
try {
|
|
430
500
|
await validatePersistedRun(input, existing);
|
|
431
501
|
} catch (error) {
|
|
@@ -452,7 +522,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
452
522
|
nextConfirmation > priorConfirmation &&
|
|
453
523
|
Number.isFinite(nextExpiry) &&
|
|
454
524
|
nextExpiry > Date.now();
|
|
455
|
-
if (!freshAuthorization) {
|
|
525
|
+
if (!freshAuthorization && !reconfirmed) {
|
|
456
526
|
return finalize(
|
|
457
527
|
input.root,
|
|
458
528
|
existing,
|
|
@@ -461,7 +531,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
461
531
|
);
|
|
462
532
|
}
|
|
463
533
|
|
|
464
|
-
validateRunAuthorization(input, existing);
|
|
534
|
+
if (!reconfirmed) validateRunAuthorization(input, existing);
|
|
465
535
|
prepareBatchRunState({ ...input, now: input.now });
|
|
466
536
|
const remapped = await Promise.all(
|
|
467
537
|
existing.children.map(async (child) => {
|
|
@@ -525,7 +595,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
525
595
|
authorization_expires_at: input.authorization_expires_at,
|
|
526
596
|
budget: input.budget,
|
|
527
597
|
batch_state: "running",
|
|
528
|
-
consecutive_qa_failures: 0,
|
|
529
598
|
children: resumedChildren,
|
|
530
599
|
});
|
|
531
600
|
}
|
|
@@ -786,105 +855,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
786
855
|
break;
|
|
787
856
|
}
|
|
788
857
|
}
|
|
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
|
-
}
|
|
858
|
+
const enrolled = requireFreshProjection(
|
|
859
|
+
await input.kernel.projectTask(input.root, child.task_id), child.task_id,
|
|
860
|
+
);
|
|
861
|
+
return executorHandoff(record, child.task_id, enrolled);
|
|
862
|
+
|
|
888
863
|
}
|
|
889
864
|
|
|
890
865
|
return finalize(input.root, record, stopReasonFor(record), "");
|
|
@@ -923,7 +898,8 @@ export async function resumeBatch(
|
|
|
923
898
|
projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
|
|
924
899
|
): Promise<BatchRunReport> {
|
|
925
900
|
let existing = readBatchRunState(input.root, input.batch_id);
|
|
926
|
-
if (!existing) return startBatch(input);
|
|
901
|
+
if (!existing || existing.batch_state === "needs_human") return startBatch(input);
|
|
902
|
+
existing = applyPlanReconfirmation(input, existing);
|
|
927
903
|
try {
|
|
928
904
|
await validatePersistedRun(input, existing);
|
|
929
905
|
} catch (error) {
|
|
@@ -937,7 +913,6 @@ export async function resumeBatch(
|
|
|
937
913
|
}
|
|
938
914
|
if (isTerminalBatchState(existing.batch_state))
|
|
939
915
|
return replayTerminal(input.root, existing);
|
|
940
|
-
if (existing.batch_state === "needs_human") return startBatch(input);
|
|
941
916
|
|
|
942
917
|
// An enrolled-but-unsettled child must reach its own Kernel terminal
|
|
943
918
|
// settlement before anything else, even under an expired authorization:
|
|
@@ -987,6 +962,26 @@ export async function resumeBatch(
|
|
|
987
962
|
});
|
|
988
963
|
return finalize(input.root, existing, "claim held by another batch", "needs-human-attention");
|
|
989
964
|
}
|
|
965
|
+
if (holdsClaim) {
|
|
966
|
+
const failures = Math.max(existing.consecutive_qa_failures, fresh.qa_failure_count ?? 0);
|
|
967
|
+
if (failures >= existing.budget.qa_failure_limit && fresh.last_qa_failure_at &&
|
|
968
|
+
Date.parse(fresh.last_qa_failure_at) > Date.parse(existing.confirmation_time)) {
|
|
969
|
+
const reason = "QA failure limit reached; repair and settle the own child through Kernel before resuming the batch";
|
|
970
|
+
skipDependents(existing, driven.task_id, `dependency ${driven.task_id} parked`);
|
|
971
|
+
existing = writeBatchRunState(input.root, {
|
|
972
|
+
...existing, batch_state: "needs_human", consecutive_qa_failures: failures,
|
|
973
|
+
children: existing.children.map((c) => c.task_id === driven.task_id ? { ...c, state: "needs_human", reason } : c),
|
|
974
|
+
});
|
|
975
|
+
return finalize(input.root, existing, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.",
|
|
976
|
+
{ recovery: deriveAssuranceRecovery(driven.task_id, fresh, {}, fresh.recovery_findings) ?? undefined });
|
|
977
|
+
}
|
|
978
|
+
if (failures !== existing.consecutive_qa_failures)
|
|
979
|
+
existing = writeBatchRunState(input.root, { ...existing, consecutive_qa_failures: failures });
|
|
980
|
+
// A failed preparation can leave frozen inputs with run_qa pending.
|
|
981
|
+
// The Parent owns that attempt; batch re-entry never blindly reruns it.
|
|
982
|
+
if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen")
|
|
983
|
+
return executorHandoff(existing, driven.task_id, fresh);
|
|
984
|
+
}
|
|
990
985
|
if (!holdsClaim) {
|
|
991
986
|
// review-1(4th round): a claimless projection still carries a
|
|
992
987
|
// projection body when the task is a terminal owner. If the fresh
|
|
@@ -1001,6 +996,7 @@ export async function resumeBatch(
|
|
|
1001
996
|
if (settledRemotely) {
|
|
1002
997
|
existing = writeBatchRunState(input.root, {
|
|
1003
998
|
...existing,
|
|
999
|
+
consecutive_qa_failures: 0,
|
|
1004
1000
|
children: existing.children.map((c) =>
|
|
1005
1001
|
c.task_id === driven.task_id
|
|
1006
1002
|
? { ...c, state: "settled", reason: "crash after settlement; resuming at commit" }
|
|
@@ -1142,6 +1138,8 @@ async function driveInterruptedChild(
|
|
|
1142
1138
|
intentPath,
|
|
1143
1139
|
});
|
|
1144
1140
|
};
|
|
1141
|
+
if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
|
|
1142
|
+
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
1145
1143
|
const adopted =
|
|
1146
1144
|
existing ??
|
|
1147
1145
|
(await doCommit().catch((error: unknown) => {
|
|
@@ -1178,39 +1176,9 @@ async function driveInterruptedChild(
|
|
|
1178
1176
|
);
|
|
1179
1177
|
record.consecutive_qa_failures = 0;
|
|
1180
1178
|
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
|
-
}
|
|
1179
|
+
// Use the same lookup/commit path for new settlement and crash recovery.
|
|
1180
|
+
child = { ...child, state: "settled", reason: null };
|
|
1181
|
+
continue;
|
|
1214
1182
|
} else if (terminal.state === "review_ready") {
|
|
1215
1183
|
record.children = record.children.map((c) =>
|
|
1216
1184
|
c.task_id === child.task_id
|
|
@@ -1227,24 +1195,31 @@ async function driveInterruptedChild(
|
|
|
1227
1195
|
} else if (terminal.state === "rework" && terminal.operation === "qa") {
|
|
1228
1196
|
// review-3(3rd round): interrupted-child rework applies the same
|
|
1229
1197
|
// consecutive-failure limit as startBatch instead of parking on the
|
|
1230
|
-
// first rework; below the limit
|
|
1198
|
+
// first rework; below the limit implementation returns to the Parent.
|
|
1231
1199
|
record.consecutive_qa_failures += 1;
|
|
1232
1200
|
if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
|
|
1233
|
-
const reason =
|
|
1201
|
+
const reason = "QA failure limit reached";
|
|
1234
1202
|
record.children = record.children.map((c) =>
|
|
1235
1203
|
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
1236
1204
|
);
|
|
1237
1205
|
record.batch_state = "needs_human";
|
|
1238
1206
|
skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
|
|
1239
1207
|
persist();
|
|
1240
|
-
|
|
1208
|
+
const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
|
|
1209
|
+
return finalize(input.root, record, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.",
|
|
1210
|
+
{ recovery: deriveAssuranceRecovery(child.task_id, fresh, terminal, fresh.recovery_findings) ?? undefined, diagnostics: terminal.diagnostics });
|
|
1241
1211
|
}
|
|
1242
1212
|
persist();
|
|
1213
|
+
const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
|
|
1214
|
+
return executorHandoff(record, child.task_id, fresh, terminal);
|
|
1215
|
+
} else if (terminal.state === "rework" || terminal.environment_failure || terminal.state === "review_preparation_failed" || terminal.recovery?.category === "repair") {
|
|
1216
|
+
// Ordinary own-claim repair is a Parent handoff, not a new user gate.
|
|
1217
|
+
const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
|
|
1218
|
+
return executorHandoff(record, child.task_id, fresh, terminal);
|
|
1243
1219
|
} else {
|
|
1244
1220
|
// stopped | failed | blocked: park and stop.
|
|
1245
1221
|
const reason =
|
|
1246
|
-
terminal.state === "stopped" ? "Kernel reported the child stopped"
|
|
1247
|
-
: terminal.state === "rework" ? terminal.summary : terminal.reason;
|
|
1222
|
+
terminal.state === "stopped" ? "Kernel reported the child stopped" : terminal.reason;
|
|
1248
1223
|
record.children = record.children.map((c) =>
|
|
1249
1224
|
c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
|
|
1250
1225
|
);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, renameSync, lstatSync, constants, rmSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { readSecureProjectFile, withKernelStoreLock } from "../kernel/storage";
|
|
7
|
+
import { readSecureProjectFile, readSecureProjectBytes, withKernelStoreLock } from "../kernel/storage";
|
|
8
8
|
import type { BatchPlanChild } from "./types";
|
|
9
9
|
|
|
10
10
|
export type BatchRunState =
|
|
@@ -243,12 +243,18 @@ export function prepareBatchRunState(input: {
|
|
|
243
243
|
return prepared;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/** Validate a captured snapshot without reading or writing filesystem state. */
|
|
247
|
+
export function parseBatchRunState(raw: string, batchId: string): BatchRunStateRecord {
|
|
248
|
+
validateBatchId(batchId);
|
|
249
|
+
const parsed: unknown = JSON.parse(raw);
|
|
250
|
+
validateRecordShape(parsed, batchId);
|
|
251
|
+
return parsed;
|
|
252
|
+
}
|
|
253
|
+
|
|
246
254
|
export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
|
|
247
255
|
const path = statePath(batchId);
|
|
248
256
|
if (!existsSync(join(root, path))) return null;
|
|
249
|
-
|
|
250
|
-
validateRecordShape(parsed, batchId);
|
|
251
|
-
return parsed;
|
|
257
|
+
return parseBatchRunState(readSecureProjectFile(root, path), batchId);
|
|
252
258
|
}
|
|
253
259
|
|
|
254
260
|
function ensureSecureDirectory(root: string, relative: string): string {
|
|
@@ -287,6 +293,19 @@ function writeFileAtomically(root: string, relative: string, bytes: string): voi
|
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
/** Replace an existing authorization only after a locked expected-byte check. */
|
|
297
|
+
export function replaceBatchRunState(root: string, expected: Buffer, next: BatchRunStateRecord, validate: () => void): BatchRunStateRecord {
|
|
298
|
+
const path = statePath(next.batch_id);
|
|
299
|
+
validateRecordShape(next, next.batch_id);
|
|
300
|
+
return withKernelStoreLock(root, () => {
|
|
301
|
+
if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
|
|
302
|
+
validate();
|
|
303
|
+
const stored = { ...next, updated_at: new Date().toISOString() };
|
|
304
|
+
writeFileAtomically(root, path, canonicalBytes(stored));
|
|
305
|
+
return stored;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
290
309
|
export function writeBatchRunState(
|
|
291
310
|
root: string,
|
|
292
311
|
record: BatchRunStateRecord,
|
|
@@ -315,6 +334,17 @@ export interface BatchRunReport {
|
|
|
315
334
|
children: BatchChildRun[];
|
|
316
335
|
commits: string[];
|
|
317
336
|
reason: string | null;
|
|
337
|
+
/** Non-authoritative foreground implementation obligation, never persisted as readiness. */
|
|
338
|
+
handoff?: {
|
|
339
|
+
role: "executor";
|
|
340
|
+
task_id: string;
|
|
341
|
+
run_id: string | null;
|
|
342
|
+
record_revision: string;
|
|
343
|
+
next_obligation: import("../kernel/types").AssuranceObligation;
|
|
344
|
+
};
|
|
345
|
+
/** Safe observation only; never an execution-readiness certificate. */
|
|
346
|
+
recovery?: import("../assurance/coordinator").AssuranceRecovery;
|
|
347
|
+
diagnostics?: import("../assurance/qa").QaCheckDiagnostic[];
|
|
318
348
|
/** The single next action for the operator. */
|
|
319
349
|
next_action: string;
|
|
320
350
|
created_at: string;
|
|
@@ -176,6 +176,15 @@ async function runKernelCli(args: string[], root: string): Promise<{
|
|
|
176
176
|
// status --json, and the explicit audit command. All other kernel
|
|
177
177
|
// subcommands (readiness, journal, migrate) are retired.
|
|
178
178
|
const sub = args[0] ?? "";
|
|
179
|
+
// The canonical author shape is discoverable before any error probing: a
|
|
180
|
+
// caller who does not know the destination/stdin contract reads it here
|
|
181
|
+
// instead of discovering it through a failed call.
|
|
182
|
+
if (sub === "intent" && (args[1] === "--help" || args[1] === "help" || args[1] === undefined))
|
|
183
|
+
return {
|
|
184
|
+
stdout: `${intentAuthorHelp()}\n`,
|
|
185
|
+
stderr: "",
|
|
186
|
+
returncode: 0,
|
|
187
|
+
};
|
|
179
188
|
// The explicit claimless storage-layout migration is reachable here: a
|
|
180
189
|
// worktree still on the retired file store has no other entry point, and
|
|
181
190
|
// every mutating subcommand stays fail-closed behind it.
|
|
@@ -224,6 +233,51 @@ async function runCli(command: string, args: string[], root: string): Promise<{
|
|
|
224
233
|
};
|
|
225
234
|
}
|
|
226
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The canonical TaskIntent author contract, printed by `imm-kernel intent` and
|
|
238
|
+
* `imm-kernel intent --help`. It states destination, stdin, and the strict v2
|
|
239
|
+
* descriptor shape before a caller has to discover them by failing a call.
|
|
240
|
+
*/
|
|
241
|
+
export function intentAuthorHelp(): string {
|
|
242
|
+
return [
|
|
243
|
+
"imm-kernel intent author <destination> --stdin --json",
|
|
244
|
+
" destination: docs/plans/<task-id>.intent.json (created exclusively; never overwritten)",
|
|
245
|
+
" stdin: the complete candidate TaskIntent JSON on file descriptor 0",
|
|
246
|
+
" stdout: { valid, enrollment_ready, intent_ref, git_base_initialization_required, ... }",
|
|
247
|
+
"",
|
|
248
|
+
"imm-kernel intent validate <path> --json",
|
|
249
|
+
"",
|
|
250
|
+
"Candidate shape (assurance_kernel/task_intent/v1):",
|
|
251
|
+
JSON.stringify(
|
|
252
|
+
{
|
|
253
|
+
contract: "assurance_kernel/task_intent/v1",
|
|
254
|
+
task_id: "<task-id>",
|
|
255
|
+
owner: "user",
|
|
256
|
+
goal: "<one closable outcome>",
|
|
257
|
+
risk: "routine|material|critical",
|
|
258
|
+
revision: 1,
|
|
259
|
+
scope_hint: ["<every file the slice touches>"],
|
|
260
|
+
acceptance: [
|
|
261
|
+
{
|
|
262
|
+
id: "<ID>",
|
|
263
|
+
assertion: "<observable invariant>",
|
|
264
|
+
verification: JSON.stringify({
|
|
265
|
+
contract: "assurance_kernel/verification_descriptor/v2",
|
|
266
|
+
command: { executable: "bun", argv: ["test", "tests/<focused>.test.ts"], cwd: ".", timeout_ms: 180000, max_output_bytes: 196608 },
|
|
267
|
+
environment: { prepare: null, writable_paths: [] },
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
null,
|
|
273
|
+
2,
|
|
274
|
+
),
|
|
275
|
+
"",
|
|
276
|
+
"Unknown fields are rejected; the descriptor parser is strict v2 and never infers a runner.",
|
|
277
|
+
"A v1 descriptor is historical-only and needs an explicit Intent revision before execution.",
|
|
278
|
+
].join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
async function main(argv: string[]): Promise<number> {
|
|
228
282
|
const mode = argv[0];
|
|
229
283
|
const root = process.cwd();
|
|
@@ -19,11 +19,13 @@ export interface GitWorkspaceSnapshot {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function git(root: string, args: string[]): string | null {
|
|
22
|
-
const
|
|
22
|
+
const diff = args[0] === "diff";
|
|
23
|
+
const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args;
|
|
24
|
+
const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], {
|
|
23
25
|
encoding: "utf8",
|
|
24
26
|
stdio: ["ignore", "pipe", "pipe"],
|
|
25
27
|
});
|
|
26
|
-
return result.status === 0 ? result.stdout : null;
|
|
28
|
+
return result.status === 0 || (diff && result.status === 1) ? result.stdout : null;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function splitNull(value: string): string[] {
|
|
@@ -217,12 +219,14 @@ export function setGitTaskSnapshotTestHook(hook: () => void): () => void {
|
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
function gitBytes(root: string, args: string[]): Buffer {
|
|
220
|
-
const
|
|
222
|
+
const diff = args[0] === "diff";
|
|
223
|
+
const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args;
|
|
224
|
+
const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], {
|
|
221
225
|
encoding: null,
|
|
222
226
|
stdio: ["ignore", "pipe", "pipe"],
|
|
223
227
|
maxBuffer: 8 * 1024 * 1024,
|
|
224
228
|
});
|
|
225
|
-
if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) {
|
|
229
|
+
if ((result.status !== 0 && !(diff && result.status === 1)) || !Buffer.isBuffer(result.stdout)) {
|
|
226
230
|
throw new Error(`Git task snapshot command failed: git ${args.join(" ")}`);
|
|
227
231
|
}
|
|
228
232
|
return result.stdout;
|
|
@@ -631,6 +635,29 @@ export function taskRevisionIdentity(
|
|
|
631
635
|
};
|
|
632
636
|
}
|
|
633
637
|
|
|
638
|
+
/** The same v4 identity for an immutable delivered commit, without touching the index. */
|
|
639
|
+
export function taskCommitRevisionIdentity(projectRoot: string, scopeHint: unknown, baseHead: string, commit: string): GitTaskDiffIdentity {
|
|
640
|
+
const requested = resolve(projectRoot);
|
|
641
|
+
if (lstatSync(requested).isSymbolicLink()) throw new Error("task commit root must be a real directory");
|
|
642
|
+
const root = realpathSync(requested), scope = assertCanonicalTaskScope(scopeHint);
|
|
643
|
+
if (!GIT_OBJECT_ID.test(baseHead) || !GIT_OBJECT_ID.test(commit)) throw new Error("invalid task commit identity");
|
|
644
|
+
if (gitRequired(root, ["cat-file", "-t", baseHead], "unreadable task base") !== "commit" ||
|
|
645
|
+
gitRequired(root, ["cat-file", "-t", commit], "unreadable task commit") !== "commit" ||
|
|
646
|
+
git(root, ["merge-base", "--is-ancestor", baseHead, commit]) === null) throw new Error("invalid task commit ancestry");
|
|
647
|
+
const paths = decodeNullPaths(gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", baseHead, commit, "--"]), "task commit paths")
|
|
648
|
+
.filter(path => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope)).sort(comparePaths);
|
|
649
|
+
assertNoCaseFoldCollisions(paths, "task commit paths");
|
|
650
|
+
const changed_paths: Record<string, GitTaskIndexEntry> = {};
|
|
651
|
+
for (const path of paths) {
|
|
652
|
+
const current = headEntry(root, commit, path), base = headEntry(root, baseHead, path);
|
|
653
|
+
changed_paths[path] = { status: !base ? "added" : !current ? "deleted" : "modified",
|
|
654
|
+
mode: current?.mode ?? null, oid: current?.oid ?? null, base_mode: base?.mode ?? null, base_oid: base?.oid ?? null };
|
|
655
|
+
}
|
|
656
|
+
const snapshot: GitTaskRevisionSnapshot = { kind: "git-task-revision-v1", repository_root: root, base_head: baseHead,
|
|
657
|
+
base_tree: gitRequired(root, ["rev-parse", `${baseHead}^{tree}`], "unreadable task base tree"), scope, changed_paths };
|
|
658
|
+
return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: paths };
|
|
659
|
+
}
|
|
660
|
+
|
|
634
661
|
/** The single v4 freshness identity shared by QA, Review, authorization, and completion. */
|
|
635
662
|
export function taskRevisionDiffHash(
|
|
636
663
|
projectRoot: string,
|