immune-brain 3.6.6 → 3.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/README.zh-CN.md +23 -3
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
- package/plugins/immune-brain/dist/imm-loop.md +22 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
- package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
AssuranceCoordinator,
|
|
7
|
+
reviewReworkFindings,
|
|
7
8
|
type AssuranceCoordinatorPorts,
|
|
8
9
|
type AssuranceSubmitReviewResult,
|
|
9
10
|
type AssuranceVerdict,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
} from "../assurance/coordinator";
|
|
13
14
|
import {
|
|
14
15
|
assertRunnerCompatible,
|
|
15
|
-
findingsDigest,
|
|
16
16
|
resolveBunRunner,
|
|
17
17
|
type FrozenRunner,
|
|
18
18
|
type VerificationDescriptor,
|
|
@@ -25,8 +25,9 @@ import {
|
|
|
25
25
|
} from "../assurance/review_evidence";
|
|
26
26
|
import { parseVerificationDescriptor } from "../verification_descriptor";
|
|
27
27
|
import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
28
|
-
import { isTaskRecordV4, type TaskApprovalV2, type TaskRecord } from "../kernel/types";
|
|
29
|
-
import {
|
|
28
|
+
import { isTaskRecordV4, type TaskApprovalV2, type TaskFinding, type TaskRecord } from "../kernel/types";
|
|
29
|
+
import { findingsDigestV2 } from "../kernel/reducer";
|
|
30
|
+
import { readAuditTaskPair, readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
|
|
30
31
|
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
31
32
|
import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
|
|
32
33
|
import {
|
|
@@ -43,7 +44,33 @@ import { enrollCanaryTask, runEnrollmentRehearsal } from "../kernel/enrollment";
|
|
|
43
44
|
import { reconcileKernelAuthority, repairKernelAuthority } from "../kernel/storage";
|
|
44
45
|
import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare";
|
|
45
46
|
import { runDeterministicQa } from "../assurance/qa";
|
|
46
|
-
import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
|
|
47
|
+
import { taskDiffIdentity, taskRevisionIdentity, pathMatchesScope } from "../workspace_scope";
|
|
48
|
+
import { projectBatchPlan } from "../unattended/batch_plan";
|
|
49
|
+
import {
|
|
50
|
+
startBatch,
|
|
51
|
+
type BatchRunnerKernelPort,
|
|
52
|
+
type BatchRunReport,
|
|
53
|
+
} from "../unattended/batch_runner";
|
|
54
|
+
import {
|
|
55
|
+
createBatchAuthorityRegistry,
|
|
56
|
+
computeBatchPlanDigest,
|
|
57
|
+
deriveChildEnrollment,
|
|
58
|
+
type BatchAuthorityRegistry,
|
|
59
|
+
type BatchAuthorizationBinding,
|
|
60
|
+
} from "../kernel/batch_authority";
|
|
61
|
+
import {
|
|
62
|
+
runBatchGitPreflight,
|
|
63
|
+
type BatchRunnerGitPort,
|
|
64
|
+
} from "../unattended/batch_git";
|
|
65
|
+
import type {
|
|
66
|
+
BatchPlan,
|
|
67
|
+
BatchPlanChild,
|
|
68
|
+
InitiativeObservationReader,
|
|
69
|
+
} from "../unattended/types";
|
|
70
|
+
import { observeGithubInitiative } from "../github_issue_tracker";
|
|
71
|
+
import { readWorkspaceStateRaw } from "../kernel/storage";
|
|
72
|
+
import { readBackendClaim } from "../kernel/backend_claim";
|
|
73
|
+
import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
47
74
|
import {
|
|
48
75
|
confirmationRef,
|
|
49
76
|
enrollmentNonce,
|
|
@@ -344,7 +371,7 @@ async function mintCapability(
|
|
|
344
371
|
confirmation_ref: input.confirmation_ref,
|
|
345
372
|
expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
|
346
373
|
findings_digest: input.action_kind === "request_rework"
|
|
347
|
-
?
|
|
374
|
+
? findingsDigestV2(input.findings as TaskFinding[])
|
|
348
375
|
: null,
|
|
349
376
|
};
|
|
350
377
|
return registry.issue(binding);
|
|
@@ -368,6 +395,99 @@ export interface ClaudeRuntimeOptions {
|
|
|
368
395
|
interactive?: boolean;
|
|
369
396
|
permissionMode?: PermissionMode;
|
|
370
397
|
requestConfirmation?: NativeConfirmationPort;
|
|
398
|
+
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
399
|
+
batchGit?: BatchRunnerGitPort;
|
|
400
|
+
readInitiative?: InitiativeObservationReader;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Synchronous re-verification that the currently held claim for `taskId` is this
|
|
405
|
+
* batch's own Kernel enrollment. Positive evidence only (see the Pi adapter):
|
|
406
|
+
* the driver's durable child slot, the batch Git lineage, the Kernel event-id
|
|
407
|
+
* derivation, the intent identity, and claim creation before the batch's last
|
|
408
|
+
* durable write. The mutable confirmation_time is deliberately not used, so a
|
|
409
|
+
* needs_human re-authorization can never turn this batch's own claim foreign.
|
|
410
|
+
* Called fresh pre/post confirmation and from ownsTaskClaim.
|
|
411
|
+
*/
|
|
412
|
+
function syncIsOwnBatchClaim(
|
|
413
|
+
cwd: string,
|
|
414
|
+
existingBatch: any,
|
|
415
|
+
taskId: string,
|
|
416
|
+
batchBranch: string,
|
|
417
|
+
): boolean {
|
|
418
|
+
let claim: any = null;
|
|
419
|
+
let workspace: any = null;
|
|
420
|
+
try {
|
|
421
|
+
claim = JSON.parse(readFileSync(join(cwd, ".imm", "state", "active-claim.json"), "utf8"));
|
|
422
|
+
workspace = JSON.parse(readFileSync(join(cwd, ".imm", "state", "workspace.json"), "utf8"));
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
const currentTaskId =
|
|
427
|
+
workspace?.state?.current_working ||
|
|
428
|
+
(claim?.lifecycle_status === "active" ? claim?.task_id : null);
|
|
429
|
+
if (currentTaskId !== taskId || !claim) return false;
|
|
430
|
+
const branch = spawnSync("git", ["-C", cwd, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
|
|
431
|
+
if (branch !== batchBranch) return false;
|
|
432
|
+
const childInBatch = existingBatch.children.find((c: { task_id: string }) => c.task_id === taskId);
|
|
433
|
+
if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
let rec: any = null;
|
|
437
|
+
try {
|
|
438
|
+
rec = JSON.parse(readFileSync(join(cwd, ".imm", "state", "tasks", `${taskId}.json`), "utf8"));
|
|
439
|
+
} catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
const lineageHeads = [existingBatch.base_head].concat(
|
|
443
|
+
Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
|
|
444
|
+
);
|
|
445
|
+
if (!lineageHeads.includes(rec.git_base_head)) return false;
|
|
446
|
+
if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
|
|
447
|
+
const createdAt = Date.parse(claim.created_at);
|
|
448
|
+
if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
|
|
449
|
+
if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
|
|
450
|
+
if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
|
|
451
|
+
if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function findExistingActiveBatch(root: string, initiativeSlug: string): any {
|
|
456
|
+
const batchesDir = join(root, ".imm", "state", "batches");
|
|
457
|
+
if (!existsSync(batchesDir)) return null;
|
|
458
|
+
const files = readdirSync(batchesDir);
|
|
459
|
+
for (const file of files) {
|
|
460
|
+
if (!file.endsWith(".json")) continue;
|
|
461
|
+
let record: any;
|
|
462
|
+
try {
|
|
463
|
+
record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
|
|
464
|
+
} catch {
|
|
465
|
+
// Unreadable Kernel batch state must fail closed: silently treating the
|
|
466
|
+
// initiative as batchless could authorize a parallel run.
|
|
467
|
+
return { corrupt: true, path: file };
|
|
468
|
+
}
|
|
469
|
+
if (record.contract === "assurance_kernel/batch_run_state/v1" && record.initiative_slug === initiativeSlug) {
|
|
470
|
+
const validStates = new Set([
|
|
471
|
+
"prepared",
|
|
472
|
+
"running",
|
|
473
|
+
"needs_human",
|
|
474
|
+
"completed",
|
|
475
|
+
"budget_stopped",
|
|
476
|
+
"failed",
|
|
477
|
+
"rejected",
|
|
478
|
+
]);
|
|
479
|
+
if (
|
|
480
|
+
typeof record.batch_id !== "string" ||
|
|
481
|
+
typeof record.base_head !== "string" ||
|
|
482
|
+
!Array.isArray(record.children) ||
|
|
483
|
+
!validStates.has(record.batch_state)
|
|
484
|
+
) {
|
|
485
|
+
return { corrupt: true, path: file };
|
|
486
|
+
}
|
|
487
|
+
return record;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
371
491
|
}
|
|
372
492
|
|
|
373
493
|
export class ClaudeRuntime {
|
|
@@ -380,13 +500,20 @@ export class ClaudeRuntime {
|
|
|
380
500
|
private hostVersion: string | undefined;
|
|
381
501
|
private mutationRegistry: MutationAuthorityRegistry | null = null;
|
|
382
502
|
private enrollmentRegistry = createEnrollmentAuthorityRegistry();
|
|
503
|
+
private batchRegistry = createBatchAuthorityRegistry();
|
|
383
504
|
private app: ReturnType<typeof createCanaryApplication> | null = null;
|
|
505
|
+
private readonly batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
506
|
+
private readonly batchGit?: BatchRunnerGitPort;
|
|
507
|
+
private readonly readInitiative?: InitiativeObservationReader;
|
|
384
508
|
|
|
385
509
|
constructor(options: ClaudeRuntimeOptions) {
|
|
386
510
|
this.cwd = options.cwd;
|
|
387
511
|
this.env = options.env ?? process.env;
|
|
388
512
|
this.interactive = options.interactive ?? true;
|
|
389
513
|
this.requestConfirmation = options.requestConfirmation;
|
|
514
|
+
this.batchKernel = options.batchKernel;
|
|
515
|
+
this.batchGit = options.batchGit;
|
|
516
|
+
this.readInitiative = options.readInitiative;
|
|
390
517
|
this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
|
|
391
518
|
this.coordinator = new AssuranceCoordinator({
|
|
392
519
|
...this.createKernelPorts(),
|
|
@@ -541,6 +668,23 @@ export class ClaudeRuntime {
|
|
|
541
668
|
});
|
|
542
669
|
}
|
|
543
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Ordinary Kernel operation, not a privileged one: the actor cannot assert a
|
|
673
|
+
* refutation, it can only bind a fresh passing QA attestation the Kernel
|
|
674
|
+
* already validated. The reducer owns every precondition.
|
|
675
|
+
*/
|
|
676
|
+
async refuteFinding(taskId: string, findingId: string, attestationId: string) {
|
|
677
|
+
return this.executeOrdinary({ cwd: this.cwd }, {
|
|
678
|
+
taskId,
|
|
679
|
+
operation: {
|
|
680
|
+
op: "refute_finding",
|
|
681
|
+
finding_id: findingId,
|
|
682
|
+
attestation_id: attestationId,
|
|
683
|
+
actor_id: "executor",
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
544
688
|
async authorize(taskId: string, operation: string, meta: ToolMeta, extra: Record<string, unknown> = {}) {
|
|
545
689
|
if (operation === "repair_authority_state") {
|
|
546
690
|
const authority = reconcileKernelAuthority(this.cwd, taskId);
|
|
@@ -726,15 +870,7 @@ export class ClaudeRuntime {
|
|
|
726
870
|
return result;
|
|
727
871
|
};
|
|
728
872
|
if (input.verdict.decision === "rework") {
|
|
729
|
-
const findings = input.verdict
|
|
730
|
-
id: finding.id,
|
|
731
|
-
kind: finding.kind,
|
|
732
|
-
status: "open",
|
|
733
|
-
acceptance_id: finding.acceptance_id,
|
|
734
|
-
source: "review",
|
|
735
|
-
review_round: null,
|
|
736
|
-
summary: finding.summary,
|
|
737
|
-
}));
|
|
873
|
+
const findings = reviewReworkFindings(input.verdict);
|
|
738
874
|
const capability = await mintCapability(registry, {
|
|
739
875
|
authority_kind: input.snapshot.role,
|
|
740
876
|
task_id: input.taskId,
|
|
@@ -795,7 +931,7 @@ export class ClaudeRuntime {
|
|
|
795
931
|
}));
|
|
796
932
|
}
|
|
797
933
|
|
|
798
|
-
private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown; finding_id?: string } }) {
|
|
934
|
+
private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown; finding_id?: string; attestation_id?: string } }) {
|
|
799
935
|
const { app } = await this.authority();
|
|
800
936
|
const operation = input.operation.op === "revise_intent"
|
|
801
937
|
? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) }
|
|
@@ -823,12 +959,699 @@ export class ClaudeRuntime {
|
|
|
823
959
|
throw error;
|
|
824
960
|
}
|
|
825
961
|
}
|
|
962
|
+
|
|
963
|
+
async startUnattendedBatch(
|
|
964
|
+
initiativeSlug: string,
|
|
965
|
+
meta: ToolMeta,
|
|
966
|
+
): Promise<
|
|
967
|
+
| { state: "started"; batch_id: string; report: BatchRunReport }
|
|
968
|
+
| { state: "rejected"; reason: string; recovery_action: string }
|
|
969
|
+
| { state: "cancelled"; reason: string; recovery_action: string }
|
|
970
|
+
| { state: "blocked"; reason: string; recovery_action: string }
|
|
971
|
+
> {
|
|
972
|
+
throwIfCancelled(meta.signal);
|
|
973
|
+
const probe = probeHost(this.env, process.platform, this.hostVersion);
|
|
974
|
+
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
975
|
+
const interactive = meta.interactive ?? this.interactive;
|
|
976
|
+
if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
|
|
977
|
+
if (!this.requestConfirmation) throw new NativeAuthorityError("interaction_not_opened", "native confirmation port is unavailable");
|
|
978
|
+
|
|
979
|
+
// 1. Validate initiative slug
|
|
980
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(initiativeSlug)) {
|
|
981
|
+
return {
|
|
982
|
+
state: "rejected",
|
|
983
|
+
reason: `invalid initiative slug: ${initiativeSlug}`,
|
|
984
|
+
recovery_action: "specify a valid initiative slug and retry in the current Host",
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Check for an existing active/paused batch for this initiative
|
|
989
|
+
const existingBatch = findExistingActiveBatch(this.cwd, initiativeSlug);
|
|
990
|
+
const isResuming = existingBatch !== null;
|
|
991
|
+
if (existingBatch?.corrupt) {
|
|
992
|
+
return {
|
|
993
|
+
state: "blocked",
|
|
994
|
+
reason: `batch run state is unreadable or invalid: ${existingBatch.path}`,
|
|
995
|
+
recovery_action: "resolve or remove the invalid batch state file, then retry in the current Host",
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const batchBranch = `imm/${initiativeSlug}`;
|
|
999
|
+
|
|
1000
|
+
// 2. Active workspace claim check (pre-confirmation)
|
|
1001
|
+
const workspaceState = readWorkspaceStateRaw(this.cwd);
|
|
1002
|
+
const claim = readBackendClaim(this.cwd);
|
|
1003
|
+
const activeTaskId = workspaceState.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
|
|
1004
|
+
const isOwnClaim =
|
|
1005
|
+
isResuming &&
|
|
1006
|
+
activeTaskId !== null &&
|
|
1007
|
+
syncIsOwnBatchClaim(this.cwd, existingBatch, activeTaskId, batchBranch);
|
|
1008
|
+
if (activeTaskId && !isOwnClaim) {
|
|
1009
|
+
return {
|
|
1010
|
+
state: "blocked",
|
|
1011
|
+
reason: `an active workspace claim already exists for task: ${activeTaskId}`,
|
|
1012
|
+
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// 3. Git HEAD & read-only preflight check (pre-confirmation)
|
|
1017
|
+
let baseHead: string;
|
|
1018
|
+
try {
|
|
1019
|
+
baseHead = readGitHead(this.cwd);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
return {
|
|
1022
|
+
state: "rejected",
|
|
1023
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1024
|
+
recovery_action: "commit working changes and ensure a committed Git HEAD exists in the current Host",
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Read-only check: verify clean tree and branch does not exist without creating refs before confirmation
|
|
1029
|
+
const branchExists = spawnSync("git", ["-C", this.cwd, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
|
|
1030
|
+
if (branchExists.status === 0 && !isResuming) {
|
|
1031
|
+
return {
|
|
1032
|
+
state: "rejected",
|
|
1033
|
+
reason: `branch preflight failed: branch refs/heads/${batchBranch} already exists`,
|
|
1034
|
+
recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
// review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
|
|
1038
|
+
// format so an unstaged modification (" M path") keeps its status columns.
|
|
1039
|
+
const statusProc = spawnSync("git", ["-C", this.cwd, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], {
|
|
1040
|
+
encoding: "utf8",
|
|
1041
|
+
});
|
|
1042
|
+
if (statusProc.status !== 0) {
|
|
1043
|
+
return {
|
|
1044
|
+
state: "rejected",
|
|
1045
|
+
reason: "branch preflight failed: git status is unreadable",
|
|
1046
|
+
recovery_action: "check the repository integrity and retry in the current Host",
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
const statusEntries: Array<{ code: string; path: string }> = [];
|
|
1050
|
+
// -z with --no-renames lists each side of a rename as its own D/A entry, so a
|
|
1051
|
+
// cross-scope rename cannot hide the out-of-scope source deletion.
|
|
1052
|
+
for (const entry of statusProc.stdout.split("\0")) {
|
|
1053
|
+
if (entry.length === 0) continue;
|
|
1054
|
+
statusEntries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
|
|
1055
|
+
}
|
|
1056
|
+
if (statusEntries.length > 0) {
|
|
1057
|
+
if (!isResuming) {
|
|
1058
|
+
return {
|
|
1059
|
+
state: "rejected",
|
|
1060
|
+
reason: "branch preflight failed: working tree is dirty",
|
|
1061
|
+
recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
// Kernel projections accept staged in-flight work inside the active child's
|
|
1065
|
+
// authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
|
|
1066
|
+
// `settled` belongs here: Kernel settlement happens before the batch commits the
|
|
1067
|
+
// child, and settlement clears the live state record, so a crash in that window
|
|
1068
|
+
// resumes into a settled child whose staged work is legitimate.
|
|
1069
|
+
const inFlightChild = existingBatch.children.find(
|
|
1070
|
+
(c: { state: string }) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
|
|
1071
|
+
);
|
|
1072
|
+
let authorizedScope: string[] = [];
|
|
1073
|
+
if (inFlightChild) {
|
|
1074
|
+
// Derive the scope from the Kernel TaskRecord intent snapshot first: a
|
|
1075
|
+
// frozen/archived sidecar must not shrink the authorized scope to empty.
|
|
1076
|
+
try {
|
|
1077
|
+
// Lock-free read: readTaskRecord would run pending-transaction recovery
|
|
1078
|
+
// before the literal user approved anything, violating zero-write on
|
|
1079
|
+
// decline/cancel/preflight rejection. Mutating Kernel entrypoints take
|
|
1080
|
+
// their own lock when the batch actually starts.
|
|
1081
|
+
const recordRead = readTaskRecordRaw(this.cwd, inFlightChild.task_id);
|
|
1082
|
+
authorizedScope = (recordRead.record as any)?.intent_snapshot?.scope_hint ?? [];
|
|
1083
|
+
} catch {
|
|
1084
|
+
// fallback below
|
|
1085
|
+
}
|
|
1086
|
+
if (authorizedScope.length === 0 && inFlightChild.state === "settled") {
|
|
1087
|
+
// A settled child has no live state record; its authority is the immutable
|
|
1088
|
+
// terminal audit pair. Read-only, so a refusal still writes nothing.
|
|
1089
|
+
try {
|
|
1090
|
+
const settled = readAuditTaskPair(this.cwd, inFlightChild.task_id)?.record as
|
|
1091
|
+
| { intent_snapshot?: { scope_hint?: string[] } }
|
|
1092
|
+
| undefined;
|
|
1093
|
+
authorizedScope = settled?.intent_snapshot?.scope_hint ?? [];
|
|
1094
|
+
} catch {
|
|
1095
|
+
// fallback below
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (authorizedScope.length === 0) {
|
|
1099
|
+
// Resolve through the TaskRecord's intent_ref: after freeze the
|
|
1100
|
+
// sidecar lives in docs/plans/archive/, so the default pre-freeze
|
|
1101
|
+
// path would either throw or read a stale file.
|
|
1102
|
+
try {
|
|
1103
|
+
const read = readTaskIntentForRecord(this.cwd, inFlightChild.task_id);
|
|
1104
|
+
authorizedScope = read.intent.scope_hint ?? [];
|
|
1105
|
+
} catch {
|
|
1106
|
+
// fallback below
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (authorizedScope.length === 0) {
|
|
1110
|
+
return {
|
|
1111
|
+
state: "rejected",
|
|
1112
|
+
reason: "branch preflight failed: cannot derive the in-flight child's authorized scope",
|
|
1113
|
+
recovery_action: "resolve the child's intent record, then retry in the current Host",
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
|
|
1118
|
+
if (dirtyBytes) {
|
|
1119
|
+
return {
|
|
1120
|
+
state: "rejected",
|
|
1121
|
+
reason: "branch preflight failed: working tree has unstaged or untracked changes",
|
|
1122
|
+
recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const outsideScope = statusEntries.some(({ path }) => {
|
|
1126
|
+
if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) return false;
|
|
1127
|
+
// Scope entries may be exact files, directories, or globs; delegate to the
|
|
1128
|
+
// Kernel's own boundary matcher instead of exact includes.
|
|
1129
|
+
return !authorizedScope.some((scopePath) => pathMatchesScope(path, scopePath));
|
|
1130
|
+
});
|
|
1131
|
+
if (outsideScope) {
|
|
1132
|
+
return {
|
|
1133
|
+
state: "rejected",
|
|
1134
|
+
reason: "branch preflight failed: working tree has changes outside the authorized child scope",
|
|
1135
|
+
recovery_action: "commit or unstage changes outside the active task scope, then retry in the current Host",
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// 4. Project or reconstruct batch plan (pre-confirmation)
|
|
1141
|
+
const now = new Date().toISOString();
|
|
1142
|
+
let recoveryChildren: BatchPlanChild[] = [];
|
|
1143
|
+
let planDigest: string;
|
|
1144
|
+
let confirmChildrenDetails: Array<{ task_id: string; slice_id: string; risk: string }> = [];
|
|
1145
|
+
let confirmExcludedDetails: Array<{ task_id: string; slice_id: string; reason: string }> = [];
|
|
1146
|
+
const recoveryRiskByTask = new Map<string, string>();
|
|
1147
|
+
let budget = existingBatch ? existingBatch.budget : { max_children: 10, deadline_at: new Date(Date.now() + 8 * 3600 * 1000).toISOString(), qa_failure_limit: 2 };
|
|
1148
|
+
|
|
1149
|
+
if (isResuming) {
|
|
1150
|
+
try {
|
|
1151
|
+
recoveryChildren = await Promise.all(
|
|
1152
|
+
existingBatch.children.map(async (c: any) => {
|
|
1153
|
+
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
1154
|
+
let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
|
|
1155
|
+
try {
|
|
1156
|
+
const taskRecordRead = readTaskRecordRaw(this.cwd, c.task_id);
|
|
1157
|
+
if (taskRecordRead.record) {
|
|
1158
|
+
read = {
|
|
1159
|
+
intent: taskRecordRead.record.intent_snapshot,
|
|
1160
|
+
content_hash: taskRecordRead.record.intent_ref.content_hash,
|
|
1161
|
+
};
|
|
1162
|
+
} else {
|
|
1163
|
+
read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
|
|
1164
|
+
}
|
|
1165
|
+
} catch {
|
|
1166
|
+
const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
|
|
1167
|
+
try {
|
|
1168
|
+
read = (await readTaskIntent(this.cwd, c.task_id, archivePath)) as any;
|
|
1169
|
+
} catch {
|
|
1170
|
+
read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// Keep the risk captured by the authoritative read; a stale reconstructed
|
|
1174
|
+
// path must never fabricate a risk in the confirmation details.
|
|
1175
|
+
recoveryRiskByTask.set(c.task_id, read.intent?.risk ?? "material");
|
|
1176
|
+
const isDone = c.state === "committed" || c.state === "settled";
|
|
1177
|
+
return {
|
|
1178
|
+
task_id: c.task_id,
|
|
1179
|
+
slice_id: c.slice_id,
|
|
1180
|
+
status: isDone ? ("already_settled" as const) : ("enrollable" as const),
|
|
1181
|
+
blocked_by: [...c.blocked_by],
|
|
1182
|
+
reason: c.reason ?? null,
|
|
1183
|
+
intent_path: intentPath,
|
|
1184
|
+
intent_revision: read.intent.revision,
|
|
1185
|
+
intent_content_hash: read.content_hash,
|
|
1186
|
+
};
|
|
1187
|
+
}),
|
|
1188
|
+
);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
return {
|
|
1191
|
+
state: "rejected",
|
|
1192
|
+
reason: `failed to project batch plan: ${err instanceof Error ? err.message : String(err)}`,
|
|
1193
|
+
recovery_action: "review initiative issues and planning sidecars in the current Host",
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
planDigest = computeBatchPlanDigest(recoveryChildren as any);
|
|
1197
|
+
for (const c of recoveryChildren) {
|
|
1198
|
+
// Risk captured from the authoritative recovery read; never fabricated.
|
|
1199
|
+
confirmChildrenDetails.push({
|
|
1200
|
+
task_id: c.task_id,
|
|
1201
|
+
slice_id: c.slice_id,
|
|
1202
|
+
risk: recoveryRiskByTask.get(c.task_id) ?? "material",
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
let plan: BatchPlan;
|
|
1207
|
+
try {
|
|
1208
|
+
plan = await projectBatchPlan(
|
|
1209
|
+
this.cwd,
|
|
1210
|
+
initiativeSlug,
|
|
1211
|
+
{ confirmation_time: now },
|
|
1212
|
+
this.readInitiative ?? observeGithubInitiative,
|
|
1213
|
+
);
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1216
|
+
if (msg.includes("has no enrollable children")) {
|
|
1217
|
+
return {
|
|
1218
|
+
state: "rejected",
|
|
1219
|
+
reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
|
|
1220
|
+
recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
return {
|
|
1224
|
+
state: "rejected",
|
|
1225
|
+
reason: `failed to project batch plan: ${msg}`,
|
|
1226
|
+
recovery_action: "review initiative issues and planning sidecars in the current Host",
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (plan.enrollable.length === 0) {
|
|
1231
|
+
return {
|
|
1232
|
+
state: "rejected",
|
|
1233
|
+
reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
|
|
1234
|
+
recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
budget = plan.budget;
|
|
1239
|
+
const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
|
|
1240
|
+
recoveryChildren = plan.children
|
|
1241
|
+
.filter((c) => c.status === "enrollable")
|
|
1242
|
+
.map((c) => {
|
|
1243
|
+
const digestChild = enrollableChildById.get(c.task_id);
|
|
1244
|
+
return {
|
|
1245
|
+
...c,
|
|
1246
|
+
blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
|
|
1247
|
+
};
|
|
1248
|
+
});
|
|
1249
|
+
planDigest = computeBatchPlanDigest(plan.enrollable);
|
|
1250
|
+
|
|
1251
|
+
for (const c of plan.children.filter((item) => item.status === "enrollable")) {
|
|
1252
|
+
let childRisk = "material";
|
|
1253
|
+
try {
|
|
1254
|
+
const intentRead = await readTaskIntent(this.cwd, c.task_id, c.intent_path ?? undefined);
|
|
1255
|
+
childRisk = intentRead.intent.risk;
|
|
1256
|
+
} catch {
|
|
1257
|
+
// fallback
|
|
1258
|
+
}
|
|
1259
|
+
confirmChildrenDetails.push({
|
|
1260
|
+
task_id: c.task_id,
|
|
1261
|
+
slice_id: c.slice_id,
|
|
1262
|
+
risk: childRisk,
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
confirmExcludedDetails = plan.children
|
|
1266
|
+
.filter((c) => c.status !== "enrollable")
|
|
1267
|
+
.map((c) => ({
|
|
1268
|
+
task_id: c.task_id,
|
|
1269
|
+
slice_id: c.slice_id,
|
|
1270
|
+
reason: c.status === "needs_human" && c.reason === "critical" ? "critical" : (c.reason ?? c.status),
|
|
1271
|
+
}));
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// 5. Native confirmation elicitation
|
|
1275
|
+
const isExistingExpired = isResuming && Date.parse(existingBatch.authorization_expires_at) <= Date.now();
|
|
1276
|
+
const expiresAt = isResuming && !isExistingExpired && existingBatch.batch_state === "running"
|
|
1277
|
+
? existingBatch.authorization_expires_at
|
|
1278
|
+
: new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
1279
|
+
const batchDetails = {
|
|
1280
|
+
initiative_slug: initiativeSlug,
|
|
1281
|
+
batch_branch: batchBranch,
|
|
1282
|
+
children: confirmChildrenDetails,
|
|
1283
|
+
excluded: confirmExcludedDetails,
|
|
1284
|
+
budget,
|
|
1285
|
+
expires_at: expiresAt,
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
// review-4: bounded elicitation timeout preventing indefinite hang on missing/replayed evidence
|
|
1289
|
+
const configuredTimeout = Number(this.env.IMMUNE_BRAIN_BATCH_TIMEOUT_MS);
|
|
1290
|
+
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 60_000;
|
|
1291
|
+
const timeoutController = new AbortController();
|
|
1292
|
+
const timeoutTimer = setTimeout(() => {
|
|
1293
|
+
timeoutController.abort(new NativeAuthorityError("user_cancelled", "native confirmation timed out"));
|
|
1294
|
+
}, timeoutMs);
|
|
1295
|
+
const elicitationSignal = meta.signal
|
|
1296
|
+
? AbortSignal.any([meta.signal, timeoutController.signal])
|
|
1297
|
+
: timeoutController.signal;
|
|
1298
|
+
|
|
1299
|
+
let confirmationResult: { decision: "accept" | "decline" | "cancel"; requestId: string };
|
|
1300
|
+
try {
|
|
1301
|
+
confirmationResult = await this.requestConfirmation({
|
|
1302
|
+
operation: "start_unattended_batch",
|
|
1303
|
+
initiativeSlug,
|
|
1304
|
+
toolCallId: meta.toolCallId,
|
|
1305
|
+
planDigest,
|
|
1306
|
+
batchDetails,
|
|
1307
|
+
signal: elicitationSignal,
|
|
1308
|
+
});
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
if (timeoutController.signal.aborted && !meta.signal?.aborted) {
|
|
1311
|
+
return {
|
|
1312
|
+
state: "rejected",
|
|
1313
|
+
reason: "native confirmation timed out waiting for user interaction",
|
|
1314
|
+
recovery_action: "retry through a fresh native gate in the current Host",
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
if (meta.signal?.aborted) {
|
|
1318
|
+
return {
|
|
1319
|
+
state: "cancelled",
|
|
1320
|
+
reason: "user cancelled before batch execution",
|
|
1321
|
+
recovery_action: "wait for a fresh literal-user request",
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
if (err instanceof NativeAuthorityError) {
|
|
1325
|
+
if (err.reasonCode === "unsupported_host") throw err;
|
|
1326
|
+
if (err.reasonCode === "user_cancelled") {
|
|
1327
|
+
return { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction };
|
|
1328
|
+
}
|
|
1329
|
+
if (err.reasonCode === "user_denied") {
|
|
1330
|
+
return { state: "rejected", reason: err.message, recovery_action: err.recoveryAction };
|
|
1331
|
+
}
|
|
1332
|
+
return { state: "rejected", reason: err.message, recovery_action: err.recoveryAction };
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
state: "rejected",
|
|
1336
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1337
|
+
recovery_action: "retry through a fresh native gate in the current Host",
|
|
1338
|
+
};
|
|
1339
|
+
} finally {
|
|
1340
|
+
clearTimeout(timeoutTimer);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (meta.signal?.aborted) {
|
|
1344
|
+
return {
|
|
1345
|
+
state: "cancelled",
|
|
1346
|
+
reason: "user cancelled before batch execution",
|
|
1347
|
+
recovery_action: "wait for a fresh literal-user request",
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (confirmationResult.decision === "decline") {
|
|
1352
|
+
return {
|
|
1353
|
+
state: "rejected",
|
|
1354
|
+
reason: "native interaction declined",
|
|
1355
|
+
recovery_action: "wait for a fresh literal-user request",
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
if (confirmationResult.decision === "cancel") {
|
|
1359
|
+
return {
|
|
1360
|
+
state: "cancelled",
|
|
1361
|
+
reason: "native interaction cancelled",
|
|
1362
|
+
recovery_action: "wait for a fresh literal-user request",
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
if (confirmationResult.decision !== "accept") {
|
|
1366
|
+
return {
|
|
1367
|
+
state: "rejected",
|
|
1368
|
+
reason: "native interaction returned no decision",
|
|
1369
|
+
recovery_action: "retry through a fresh native gate in the current Host",
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// 6. Post-confirmation Revalidation (Drift check, Workspace claim, Git HEAD)
|
|
1374
|
+
const postWorkspaceState = readWorkspaceStateRaw(this.cwd);
|
|
1375
|
+
const postClaim = readBackendClaim(this.cwd);
|
|
1376
|
+
const postActiveTaskId = postWorkspaceState.state.current_working || (postClaim?.lifecycle_status === "active" ? postClaim.task_id : null);
|
|
1377
|
+
const isPostOwnClaim =
|
|
1378
|
+
isResuming &&
|
|
1379
|
+
postActiveTaskId !== null &&
|
|
1380
|
+
syncIsOwnBatchClaim(this.cwd, existingBatch, postActiveTaskId, batchBranch);
|
|
1381
|
+
if (postActiveTaskId && !isPostOwnClaim) {
|
|
1382
|
+
return {
|
|
1383
|
+
state: "blocked",
|
|
1384
|
+
reason: `an active workspace claim appeared during confirmation for task: ${postActiveTaskId}`,
|
|
1385
|
+
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (isResuming) {
|
|
1390
|
+
let recheckedDigest: string;
|
|
1391
|
+
try {
|
|
1392
|
+
const recheckedChildren = await Promise.all(
|
|
1393
|
+
existingBatch.children.map(async (c: any) => {
|
|
1394
|
+
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
1395
|
+
let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
|
|
1396
|
+
try {
|
|
1397
|
+
const taskRecordRead = readTaskRecordRaw(this.cwd, c.task_id);
|
|
1398
|
+
if (taskRecordRead.record) {
|
|
1399
|
+
read = {
|
|
1400
|
+
intent: taskRecordRead.record.intent_snapshot,
|
|
1401
|
+
content_hash: taskRecordRead.record.intent_ref.content_hash,
|
|
1402
|
+
};
|
|
1403
|
+
} else {
|
|
1404
|
+
read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
|
|
1405
|
+
}
|
|
1406
|
+
} catch {
|
|
1407
|
+
const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
|
|
1408
|
+
try {
|
|
1409
|
+
read = (await readTaskIntent(this.cwd, c.task_id, archivePath)) as any;
|
|
1410
|
+
} catch {
|
|
1411
|
+
read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
const isDone = c.state === "committed" || c.state === "settled";
|
|
1415
|
+
return {
|
|
1416
|
+
task_id: c.task_id,
|
|
1417
|
+
slice_id: c.slice_id,
|
|
1418
|
+
status: isDone ? ("already_settled" as const) : ("enrollable" as const),
|
|
1419
|
+
blocked_by: [...c.blocked_by],
|
|
1420
|
+
reason: c.reason ?? null,
|
|
1421
|
+
intent_path: intentPath,
|
|
1422
|
+
intent_revision: read.intent.revision,
|
|
1423
|
+
intent_content_hash: read.content_hash,
|
|
1424
|
+
};
|
|
1425
|
+
}),
|
|
1426
|
+
);
|
|
1427
|
+
recheckedDigest = computeBatchPlanDigest(recheckedChildren);
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
return {
|
|
1430
|
+
state: "rejected",
|
|
1431
|
+
reason: "batch plan became unreadable after native confirmation",
|
|
1432
|
+
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
if (recheckedDigest !== planDigest) {
|
|
1436
|
+
return {
|
|
1437
|
+
state: "rejected",
|
|
1438
|
+
reason: "batch plan changed after native confirmation",
|
|
1439
|
+
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
let revalidatedPlan: BatchPlan;
|
|
1444
|
+
try {
|
|
1445
|
+
revalidatedPlan = await projectBatchPlan(
|
|
1446
|
+
this.cwd,
|
|
1447
|
+
initiativeSlug,
|
|
1448
|
+
{ confirmation_time: now },
|
|
1449
|
+
this.readInitiative ?? observeGithubInitiative,
|
|
1450
|
+
);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
return {
|
|
1453
|
+
state: "rejected",
|
|
1454
|
+
reason: "batch plan became unreadable after native confirmation",
|
|
1455
|
+
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const revalidatedDigest = computeBatchPlanDigest(revalidatedPlan.enrollable);
|
|
1460
|
+
if (revalidatedDigest !== planDigest) {
|
|
1461
|
+
return {
|
|
1462
|
+
state: "rejected",
|
|
1463
|
+
reason: "batch plan changed after native confirmation",
|
|
1464
|
+
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// review-batch-head-revalidation-window: re-verify Git HEAD after asynchronous revalidation
|
|
1470
|
+
let postHead: string;
|
|
1471
|
+
try {
|
|
1472
|
+
postHead = readGitHead(this.cwd);
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
return {
|
|
1475
|
+
state: "rejected",
|
|
1476
|
+
reason: "Git repository became unreadable after native confirmation",
|
|
1477
|
+
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
if (postHead !== baseHead) {
|
|
1481
|
+
return {
|
|
1482
|
+
state: "rejected",
|
|
1483
|
+
reason: "Git HEAD moved after native confirmation",
|
|
1484
|
+
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// review-batch-active-claim-race: re-check workspace and backend claim after asynchronous revalidation
|
|
1489
|
+
const finalWorkspaceState = readWorkspaceStateRaw(this.cwd);
|
|
1490
|
+
const finalClaim = readBackendClaim(this.cwd);
|
|
1491
|
+
const finalActiveTaskId = finalWorkspaceState.state.current_working || (finalClaim?.lifecycle_status === "active" ? finalClaim.task_id : null);
|
|
1492
|
+
// Re-verify the full claim identity now: a claim swapped for the same child
|
|
1493
|
+
// during confirmation must stay blocked even when plan and HEAD are stable.
|
|
1494
|
+
const finalIsOwnClaim =
|
|
1495
|
+
isResuming &&
|
|
1496
|
+
finalActiveTaskId !== null &&
|
|
1497
|
+
syncIsOwnBatchClaim(this.cwd, existingBatch, finalActiveTaskId, batchBranch);
|
|
1498
|
+
if (finalActiveTaskId && !finalIsOwnClaim) {
|
|
1499
|
+
return {
|
|
1500
|
+
state: "blocked",
|
|
1501
|
+
reason: `an active workspace claim appeared during confirmation for task: ${finalActiveTaskId}`,
|
|
1502
|
+
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// review-1: verify cancellation signal right before authority issuance and startBatch
|
|
1507
|
+
if (meta.signal?.aborted) {
|
|
1508
|
+
return {
|
|
1509
|
+
state: "cancelled",
|
|
1510
|
+
reason: "user cancelled before batch execution",
|
|
1511
|
+
recovery_action: "wait for a fresh literal-user request",
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// 7. Issue Batch Authorization through Kernel registry and startBatch
|
|
1516
|
+
const batchId = existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${Date.now()}`;
|
|
1517
|
+
const confirmation = confirmationRef({
|
|
1518
|
+
connectionId: meta.sessionId,
|
|
1519
|
+
toolCallId: meta.toolCallId,
|
|
1520
|
+
requestId: confirmationResult.requestId,
|
|
1521
|
+
operation: "start_unattended_batch",
|
|
1522
|
+
initiativeSlug,
|
|
1523
|
+
planDigest,
|
|
1524
|
+
});
|
|
1525
|
+
const binding: BatchAuthorizationBinding = {
|
|
1526
|
+
batch_id: batchId,
|
|
1527
|
+
initiative_slug: initiativeSlug,
|
|
1528
|
+
plan_digest: planDigest,
|
|
1529
|
+
branch: batchBranch,
|
|
1530
|
+
base_head: isResuming ? existingBatch.base_head : baseHead,
|
|
1531
|
+
budget,
|
|
1532
|
+
actor_id: "user",
|
|
1533
|
+
confirmation_ref: confirmation,
|
|
1534
|
+
expires_at: expiresAt,
|
|
1535
|
+
nonce: enrollmentNonce(),
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
1539
|
+
|
|
1540
|
+
const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
|
|
1541
|
+
const kernelPort: BatchRunnerKernelPort = {
|
|
1542
|
+
...basePort,
|
|
1543
|
+
ownsTaskClaim: (taskId) => {
|
|
1544
|
+
if (isResuming && taskId === activeTaskId) {
|
|
1545
|
+
// Re-verify the CURRENT claim identity synchronously.
|
|
1546
|
+
return syncIsOwnBatchClaim(this.cwd, existingBatch, taskId, batchBranch);
|
|
1547
|
+
}
|
|
1548
|
+
return basePort.ownsTaskClaim(taskId);
|
|
1549
|
+
},
|
|
1550
|
+
};
|
|
1551
|
+
const report = await startBatch({
|
|
1552
|
+
root: this.cwd,
|
|
1553
|
+
batch_id: batchId,
|
|
1554
|
+
initiative_slug: initiativeSlug,
|
|
1555
|
+
registry: this.batchRegistry,
|
|
1556
|
+
capability,
|
|
1557
|
+
children: recoveryChildren,
|
|
1558
|
+
plan_digest: planDigest,
|
|
1559
|
+
base_head: isResuming ? existingBatch.base_head : baseHead,
|
|
1560
|
+
confirmation_time: now,
|
|
1561
|
+
authorization_expires_at: expiresAt,
|
|
1562
|
+
budget,
|
|
1563
|
+
now,
|
|
1564
|
+
kernel: kernelPort,
|
|
1565
|
+
git: this.batchGit,
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// review-3 & review-batch-preflight-recovery-is-diagnostic: map rejected batch state to rejected result with same-Host recovery action
|
|
1569
|
+
if (report.batch_state === "rejected") {
|
|
1570
|
+
return {
|
|
1571
|
+
state: "rejected",
|
|
1572
|
+
reason: report.reason ?? "batch run rejected",
|
|
1573
|
+
recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
return {
|
|
1578
|
+
state: "started",
|
|
1579
|
+
batch_id: batchId,
|
|
1580
|
+
report,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
private createBatchKernelPort(
|
|
1585
|
+
registry: BatchAuthorityRegistry,
|
|
1586
|
+
capability: object,
|
|
1587
|
+
binding: BatchAuthorizationBinding,
|
|
1588
|
+
): BatchRunnerKernelPort {
|
|
1589
|
+
// review-batch-partial-port-fabricates-enrollment: construct real production port first, never fabricate stubs
|
|
1590
|
+
const realPort: BatchRunnerKernelPort = {
|
|
1591
|
+
enrollTask: async ({ root, task_id, batch }) => {
|
|
1592
|
+
const now = new Date().toISOString();
|
|
1593
|
+
const derived = deriveChildEnrollment(root, batch.registry, {
|
|
1594
|
+
capability: batch.capability,
|
|
1595
|
+
binding,
|
|
1596
|
+
task_id,
|
|
1597
|
+
expected_head: batch.binding.expected_head,
|
|
1598
|
+
now,
|
|
1599
|
+
});
|
|
1600
|
+
const enrollmentCapability = this.enrollmentRegistry.issue(derived.binding);
|
|
1601
|
+
const input = {
|
|
1602
|
+
task_id,
|
|
1603
|
+
intent_path: derived.binding.intent_path,
|
|
1604
|
+
intent_revision: derived.binding.intent_revision,
|
|
1605
|
+
preparation_digest: derived.binding.preparation_digest,
|
|
1606
|
+
capability: enrollmentCapability,
|
|
1607
|
+
capability_binding: derived.binding,
|
|
1608
|
+
batch: {
|
|
1609
|
+
registry: batch.registry,
|
|
1610
|
+
capability: batch.capability,
|
|
1611
|
+
binding,
|
|
1612
|
+
expected_head: batch.binding.expected_head,
|
|
1613
|
+
},
|
|
1614
|
+
now,
|
|
1615
|
+
};
|
|
1616
|
+
const rehearsal = runEnrollmentRehearsal(root, input, enrollmentCapability, this.enrollmentRegistry);
|
|
1617
|
+
if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready") {
|
|
1618
|
+
throw new Error(`Kernel enrollment rehearsal failed: ${rehearsal.evidence.blockers.join("; ")}`);
|
|
1619
|
+
}
|
|
1620
|
+
const enrolled = enrollCanaryTask(root, input, this.enrollmentRegistry);
|
|
1621
|
+
const recordRaw = readTaskRecordRaw(root, task_id);
|
|
1622
|
+
return { record_revision: recordRaw.revision };
|
|
1623
|
+
},
|
|
1624
|
+
advanceTask: async (root, taskId) => {
|
|
1625
|
+
const result = await this.coordinator.advance(taskId, { cwd: root });
|
|
1626
|
+
if (result.state === "completed") return { state: "completed" };
|
|
1627
|
+
if (result.state === "stopped") return { state: "stopped" };
|
|
1628
|
+
if (result.state === "rework") return { state: "rework", operation: result.operation, summary: result.summary };
|
|
1629
|
+
if (result.state === "review_ready") return { state: "review_ready", operation_id: result.operation_id };
|
|
1630
|
+
if (result.state === "blocked") return { state: "blocked", reason: result.reason };
|
|
1631
|
+
return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed" };
|
|
1632
|
+
},
|
|
1633
|
+
projectTask: async (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
|
|
1634
|
+
ownsTaskClaim: (taskId) => {
|
|
1635
|
+
return registry.isChildConsumed(capability, taskId);
|
|
1636
|
+
},
|
|
1637
|
+
validateBatchAuthorization: (input) => {
|
|
1638
|
+
return input.registry.inspect(input.capability, input.binding as never);
|
|
1639
|
+
},
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
if (!this.batchKernel) return realPort;
|
|
1643
|
+
return {
|
|
1644
|
+
...realPort,
|
|
1645
|
+
...this.batchKernel,
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
826
1648
|
}
|
|
827
1649
|
|
|
828
1650
|
export interface ToolMeta {
|
|
829
1651
|
sessionId: string;
|
|
830
1652
|
toolCallId: string;
|
|
831
1653
|
taskId: string;
|
|
1654
|
+
initiativeSlug?: string;
|
|
832
1655
|
requiresUserInteraction?: boolean;
|
|
833
1656
|
permissionMode?: PermissionMode;
|
|
834
1657
|
interactive?: boolean;
|