immune-brain 3.6.9 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +33 -25
- package/plugins/immune-brain/dist/imm-planner.md +41 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +11 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync,
|
|
3
|
-
import { execFileSync
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
AssuranceCoordinator,
|
|
7
|
+
reviewAdvisoryRecords,
|
|
7
8
|
reviewReworkFindings,
|
|
8
9
|
type AssuranceCoordinatorPorts,
|
|
9
10
|
type AssuranceSubmitReviewResult,
|
|
@@ -12,22 +13,18 @@ import {
|
|
|
12
13
|
type SnapshotDescriptor,
|
|
13
14
|
} from "../assurance/coordinator";
|
|
14
15
|
import {
|
|
15
|
-
assertRunnerCompatible,
|
|
16
|
-
resolveBunRunner,
|
|
17
|
-
type FrozenRunner,
|
|
18
16
|
type VerificationDescriptor,
|
|
19
17
|
} from "../assurance/verification";
|
|
20
18
|
import {
|
|
21
|
-
captureReviewBundle,
|
|
22
19
|
captureReviewManifest,
|
|
23
20
|
writeNativeReviewEvidence,
|
|
24
21
|
type ReviewRevision,
|
|
25
22
|
} from "../assurance/review_evidence";
|
|
26
23
|
import { parseVerificationDescriptor } from "../verification_descriptor";
|
|
27
24
|
import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
28
|
-
import {
|
|
25
|
+
import { type TaskApprovalV2, type TaskFinding, type TaskRecord } from "../kernel/types";
|
|
29
26
|
import { findingsDigestV2 } from "../kernel/reducer";
|
|
30
|
-
import {
|
|
27
|
+
import { readTaskRecord, readTaskRecordRaw, recoverKernelStoreFollowUps } from "../kernel/storage";
|
|
31
28
|
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
32
29
|
import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
|
|
33
30
|
import {
|
|
@@ -44,8 +41,24 @@ import { enrollCanaryTask, runEnrollmentRehearsal } from "../kernel/enrollment";
|
|
|
44
41
|
import { reconcileKernelAuthority, repairKernelAuthority } from "../kernel/storage";
|
|
45
42
|
import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare";
|
|
46
43
|
import { runDeterministicQa } from "../assurance/qa";
|
|
47
|
-
import { taskDiffIdentity, taskRevisionIdentity
|
|
48
|
-
import {
|
|
44
|
+
import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
|
|
45
|
+
import { batchReason } from "../unattended/batch_reasons";
|
|
46
|
+
import { startConfirmationDeadline } from "../unattended/confirmation_deadline";
|
|
47
|
+
import { deriveAuthorizationOperation } from "../authorization_operation";
|
|
48
|
+
import { LITERAL_USER_ACTOR_ID, canonicalActorId } from "../kernel/actor_identity";
|
|
49
|
+
import {
|
|
50
|
+
captureStagedIntent,
|
|
51
|
+
restoreStagedIntent as restoreStagedIntentShared,
|
|
52
|
+
} from "../staged_intent";
|
|
53
|
+
import { projectTerminalTrackerState, TRACKER_PROJECTION_FAILURE } from "../assurance/coordinator";
|
|
54
|
+
import { runGithubTrackerOperation } from "../github_issue_tracker";
|
|
55
|
+
import { readTaskTombstone } from "../kernel/backend_claim";
|
|
56
|
+
import {
|
|
57
|
+
authorizeBatch,
|
|
58
|
+
projectBatchPreflight,
|
|
59
|
+
readActiveClaimTaskId,
|
|
60
|
+
isOwnBatchClaim,
|
|
61
|
+
} from "../unattended/batch_preflight";
|
|
49
62
|
import {
|
|
50
63
|
startBatch,
|
|
51
64
|
type BatchRunnerKernelPort,
|
|
@@ -53,24 +66,17 @@ import {
|
|
|
53
66
|
} from "../unattended/batch_runner";
|
|
54
67
|
import {
|
|
55
68
|
createBatchAuthorityRegistry,
|
|
56
|
-
computeBatchPlanDigest,
|
|
57
69
|
deriveChildEnrollment,
|
|
58
70
|
type BatchAuthorityRegistry,
|
|
59
71
|
type BatchAuthorizationBinding,
|
|
60
72
|
} from "../kernel/batch_authority";
|
|
61
73
|
import {
|
|
62
|
-
runBatchGitPreflight,
|
|
63
74
|
type BatchRunnerGitPort,
|
|
64
75
|
} from "../unattended/batch_git";
|
|
65
76
|
import type {
|
|
66
|
-
BatchPlan,
|
|
67
|
-
BatchPlanChild,
|
|
68
77
|
InitiativeObservationReader,
|
|
69
78
|
} from "../unattended/types";
|
|
70
79
|
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";
|
|
74
80
|
import {
|
|
75
81
|
confirmationRef,
|
|
76
82
|
enrollmentNonce,
|
|
@@ -78,6 +84,7 @@ import {
|
|
|
78
84
|
isPrivilegedOperation,
|
|
79
85
|
NativeAuthorityError,
|
|
80
86
|
type NativeConfirmationPort,
|
|
87
|
+
type NativeDecision,
|
|
81
88
|
type PrivilegedOperation,
|
|
82
89
|
} from "./interaction";
|
|
83
90
|
import { ClaudeReviewHost, FileHookEventLog, type ClaudeHookEvent } from "./review_host";
|
|
@@ -89,9 +96,9 @@ export function diffSnapshotOf(root: string, record: TaskRecord): {
|
|
|
89
96
|
} {
|
|
90
97
|
if (record.contract === "assurance_kernel/task_record/v4") {
|
|
91
98
|
if (!record.git_base_head) throw new Error("TaskRecord v4 is missing git_base_head");
|
|
92
|
-
return taskRevisionIdentity(root, record.intent_snapshot.scope_hint, record.git_base_head);
|
|
99
|
+
return taskRevisionIdentity(root, record.intent_snapshot.scope_hint, record.git_base_head, record.task_id);
|
|
93
100
|
}
|
|
94
|
-
return taskDiffIdentity(root, record.intent_snapshot.scope_hint);
|
|
101
|
+
return taskDiffIdentity(root, record.intent_snapshot.scope_hint, record.task_id);
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
export function diffHashOf(root: string, record: TaskRecord): string {
|
|
@@ -101,12 +108,14 @@ export function diffHashOf(root: string, record: TaskRecord): string {
|
|
|
101
108
|
/**
|
|
102
109
|
* Read the TaskIntent through the TaskRecord's `intent_ref.path`.
|
|
103
110
|
*
|
|
104
|
-
* `freeze_artifacts`
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
111
|
+
* `freeze_artifacts` binds the sidecar in place. Every post-freeze read — QA
|
|
112
|
+
* settlement included — must follow the record instead of guessing a default
|
|
113
|
+
* path. Historical archived sidecars remain readable. The Pi adapter resolves
|
|
114
|
+
* the same way in its own runtime stub; both Hosts must stay in step.
|
|
108
115
|
*/
|
|
109
116
|
function readTaskIntentForRecord(root: string, taskId: string) {
|
|
117
|
+
// Converge committed follow-ups first, then follow the record.
|
|
118
|
+
recoverKernelStoreFollowUps(root, taskId);
|
|
110
119
|
const currentPath = readTaskRecordRaw(root, taskId).record?.intent_ref?.path;
|
|
111
120
|
return readTaskIntent(root, taskId, currentPath);
|
|
112
121
|
}
|
|
@@ -160,6 +169,20 @@ export async function submitClaudeReview(
|
|
|
160
169
|
return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether a Kernel result settled its task: the coordinator's own terminal
|
|
174
|
+
* outcomes, or the lifecycle a privileged mutation committed. Only such a result
|
|
175
|
+
* can have produced the claimless terminal projection the GitHub tracker step
|
|
176
|
+
* needs, and it is exported so the gate's terminal shapes are asserted directly
|
|
177
|
+
* instead of by settling a task against a live tracker.
|
|
178
|
+
*/
|
|
179
|
+
export function settledKernelResult(result: object): boolean {
|
|
180
|
+
const state = (result as { state?: unknown }).state;
|
|
181
|
+
if (state === "completed" || state === "stopped") return true;
|
|
182
|
+
const lifecycle = (result as { record?: { lifecycle?: unknown } }).record?.lifecycle;
|
|
183
|
+
return lifecycle === "done" || lifecycle === "stopped";
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
/** `extra` arrives as `Record<string, unknown>`; only a real string is a reason. */
|
|
164
187
|
function stopReason(value: unknown): string {
|
|
165
188
|
return typeof value === "string" && value.length > 0 ? value : "user stop";
|
|
@@ -239,24 +262,18 @@ async function buildAssuranceSnapshot(
|
|
|
239
262
|
taskId: string,
|
|
240
263
|
role: "qa" | "review",
|
|
241
264
|
projection: AssuranceProjectionResult,
|
|
242
|
-
runner: FrozenRunner,
|
|
243
265
|
) {
|
|
244
266
|
const read = await readTaskRecord(root, taskId);
|
|
245
267
|
const record = read.record;
|
|
246
268
|
if (!record || read.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
|
|
247
269
|
const intent = record.intent_snapshot;
|
|
248
270
|
const descriptors = new Map<string, VerificationDescriptor>();
|
|
249
|
-
for (const item of intent.acceptance) {
|
|
271
|
+
for (const item of role === "qa" ? intent.acceptance : []) {
|
|
250
272
|
const descriptor = parseVerificationDescriptor(item.verification);
|
|
251
|
-
assertRunnerCompatible(descriptor, runner);
|
|
252
273
|
descriptors.set(item.id, descriptor);
|
|
253
274
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const reviewBundle = role === "review" && !isTaskRecordV4(record)
|
|
257
|
-
? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record))
|
|
258
|
-
: null;
|
|
259
|
-
const reviewManifest = role === "review" && isTaskRecordV4(record)
|
|
275
|
+
const reviewBundle = null;
|
|
276
|
+
const reviewManifest = role === "review"
|
|
260
277
|
? captureReviewManifest(root, {
|
|
261
278
|
taskId,
|
|
262
279
|
baseHead: record.git_base_head,
|
|
@@ -272,10 +289,11 @@ async function buildAssuranceSnapshot(
|
|
|
272
289
|
outcomes: qaOutcomes(record),
|
|
273
290
|
})
|
|
274
291
|
: null;
|
|
275
|
-
const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) :
|
|
292
|
+
const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : [];
|
|
276
293
|
const snapshot: SnapshotDescriptor = {
|
|
277
294
|
contract: "assurance_kernel/assurance_snapshot/v2",
|
|
278
295
|
task_id: taskId,
|
|
296
|
+
run_id: projection.projection.run_id,
|
|
279
297
|
role,
|
|
280
298
|
record_revision: projection.projection.record_revision,
|
|
281
299
|
workspace_revision: projection.projection.workspace_revision,
|
|
@@ -290,7 +308,7 @@ async function buildAssuranceSnapshot(
|
|
|
290
308
|
stale_attestation_ids: projection.projection.stale_attestation_ids,
|
|
291
309
|
acceptance: intent.acceptance,
|
|
292
310
|
dirty_files: dirtyFiles,
|
|
293
|
-
review_bundle_digest: reviewManifest?.manifest_digest ??
|
|
311
|
+
review_bundle_digest: reviewManifest?.manifest_digest ?? null,
|
|
294
312
|
root,
|
|
295
313
|
...(reviewManifest
|
|
296
314
|
? {
|
|
@@ -329,6 +347,7 @@ async function mintCapability(
|
|
|
329
347
|
input: {
|
|
330
348
|
authority_kind: "review" | "qa" | "user";
|
|
331
349
|
task_id: string;
|
|
350
|
+
run_id?: string | null;
|
|
332
351
|
action_kind: string;
|
|
333
352
|
expected_record_hash: string;
|
|
334
353
|
intent_revision: number;
|
|
@@ -362,6 +381,7 @@ async function mintCapability(
|
|
|
362
381
|
const binding: CapabilityBindingV2 = {
|
|
363
382
|
authority_kind: input.authority_kind,
|
|
364
383
|
task_id: input.task_id,
|
|
384
|
+
...(input.run_id ? { run_id: input.run_id } : {}),
|
|
365
385
|
action_digest: digestOfAction(action),
|
|
366
386
|
expected_record_hash: input.expected_record_hash,
|
|
367
387
|
intent_revision: input.intent_revision,
|
|
@@ -400,6 +420,16 @@ export interface ClaudeRuntimeOptions {
|
|
|
400
420
|
readInitiative?: InitiativeObservationReader;
|
|
401
421
|
}
|
|
402
422
|
|
|
423
|
+
/**
|
|
424
|
+
* The Claude Host's own failure envelopes for a batch start; the shared
|
|
425
|
+
* authorization flow passes them through untouched.
|
|
426
|
+
*/
|
|
427
|
+
type ClaudeBatchStartResult =
|
|
428
|
+
| { state: "started"; batch_id: string; report: BatchRunReport }
|
|
429
|
+
| { state: "rejected"; reason: string; recovery_action: string }
|
|
430
|
+
| { state: "cancelled"; reason: string; recovery_action: string }
|
|
431
|
+
| { state: "blocked"; reason: string; recovery_action: string };
|
|
432
|
+
|
|
403
433
|
/**
|
|
404
434
|
* Synchronous re-verification that the currently held claim for `taskId` is this
|
|
405
435
|
* batch's own Kernel enrollment. Positive evidence only (see the Pi adapter):
|
|
@@ -409,87 +439,6 @@ export interface ClaudeRuntimeOptions {
|
|
|
409
439
|
* needs_human re-authorization can never turn this batch's own claim foreign.
|
|
410
440
|
* Called fresh pre/post confirmation and from ownsTaskClaim.
|
|
411
441
|
*/
|
|
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;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
442
|
export class ClaudeRuntime {
|
|
494
443
|
readonly host: ClaudeReviewHost;
|
|
495
444
|
readonly coordinator: AssuranceCoordinator;
|
|
@@ -555,10 +504,9 @@ export class ClaudeRuntime {
|
|
|
555
504
|
projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
|
|
556
505
|
readTaskRecord: async (root, taskId) => readTaskRecord(root, taskId),
|
|
557
506
|
readTaskIntent: async (root, taskId) => readTaskIntentForRecord(root, taskId),
|
|
558
|
-
|
|
559
|
-
buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
|
|
507
|
+
buildAssurance: (root, taskId, role, projection) => buildAssuranceSnapshot(root, taskId, role, projection),
|
|
560
508
|
ensureReviewRevision: (root, taskId, projection) => ensureClaudeReviewRevision(root, taskId, projection),
|
|
561
|
-
runQa: (snapshot, descriptors,
|
|
509
|
+
runQa: (snapshot, descriptors, options) => runDeterministicQa(snapshot, descriptors, options),
|
|
562
510
|
writeReviewEvidence: (input) => writeNativeReviewEvidence(input.evidence),
|
|
563
511
|
applyVerdict: (ctx, input) => this.applyVerdict(ctx, input),
|
|
564
512
|
applyOrdinaryOperation: (ctx, input) => this.executeOrdinary(ctx, input),
|
|
@@ -583,7 +531,10 @@ export class ClaudeRuntime {
|
|
|
583
531
|
const interactive = meta.interactive ?? this.interactive;
|
|
584
532
|
if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
|
|
585
533
|
if (!isPrivilegedOperation(operation)) throw new Error(`unsupported native operation ${operation}`);
|
|
586
|
-
|
|
534
|
+
// The text is table-owned; only the form (a thrown native error rather than
|
|
535
|
+
// a returned envelope) is this Host's.
|
|
536
|
+
if (!this.requestConfirmation)
|
|
537
|
+
throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
|
|
587
538
|
const result = await this.requestConfirmation({ operation, taskId: meta.taskId, toolCallId: meta.toolCallId, signal: meta.signal, ...binding });
|
|
588
539
|
throwIfCancelled(meta.signal);
|
|
589
540
|
const gate = evaluateNativeGate({ operation, interactive, decision: result.decision });
|
|
@@ -625,7 +576,7 @@ export class ClaudeRuntime {
|
|
|
625
576
|
intent_revision: preparation.intent.revision,
|
|
626
577
|
intent_content_hash: preparation.intent.content_hash,
|
|
627
578
|
preparation_digest: preparation.digest,
|
|
628
|
-
actor_id:
|
|
579
|
+
actor_id: LITERAL_USER_ACTOR_ID,
|
|
629
580
|
confirmation_ref: gate.confirmation_ref,
|
|
630
581
|
expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
|
631
582
|
nonce,
|
|
@@ -648,11 +599,51 @@ export class ClaudeRuntime {
|
|
|
648
599
|
}
|
|
649
600
|
|
|
650
601
|
async advance(taskId: string, signal?: AbortSignal) {
|
|
651
|
-
return this.
|
|
602
|
+
return this.withTerminalTracker(
|
|
603
|
+
taskId,
|
|
604
|
+
await this.coordinator.advance(taskId, { cwd: this.cwd }, signal),
|
|
605
|
+
);
|
|
652
606
|
}
|
|
653
607
|
|
|
654
608
|
async submitReview(taskId: string, verdictInput: unknown) {
|
|
655
|
-
return
|
|
609
|
+
return this.withTerminalTracker(
|
|
610
|
+
taskId,
|
|
611
|
+
await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Post-settlement GitHub tracker projection: the same shared step the Pi Host
|
|
617
|
+
* runs, so an opted-in terminal projection no longer depends on which Host
|
|
618
|
+
* settled the task. Only a call that settled its task pays for it: the step
|
|
619
|
+
* cannot project anything from a result that reports a live obligation, so
|
|
620
|
+
* every other invocation returns untouched without reading the Kernel.
|
|
621
|
+
*
|
|
622
|
+
* Transport, never authority: the shared projection derives nothing unless the
|
|
623
|
+
* Kernel already shows a fresh claimless done/stopped task with its exact
|
|
624
|
+
* terminal tombstone, a tracker failure is reported as `tracker` alongside the
|
|
625
|
+
* authoritative result rather than as evidence or a blocker, and it never
|
|
626
|
+
* repeats the settling mutation.
|
|
627
|
+
*/
|
|
628
|
+
private async withTerminalTracker<T>(taskId: string, result: T): Promise<T> {
|
|
629
|
+
if (result === null || typeof result !== "object") return result;
|
|
630
|
+
if (!settledKernelResult(result)) return result;
|
|
631
|
+
try {
|
|
632
|
+
const projection = await this.status(taskId);
|
|
633
|
+
const tracker = await projectTerminalTrackerState({
|
|
634
|
+
root: this.cwd,
|
|
635
|
+
task_id: taskId,
|
|
636
|
+
projection,
|
|
637
|
+
tombstone: readTaskTombstone(this.cwd, taskId),
|
|
638
|
+
markTerminal: (root, input) => runGithubTrackerOperation(root, { op: "mark-terminal", ...input }),
|
|
639
|
+
});
|
|
640
|
+
return tracker ? ({ ...(result as object), tracker } as T) : result;
|
|
641
|
+
} catch {
|
|
642
|
+
// The underlying Kernel mutation already committed, so a failed observation
|
|
643
|
+
// is reported beside the authoritative result instead of rethrown into a
|
|
644
|
+
// path a caller could read as "the mutation did not happen" and retry.
|
|
645
|
+
return { ...(result as object), tracker: TRACKER_PROJECTION_FAILURE } as T;
|
|
646
|
+
}
|
|
656
647
|
}
|
|
657
648
|
|
|
658
649
|
/**
|
|
@@ -688,6 +679,12 @@ export class ClaudeRuntime {
|
|
|
688
679
|
async authorize(taskId: string, operation: string, meta: ToolMeta, extra: Record<string, unknown> = {}) {
|
|
689
680
|
if (operation === "repair_authority_state") {
|
|
690
681
|
const authority = reconcileKernelAuthority(this.cwd, taskId);
|
|
682
|
+
// The workspace owner is derived from the single active run, so a claim
|
|
683
|
+
// that contradicts the run index cannot exist: a settled or unowned
|
|
684
|
+
// authority has nothing to repair, and any leftover retired claim file
|
|
685
|
+
// is inert. A live owner is a conflict the caller must resolve.
|
|
686
|
+
if (authority.state === "terminal_owner" || authority.state === "unowned")
|
|
687
|
+
return repairKernelAuthority(this.cwd, taskId, authority.revision);
|
|
691
688
|
if (authority.state !== "repairable_stale_claim" || authority.owner_task_id !== taskId) {
|
|
692
689
|
throw new Error(authority.diagnostic ?? "authority repair requires a repairable stale claim");
|
|
693
690
|
}
|
|
@@ -698,12 +695,14 @@ export class ClaudeRuntime {
|
|
|
698
695
|
let decisionOp: { finding_id: string; resolution: string } | undefined;
|
|
699
696
|
const projection = await this.status(taskId);
|
|
700
697
|
if (projection.error || !projection.claim) throw new Error(projection.error ?? "no active backend claim");
|
|
701
|
-
// Kernel projection is the sole source of authorization readiness
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
698
|
+
// Kernel projection is the sole source of authorization readiness, and the
|
|
699
|
+
// derivation is the shared export both Hosts use, so it cannot drift:
|
|
700
|
+
// request_authorization submits the exact operation the projection derives,
|
|
701
|
+
// including the single bound user-decision resolution.
|
|
705
702
|
if (operation === "request_authorization") {
|
|
706
|
-
|
|
703
|
+
const derived = deriveAuthorizationOperation({ readiness: projection.projection.authorization });
|
|
704
|
+
if ("blocked" in derived) throw new Error(derived.blocked);
|
|
705
|
+
if (derived.operation === "resolve-user-decision") {
|
|
707
706
|
const record = await readTaskRecord(this.cwd, taskId);
|
|
708
707
|
const open = (record.record?.findings ?? []).filter(
|
|
709
708
|
(finding) => finding.kind === "unresolved_user_decision" && finding.status === "open",
|
|
@@ -711,15 +710,13 @@ export class ClaudeRuntime {
|
|
|
711
710
|
if (open.length !== 1) throw new Error(`resolve-user-decision requires exactly one open user decision; found ${open.length}`);
|
|
712
711
|
op = "resolve_user_decision";
|
|
713
712
|
decisionOp = { finding_id: open[0].id, resolution: `resume after literal-user decision: ${open[0].summary}` };
|
|
714
|
-
} else if (readiness.state === "authorize_rework") {
|
|
715
|
-
op = "authorize_rework";
|
|
716
713
|
} else {
|
|
717
|
-
|
|
714
|
+
op = "authorize_rework";
|
|
718
715
|
}
|
|
719
716
|
}
|
|
720
717
|
const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
|
|
721
718
|
const now = new Date().toISOString();
|
|
722
|
-
const actorId =
|
|
719
|
+
const actorId = LITERAL_USER_ACTOR_ID;
|
|
723
720
|
const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
|
|
724
721
|
if (op === "approve_breaking_intent_revision" && !nextIntent) throw new Error("approve_breaking_intent_revision requires next_intent");
|
|
725
722
|
const nextIntentHash = nextIntent ? canonicalIntentHash(nextIntent) : undefined;
|
|
@@ -727,27 +724,12 @@ export class ClaudeRuntime {
|
|
|
727
724
|
? { path: `docs/plans/${nextIntent.task_id}.intent.json`, content_hash: nextIntentHash! }
|
|
728
725
|
: undefined;
|
|
729
726
|
const sidecar = nextIntent ? join(this.cwd, priorIntent.intent_ref.path) : undefined;
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
cwd: this.cwd,
|
|
734
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
735
|
-
})
|
|
736
|
-
: undefined;
|
|
727
|
+
// The snapshot carries the prior bytes; a second read would only be a
|
|
728
|
+
// second source for the same fact.
|
|
729
|
+
const stagedSnapshot = sidecar ? captureStagedIntent(this.cwd, priorIntent.intent_ref.path) : undefined;
|
|
737
730
|
const restoreStagedIntent = (): void => {
|
|
738
|
-
if (!
|
|
739
|
-
|
|
740
|
-
execFileSync("git", ["update-index", "--force-remove", "--", priorIntent.intent_ref.path], {
|
|
741
|
-
cwd: this.cwd,
|
|
742
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
743
|
-
});
|
|
744
|
-
if (priorIndexState.length > 0) {
|
|
745
|
-
execFileSync("git", ["update-index", "-z", "--index-info"], {
|
|
746
|
-
cwd: this.cwd,
|
|
747
|
-
input: priorIndexState,
|
|
748
|
-
stdio: ["pipe", "ignore", "pipe"],
|
|
749
|
-
});
|
|
750
|
-
}
|
|
731
|
+
if (!stagedSnapshot) return;
|
|
732
|
+
restoreStagedIntentShared(this.cwd, stagedSnapshot);
|
|
751
733
|
};
|
|
752
734
|
let preparedDiffHash = projection.projection.diff_hash;
|
|
753
735
|
let gate: { confirmation_ref: string };
|
|
@@ -777,7 +759,7 @@ export class ClaudeRuntime {
|
|
|
777
759
|
bindingDigest: `${preparedDiffHash}:${nextIntentHash ?? ""}`,
|
|
778
760
|
});
|
|
779
761
|
} catch (error) {
|
|
780
|
-
if (
|
|
762
|
+
if (stagedSnapshot) {
|
|
781
763
|
const current = await readTaskRecord(this.cwd, taskId);
|
|
782
764
|
if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) {
|
|
783
765
|
restoreStagedIntent();
|
|
@@ -787,6 +769,7 @@ export class ClaudeRuntime {
|
|
|
787
769
|
}
|
|
788
770
|
const { registry, app } = this.authority();
|
|
789
771
|
const confirmation = gate.confirmation_ref;
|
|
772
|
+
let committed: ReturnType<typeof app.execute>;
|
|
790
773
|
try {
|
|
791
774
|
const capabilityProjection = await this.status(taskId);
|
|
792
775
|
try {
|
|
@@ -802,12 +785,13 @@ export class ClaudeRuntime {
|
|
|
802
785
|
const capability = await mintCapability(registry, {
|
|
803
786
|
authority_kind: "user",
|
|
804
787
|
task_id: taskId,
|
|
788
|
+
run_id: capabilityProjection.projection.run_id,
|
|
805
789
|
action_kind: op,
|
|
806
790
|
expected_record_hash: capabilityProjection.projection.record_revision,
|
|
807
791
|
intent_revision: nextIntent?.revision ?? capabilityProjection.projection.intent_revision,
|
|
808
792
|
intent_content_hash: nextIntentHash ?? capabilityProjection.projection.intent_content_hash,
|
|
809
793
|
diff_hash: operationDiffHash,
|
|
810
|
-
actor_id: actorId,
|
|
794
|
+
actor_id: canonicalActorId(actorId),
|
|
811
795
|
now,
|
|
812
796
|
confirmation_ref: confirmation,
|
|
813
797
|
...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
|
|
@@ -821,7 +805,7 @@ export class ClaudeRuntime {
|
|
|
821
805
|
operation: {
|
|
822
806
|
op,
|
|
823
807
|
capability,
|
|
824
|
-
actor_id: actorId,
|
|
808
|
+
actor_id: canonicalActorId(actorId),
|
|
825
809
|
...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
|
|
826
810
|
...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
|
|
827
811
|
...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
|
|
@@ -830,14 +814,14 @@ export class ClaudeRuntime {
|
|
|
830
814
|
diffProvider: diffSnapshotOf,
|
|
831
815
|
now,
|
|
832
816
|
});
|
|
817
|
+
committed = result;
|
|
833
818
|
if (
|
|
834
819
|
op === "stop" ||
|
|
835
820
|
op === "authorize_rework" ||
|
|
836
821
|
op === "approve_breaking_intent_revision"
|
|
837
822
|
) stagePlanningArtifactTransition(this.cwd, result.record);
|
|
838
|
-
return result;
|
|
839
823
|
} catch (error) {
|
|
840
|
-
if (
|
|
824
|
+
if (stagedSnapshot) {
|
|
841
825
|
const current = await readTaskRecord(this.cwd, taskId);
|
|
842
826
|
if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) {
|
|
843
827
|
restoreStagedIntent();
|
|
@@ -845,6 +829,11 @@ export class ClaudeRuntime {
|
|
|
845
829
|
}
|
|
846
830
|
throw error;
|
|
847
831
|
}
|
|
832
|
+
// A stop settles the task: project the terminal tracker state exactly as the
|
|
833
|
+
// Pi Host does after its own settlement. It runs once the mutation's own
|
|
834
|
+
// try/catch has closed, so observation can never roll the staged intent back
|
|
835
|
+
// or turn a committed mutation into an exception.
|
|
836
|
+
return this.withTerminalTracker(taskId, committed);
|
|
848
837
|
}
|
|
849
838
|
|
|
850
839
|
private async applyVerdict(
|
|
@@ -874,6 +863,7 @@ export class ClaudeRuntime {
|
|
|
874
863
|
const capability = await mintCapability(registry, {
|
|
875
864
|
authority_kind: input.snapshot.role,
|
|
876
865
|
task_id: input.taskId,
|
|
866
|
+
run_id: input.snapshot.run_id,
|
|
877
867
|
action_kind: "request_rework",
|
|
878
868
|
expected_record_hash: input.snapshot.record_revision,
|
|
879
869
|
intent_revision: input.snapshot.intent_revision,
|
|
@@ -896,6 +886,7 @@ export class ClaudeRuntime {
|
|
|
896
886
|
stagePlanningArtifactTransition(ctx.cwd, result.record);
|
|
897
887
|
return;
|
|
898
888
|
}
|
|
889
|
+
const advisories = input.verdict.decision === "pass" ? reviewAdvisoryRecords(input.verdict) : [];
|
|
899
890
|
const approval: TaskApprovalV2 = {
|
|
900
891
|
id: `approval-${input.snapshot.role}-${randomUUID().slice(0, 8)}`,
|
|
901
892
|
kind: input.snapshot.role === "qa" ? "qa" : "review",
|
|
@@ -906,10 +897,12 @@ export class ClaudeRuntime {
|
|
|
906
897
|
actor_id: input.actorId,
|
|
907
898
|
summary: input.verdict.approval!.summary,
|
|
908
899
|
...(input.snapshot.role === "review" && input.snapshot.review_revision ? { review_revision: input.snapshot.review_revision } : {}),
|
|
900
|
+
...(input.snapshot.role === "review" && advisories.length > 0 ? { advisory_findings: advisories } : {}),
|
|
909
901
|
};
|
|
910
902
|
const capability = await mintCapability(registry, {
|
|
911
903
|
authority_kind: input.snapshot.role,
|
|
912
904
|
task_id: input.taskId,
|
|
905
|
+
run_id: input.snapshot.run_id,
|
|
913
906
|
action_kind: "record_approval",
|
|
914
907
|
expected_record_hash: input.snapshot.record_revision,
|
|
915
908
|
intent_revision: input.snapshot.intent_revision,
|
|
@@ -939,8 +932,18 @@ export class ClaudeRuntime {
|
|
|
939
932
|
const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
|
|
940
933
|
const sidecar = join(ctx.cwd, priorIntent.intent_ref.path);
|
|
941
934
|
const priorBytes = operation.op === "revise_intent" ? readFileSync(sidecar) : null;
|
|
935
|
+
// A content-changing revision writes the sidecar before the kernel's drift
|
|
936
|
+
// check runs, so the written sidecar is staged and the exact prior bytes and
|
|
937
|
+
// index entry are restored, verified, if the revision fails.
|
|
938
|
+
const priorStaged = priorBytes !== null ? captureStagedIntent(ctx.cwd, priorIntent.intent_ref.path) : null;
|
|
942
939
|
try {
|
|
943
|
-
if (priorBytes)
|
|
940
|
+
if (priorBytes) {
|
|
941
|
+
writeFileSync(sidecar, `${JSON.stringify(operation.next_intent, null, 2)}\n`);
|
|
942
|
+
execFileSync("git", ["add", "--", priorIntent.intent_ref.path], {
|
|
943
|
+
cwd: ctx.cwd,
|
|
944
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
945
|
+
});
|
|
946
|
+
}
|
|
944
947
|
const result = await app.execute({
|
|
945
948
|
root: ctx.cwd,
|
|
946
949
|
task_id: input.taskId,
|
|
@@ -952,9 +955,10 @@ export class ClaudeRuntime {
|
|
|
952
955
|
if (operation.op === "freeze_artifacts" || operation.op === "stop") stagePlanningArtifactTransition(ctx.cwd, result.record);
|
|
953
956
|
return result;
|
|
954
957
|
} catch (error) {
|
|
955
|
-
if (
|
|
958
|
+
if (priorStaged) {
|
|
956
959
|
const current = await readTaskRecord(ctx.cwd, input.taskId);
|
|
957
|
-
if (current.record?.intent_snapshot.revision === priorIntent.intent.revision)
|
|
960
|
+
if (current.record?.intent_snapshot.revision === priorIntent.intent.revision)
|
|
961
|
+
restoreStagedIntentShared(ctx.cwd, priorStaged);
|
|
958
962
|
}
|
|
959
963
|
throw error;
|
|
960
964
|
}
|
|
@@ -963,577 +967,145 @@ export class ClaudeRuntime {
|
|
|
963
967
|
async startUnattendedBatch(
|
|
964
968
|
initiativeSlug: string,
|
|
965
969
|
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
|
-
> {
|
|
970
|
+
): Promise<ClaudeBatchStartResult> {
|
|
972
971
|
throwIfCancelled(meta.signal);
|
|
973
972
|
const probe = probeHost(this.env, process.platform, this.hostVersion);
|
|
974
973
|
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
975
974
|
const interactive = meta.interactive ?? this.interactive;
|
|
976
975
|
if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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",
|
|
976
|
+
// The text is table-owned; only the form (a thrown native error rather than
|
|
977
|
+
// a returned envelope) is this Host's.
|
|
978
|
+
if (!this.requestConfirmation)
|
|
979
|
+
throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
|
|
980
|
+
|
|
981
|
+
// 1. Host-independent batch preflight: claim ownership, branch
|
|
982
|
+
// availability, working-tree cleanliness against the authorized scope,
|
|
983
|
+
// recovery children, plan digest, and base HEAD are one shared projection,
|
|
984
|
+
// so neither Host re-implements a batch decision.
|
|
985
|
+
const now = new Date().toISOString();
|
|
986
|
+
const preflight = await projectBatchPreflight({
|
|
987
|
+
root: this.cwd,
|
|
988
|
+
initiative_slug: initiativeSlug,
|
|
989
|
+
now,
|
|
990
|
+
readInitiative: this.readInitiative ?? observeGithubInitiative,
|
|
1041
991
|
});
|
|
1042
|
-
if (
|
|
992
|
+
if (!preflight.ok) {
|
|
1043
993
|
return {
|
|
1044
|
-
state:
|
|
1045
|
-
reason:
|
|
1046
|
-
recovery_action:
|
|
994
|
+
state: preflight.state,
|
|
995
|
+
reason: preflight.reason,
|
|
996
|
+
recovery_action: preflight.recovery_action,
|
|
1047
997
|
};
|
|
1048
998
|
}
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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.
|
|
999
|
+
const isResuming = preflight.projection.is_resuming;
|
|
1000
|
+
const batchBranch = preflight.projection.batch_branch;
|
|
1001
|
+
const existingBatch = preflight.projection.existing_batch;
|
|
1002
|
+
const budget = preflight.projection.budget;
|
|
1003
|
+
const planDigest = preflight.projection.plan_digest;
|
|
1004
|
+
const recoveryChildren = preflight.projection.recovery_children;
|
|
1005
|
+
// 5. Literal-user gate plus the shared reuse/expiry decision, the post-gate
|
|
1006
|
+
// claim/drift cascade, and the Batch Authorization binding. The Host supplies
|
|
1007
|
+
// only its gate, its confirmation reference, and its binding nonce.
|
|
1008
|
+
const authorization = await authorizeBatch<ClaudeBatchStartResult>({
|
|
1009
|
+
root: this.cwd,
|
|
1010
|
+
initiative_slug: initiativeSlug,
|
|
1011
|
+
now,
|
|
1012
|
+
projection: preflight.projection,
|
|
1013
|
+
readInitiative: this.readInitiative ?? observeGithubInitiative,
|
|
1014
|
+
nonce: enrollmentNonce(),
|
|
1015
|
+
gate: async (facts) => {
|
|
1016
|
+
// review-4: the bounded elicitation deadline is shared behavior now, so
|
|
1017
|
+
// both Hosts bound an unanswered confirmation by the same setting.
|
|
1018
|
+
const deadline = startConfirmationDeadline({ env: this.env, signal: meta.signal });
|
|
1019
|
+
let confirmationResult: { decision: NativeDecision; requestId: string };
|
|
1076
1020
|
try {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
}
|
|
1021
|
+
confirmationResult = await this.requestConfirmation!({
|
|
1022
|
+
operation: "start_unattended_batch",
|
|
1023
|
+
initiativeSlug,
|
|
1024
|
+
toolCallId: meta.toolCallId,
|
|
1025
|
+
planDigest,
|
|
1026
|
+
batchDetails: {
|
|
1027
|
+
initiative_slug: facts.initiative_slug,
|
|
1028
|
+
batch_branch: facts.batch_branch,
|
|
1029
|
+
children: facts.children.map((child) => ({
|
|
1030
|
+
task_id: child.task_id,
|
|
1031
|
+
slice_id: child.slice_id,
|
|
1032
|
+
risk: child.risk,
|
|
1033
|
+
})),
|
|
1034
|
+
excluded: facts.excluded.map((child) => ({
|
|
1035
|
+
task_id: child.task_id,
|
|
1036
|
+
slice_id: child.slice_id,
|
|
1037
|
+
reason: child.reason,
|
|
1038
|
+
})),
|
|
1039
|
+
budget: facts.budget,
|
|
1040
|
+
expires_at: facts.expires_at,
|
|
1041
|
+
...(facts.reuse_blockers.length > 0
|
|
1042
|
+
? {
|
|
1043
|
+
re_confirmation_required: facts.reuse_blockers,
|
|
1044
|
+
recovery: "confirm to issue a fresh authorization bound to the current plan and HEAD",
|
|
1045
|
+
}
|
|
1046
|
+
: {}),
|
|
1047
|
+
},
|
|
1048
|
+
signal: deadline.signal,
|
|
1049
|
+
});
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
1052
|
+
if (meta.signal?.aborted)
|
|
1053
|
+
return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
|
|
1054
|
+
if (err instanceof NativeAuthorityError) {
|
|
1055
|
+
if (err.reasonCode === "unsupported_host") throw err;
|
|
1056
|
+
if (err.reasonCode === "user_cancelled") {
|
|
1057
|
+
return {
|
|
1058
|
+
kind: "host_rejection",
|
|
1059
|
+
value: { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction },
|
|
1060
|
+
};
|
|
1172
1061
|
}
|
|
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
1062
|
return {
|
|
1178
|
-
|
|
1179
|
-
|
|
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,
|
|
1063
|
+
kind: "host_rejection",
|
|
1064
|
+
value: { state: "rejected", reason: err.message, recovery_action: err.recoveryAction },
|
|
1186
1065
|
};
|
|
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);
|
|
1066
|
+
}
|
|
1244
1067
|
return {
|
|
1245
|
-
|
|
1246
|
-
|
|
1068
|
+
kind: "host_rejection",
|
|
1069
|
+
value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
|
|
1247
1070
|
};
|
|
1248
|
-
}
|
|
1249
|
-
|
|
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 };
|
|
1071
|
+
} finally {
|
|
1072
|
+
deadline.clear();
|
|
1331
1073
|
}
|
|
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
1074
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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,
|
|
1075
|
+
// A transport that answers "cancel" on abort is still a timeout, not a user
|
|
1076
|
+
// decision: the Host that owns the transport reports which one it was.
|
|
1077
|
+
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
1078
|
+
if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
|
|
1079
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1080
|
+
if (meta.signal?.aborted)
|
|
1081
|
+
return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
|
|
1082
|
+
if (confirmationResult.decision === "decline")
|
|
1083
|
+
return { kind: "host_rejection", value: batchReason("confirmation_declined") };
|
|
1084
|
+
if (confirmationResult.decision === "cancel")
|
|
1085
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1086
|
+
if (confirmationResult.decision !== "accept")
|
|
1087
|
+
return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
|
|
1088
|
+
return { kind: "confirmed", request_id: confirmationResult.requestId };
|
|
1089
|
+
},
|
|
1090
|
+
// A reused authorization keeps this invocation's Kernel binding without a new
|
|
1091
|
+
// literal-user act, so its confirmation reference names the resumed batch
|
|
1092
|
+
// rather than a requestId no elicitation produced.
|
|
1093
|
+
confirmationRef: ({ batch_id, request_id }) =>
|
|
1094
|
+
confirmationRef({
|
|
1095
|
+
connectionId: meta.sessionId,
|
|
1096
|
+
toolCallId: meta.toolCallId,
|
|
1097
|
+
requestId: request_id ?? `resumed-${batch_id}`,
|
|
1098
|
+
operation: "start_unattended_batch",
|
|
1447
1099
|
initiativeSlug,
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
}
|
|
1100
|
+
planDigest,
|
|
1101
|
+
}),
|
|
1102
|
+
});
|
|
1103
|
+
if (authorization.outcome === "host_rejection") return authorization.value;
|
|
1104
|
+
if (authorization.outcome === "rejected") return authorization.rejection;
|
|
1105
|
+
const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
|
|
1505
1106
|
|
|
1506
1107
|
// 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
|
-
};
|
|
1108
|
+
if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
|
|
1537
1109
|
|
|
1538
1110
|
const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
1539
1111
|
|
|
@@ -1541,9 +1113,9 @@ export class ClaudeRuntime {
|
|
|
1541
1113
|
const kernelPort: BatchRunnerKernelPort = {
|
|
1542
1114
|
...basePort,
|
|
1543
1115
|
ownsTaskClaim: (taskId) => {
|
|
1544
|
-
if (isResuming && taskId ===
|
|
1116
|
+
if (isResuming && taskId === readActiveClaimTaskId(this.cwd)) {
|
|
1545
1117
|
// Re-verify the CURRENT claim identity synchronously.
|
|
1546
|
-
return
|
|
1118
|
+
return isOwnBatchClaim(this.cwd, existingBatch!, taskId, batchBranch);
|
|
1547
1119
|
}
|
|
1548
1120
|
return basePort.ownsTaskClaim(taskId);
|
|
1549
1121
|
},
|
|
@@ -1556,7 +1128,7 @@ export class ClaudeRuntime {
|
|
|
1556
1128
|
capability,
|
|
1557
1129
|
children: recoveryChildren,
|
|
1558
1130
|
plan_digest: planDigest,
|
|
1559
|
-
base_head:
|
|
1131
|
+
base_head: binding.base_head,
|
|
1560
1132
|
confirmation_time: now,
|
|
1561
1133
|
authorization_expires_at: expiresAt,
|
|
1562
1134
|
budget,
|
|
@@ -1566,13 +1138,7 @@ export class ClaudeRuntime {
|
|
|
1566
1138
|
});
|
|
1567
1139
|
|
|
1568
1140
|
// 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
|
-
}
|
|
1141
|
+
if (report.batch_state === "rejected") return batchReason("batch_run_rejected", report.reason ?? "");
|
|
1576
1142
|
|
|
1577
1143
|
return {
|
|
1578
1144
|
state: "started",
|
|
@@ -1617,7 +1183,8 @@ export class ClaudeRuntime {
|
|
|
1617
1183
|
if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready") {
|
|
1618
1184
|
throw new Error(`Kernel enrollment rehearsal failed: ${rehearsal.evidence.blockers.join("; ")}`);
|
|
1619
1185
|
}
|
|
1620
|
-
|
|
1186
|
+
// The enrollment itself is the effect; its record is re-read below.
|
|
1187
|
+
enrollCanaryTask(root, input, this.enrollmentRegistry);
|
|
1621
1188
|
const recordRaw = readTaskRecordRaw(root, task_id);
|
|
1622
1189
|
return { record_revision: recordRaw.revision };
|
|
1623
1190
|
},
|