immune-brain 3.6.9 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +34 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -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 +3 -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,48 +1,38 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { join } from "node:path";
|
|
7
4
|
import {
|
|
8
5
|
createBatchAuthorityRegistry,
|
|
9
|
-
computeBatchPlanDigest,
|
|
10
6
|
deriveChildEnrollment,
|
|
11
|
-
projectBatchPlan,
|
|
12
7
|
startBatch,
|
|
13
|
-
readGitHead,
|
|
14
|
-
readWorkspaceState,
|
|
15
|
-
readBackendClaim,
|
|
16
|
-
readSettledTaskRecord,
|
|
17
|
-
readTaskIntent,
|
|
18
8
|
readTaskRecord,
|
|
19
9
|
advancePiTask,
|
|
20
10
|
projectAssuranceForTask,
|
|
21
|
-
pathMatchesScope,
|
|
22
|
-
findExistingActiveBatch,
|
|
23
11
|
runEnrollmentRehearsal,
|
|
24
12
|
enrollCanaryTask,
|
|
25
13
|
createEnrollmentAuthorityRegistry,
|
|
26
|
-
type BatchPlan,
|
|
27
|
-
type BatchPlanChild,
|
|
28
14
|
type BatchAuthorityRegistry,
|
|
29
|
-
type BatchAuthorizationBinding,
|
|
30
15
|
type BatchRunnerKernelPort,
|
|
31
16
|
type BatchRunnerGitPort,
|
|
32
17
|
type BatchRunReport,
|
|
33
18
|
type InitiativeObservationReader,
|
|
34
19
|
} from "./runtime-stub";
|
|
20
|
+
import { batchReason } from "../runtime/unattended/batch_reasons";
|
|
21
|
+
import { startConfirmationDeadline } from "../runtime/unattended/confirmation_deadline";
|
|
22
|
+
import {
|
|
23
|
+
authorizeBatch,
|
|
24
|
+
projectBatchPreflight,
|
|
25
|
+
readActiveClaimTaskId,
|
|
26
|
+
isOwnBatchClaim,
|
|
27
|
+
} from "../runtime/unattended/batch_preflight";
|
|
35
28
|
import {
|
|
36
29
|
presentTaskRail,
|
|
37
|
-
presentTaskRailResult,
|
|
38
30
|
renderStructuredCall,
|
|
39
31
|
renderStructuredResult,
|
|
40
32
|
requestAuthorityDialog,
|
|
41
|
-
type UserAttentionReason,
|
|
42
33
|
} from "./pi-canary-interaction";
|
|
43
|
-
import {
|
|
34
|
+
import { throwToolFailure } from "./pi-canary-tool-failure";
|
|
44
35
|
|
|
45
|
-
const INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
46
36
|
|
|
47
37
|
/**
|
|
48
38
|
* Map the native dialog selection to the gate decision. The registered tool's
|
|
@@ -59,60 +49,21 @@ export function mapDialogSelection(selected: string | undefined): "accept" | "de
|
|
|
59
49
|
}
|
|
60
50
|
|
|
61
51
|
/**
|
|
62
|
-
*
|
|
63
|
-
* batch
|
|
64
|
-
*
|
|
65
|
-
* event-id derivation, the intent identity on the TaskRecord, and the claim
|
|
66
|
-
* created before the batch's last durable write. The mutable confirmation_time
|
|
67
|
-
* is deliberately not used, so a needs_human re-authorization (which updates
|
|
68
|
-
* confirmation_time) can never turn this batch's own claim into a foreign one.
|
|
69
|
-
*
|
|
70
|
-
* Called fresh at pre-confirmation, post-confirmation, and from ownsTaskClaim so
|
|
71
|
-
* a claim swapped during confirmation is never adopted.
|
|
52
|
+
* The one refusal a non-interactive Host gets. The registered tool surface and
|
|
53
|
+
* the batch entry point share it, so they cannot drift on which Host may run a
|
|
54
|
+
* batch or on how to recover from asking one that may not.
|
|
72
55
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
): boolean {
|
|
79
|
-
let claim: any = null;
|
|
80
|
-
let workspace: any = null;
|
|
81
|
-
try {
|
|
82
|
-
claim = JSON.parse(readFileSync(join(root, ".imm", "state", "active-claim.json"), "utf8"));
|
|
83
|
-
workspace = JSON.parse(readFileSync(join(root, ".imm", "state", "workspace.json"), "utf8"));
|
|
84
|
-
} catch {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
const currentTaskId =
|
|
88
|
-
workspace?.state?.current_working ||
|
|
89
|
-
(claim?.lifecycle_status === "active" ? claim?.task_id : null);
|
|
90
|
-
if (currentTaskId !== taskId || !claim) return false;
|
|
91
|
-
const branch = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
|
|
92
|
-
if (branch !== batchBranch) return false;
|
|
93
|
-
const childInBatch = existingBatch.children.find((c: { task_id: string }) => c.task_id === taskId);
|
|
94
|
-
if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
let rec: any = null;
|
|
98
|
-
try {
|
|
99
|
-
rec = JSON.parse(readFileSync(join(root, ".imm", "state", "tasks", `${taskId}.json`), "utf8"));
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
const lineageHeads = [existingBatch.base_head].concat(
|
|
104
|
-
Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
|
|
105
|
-
);
|
|
106
|
-
if (!lineageHeads.includes(rec.git_base_head)) return false;
|
|
107
|
-
if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
|
|
108
|
-
const createdAt = Date.parse(claim.created_at);
|
|
109
|
-
if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
|
|
110
|
-
if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
|
|
111
|
-
if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
|
|
112
|
-
if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
|
|
113
|
-
return true;
|
|
56
|
+
function nonInteractiveRefusal(): { reason: string; recovery_action: string } {
|
|
57
|
+
return {
|
|
58
|
+
reason: "interactive TUI elicitation is unavailable in non-interactive mode",
|
|
59
|
+
recovery_action: "invoke through an interactive Pi TUI session in the current Host",
|
|
60
|
+
};
|
|
114
61
|
}
|
|
115
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Batch execution the Host adapters drive: everything above is shared with the
|
|
65
|
+
* Claude adapter and the decisions below are the Host's own.
|
|
66
|
+
*/
|
|
116
67
|
export interface PiBatchExecutionOptions {
|
|
117
68
|
root: string;
|
|
118
69
|
initiativeSlug: string;
|
|
@@ -121,6 +72,8 @@ export interface PiBatchExecutionOptions {
|
|
|
121
72
|
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
122
73
|
batchGit?: BatchRunnerGitPort;
|
|
123
74
|
readInitiative?: InitiativeObservationReader;
|
|
75
|
+
/** Environment the confirmation deadline reads; production passes process.env. */
|
|
76
|
+
env?: Record<string, string | undefined>;
|
|
124
77
|
confirmBatch?: (details: {
|
|
125
78
|
title: string;
|
|
126
79
|
summary: string;
|
|
@@ -155,544 +108,100 @@ export async function executePiUnattendedBatch(
|
|
|
155
108
|
const { root, initiativeSlug, signal } = options;
|
|
156
109
|
const interactive = options.interactive ?? true;
|
|
157
110
|
|
|
158
|
-
if (!interactive) {
|
|
159
|
-
return {
|
|
160
|
-
state: "rejected",
|
|
161
|
-
reason: "interactive TUI elicitation is unavailable in non-interactive mode",
|
|
162
|
-
recovery_action: "invoke through an interactive Pi TUI session in the current Host",
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!INITIATIVE_SLUG_PATTERN.test(initiativeSlug)) {
|
|
167
|
-
return {
|
|
168
|
-
state: "rejected",
|
|
169
|
-
reason: `invalid initiative slug: ${initiativeSlug}`,
|
|
170
|
-
recovery_action: "specify a valid initiative slug and retry in the current Host",
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Check for an existing active/paused batch for this initiative
|
|
175
|
-
const existingBatch = await findExistingActiveBatch(root, initiativeSlug);
|
|
176
|
-
if (existingBatch?.corrupt) {
|
|
177
|
-
return {
|
|
178
|
-
state: "blocked",
|
|
179
|
-
reason: `batch run state is unreadable or invalid: ${existingBatch.path}`,
|
|
180
|
-
recovery_action: "resolve or remove the invalid batch state file, then retry in the current Host",
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
const isResuming = existingBatch !== null;
|
|
184
|
-
const batchBranch = `imm/${initiativeSlug}`;
|
|
185
|
-
|
|
186
|
-
// 1. Active workspace claim check (pre-confirmation)
|
|
187
|
-
const workspace = await readWorkspaceState(root);
|
|
188
|
-
const claim = await readBackendClaim(root);
|
|
189
|
-
const activeTaskId = workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
|
|
190
|
-
const isOwnClaim =
|
|
191
|
-
isResuming &&
|
|
192
|
-
activeTaskId !== null &&
|
|
193
|
-
syncIsOwnBatchClaim(root, existingBatch, activeTaskId, batchBranch);
|
|
194
|
-
if (activeTaskId && !isOwnClaim) {
|
|
195
|
-
return {
|
|
196
|
-
state: "blocked",
|
|
197
|
-
reason: `an active workspace claim already exists for task: ${activeTaskId}`,
|
|
198
|
-
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
199
|
-
};
|
|
200
|
-
}
|
|
111
|
+
if (!interactive) return { state: "rejected", ...nonInteractiveRefusal() };
|
|
201
112
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (branchExists.status === 0 && !isResuming) {
|
|
216
|
-
return {
|
|
217
|
-
state: "rejected",
|
|
218
|
-
reason: `branch preflight failed: branch refs/heads/${batchBranch} already exists`,
|
|
219
|
-
recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
|
|
224
|
-
// format. Trimming the whole output first shifted the fixed status columns of
|
|
225
|
-
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
226
|
-
const statusProc = spawnSync("git", ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { encoding: "utf8" });
|
|
227
|
-
if (statusProc.status !== 0) {
|
|
228
|
-
return {
|
|
229
|
-
state: "rejected",
|
|
230
|
-
reason: "branch preflight failed: git status is unreadable",
|
|
231
|
-
recovery_action: "check the repository integrity and retry in the current Host",
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
const statusEntries: Array<{ code: string; path: string }> = [];
|
|
235
|
-
// -z with --no-renames lists each side of a rename as its own D/A entry, so a
|
|
236
|
-
// cross-scope rename cannot hide the out-of-scope source deletion.
|
|
237
|
-
for (const entry of statusProc.stdout.split("\0")) {
|
|
238
|
-
if (entry.length === 0) continue;
|
|
239
|
-
statusEntries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
|
|
113
|
+
// 1. Host-independent batch preflight: claim ownership, branch availability,
|
|
114
|
+
// working-tree cleanliness against the authorized scope, recovery children,
|
|
115
|
+
// plan digest, and base HEAD are one shared projection, so neither Host
|
|
116
|
+
// re-implements a batch decision.
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
const preflight = await projectBatchPreflight({
|
|
119
|
+
root,
|
|
120
|
+
initiative_slug: initiativeSlug,
|
|
121
|
+
now,
|
|
122
|
+
readInitiative: options.readInitiative,
|
|
123
|
+
});
|
|
124
|
+
if (!preflight.ok) {
|
|
125
|
+
return { state: preflight.state, reason: preflight.reason, recovery_action: preflight.recovery_action };
|
|
240
126
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
127
|
+
const isResuming = preflight.projection.is_resuming;
|
|
128
|
+
const batchBranch = preflight.projection.batch_branch;
|
|
129
|
+
const existingBatch = preflight.projection.existing_batch;
|
|
130
|
+
const budget = preflight.projection.budget;
|
|
131
|
+
const planDigest = preflight.projection.plan_digest;
|
|
132
|
+
const recoveryChildren = preflight.projection.recovery_children;
|
|
133
|
+
|
|
134
|
+
// 4. Literal-user gate plus the shared reuse/expiry decision, the post-gate
|
|
135
|
+
// claim/drift cascade, and the Batch Authorization binding. The Host supplies
|
|
136
|
+
// only its gate, its confirmation reference, and its binding nonce.
|
|
137
|
+
const authorization = await authorizeBatch<PiBatchExecutionResult>({
|
|
138
|
+
root,
|
|
139
|
+
initiative_slug: initiativeSlug,
|
|
140
|
+
now,
|
|
141
|
+
projection: preflight.projection,
|
|
142
|
+
readInitiative: options.readInitiative,
|
|
143
|
+
nonce: randomUUID(),
|
|
144
|
+
gate: async (facts) => {
|
|
145
|
+
// review-2: fail closed with zero writes when confirmation port is missing
|
|
146
|
+
if (!options.confirmBatch) {
|
|
147
|
+
return { kind: "host_rejection", value: batchReason("confirmation_port_unavailable") };
|
|
148
|
+
}
|
|
149
|
+
const confirmDetails = {
|
|
150
|
+
title: `Authorize Unattended Batch: ${facts.initiative_slug}`,
|
|
151
|
+
summary: `Initiative: ${facts.initiative_slug}\nBatch branch: ${facts.batch_branch}\nPlan digest: ${facts.plan_digest}\nBudget: max_children=${facts.budget.max_children}, deadline_at=${facts.budget.deadline_at}, qa_failure_limit=${facts.budget.qa_failure_limit}\nExpires at: ${facts.expires_at}`,
|
|
152
|
+
details: `Ordered children (${facts.children.length}):\n${facts.children.map((child) => ` - ${child.task_id} (${child.slice_id}) [risk: ${child.risk}] [status: ${child.status === "already_settled" ? "completed" : "pending execution"}]`).join("\n")}${facts.excluded.length > 0 ? `\n\nExcluded children:\n${facts.excluded.map((child) => ` - ${child.task_id} (${child.slice_id}): ${child.reason}`).join("\n")}` : ""}${facts.reuse_blockers.length > 0 ? `\n\nRe-confirmation required: ${facts.reuse_blockers.join(", ")}.\nRecovery: confirm to issue a fresh authorization bound to the current plan and HEAD.` : ""}`,
|
|
153
|
+
planDigest: facts.plan_digest,
|
|
154
|
+
signal,
|
|
247
155
|
};
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// `settled` belongs here: Kernel settlement happens before the batch commits the
|
|
254
|
-
// child, and settlement clears the live state record, so a crash in that window
|
|
255
|
-
// resumes into a settled child whose staged work is legitimate.
|
|
256
|
-
const inFlightChild = existingBatch.children.find(
|
|
257
|
-
(c: { state: string }) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
|
|
258
|
-
);
|
|
259
|
-
let authorizedScope: string[] = [];
|
|
260
|
-
if (inFlightChild) {
|
|
261
|
-
// Derive the scope from the Kernel TaskRecord intent snapshot first: a
|
|
262
|
-
// frozen/archived sidecar must not shrink the authorized scope to empty.
|
|
263
|
-
let recordedIntentPath: string | undefined;
|
|
156
|
+
|
|
157
|
+
// The bounded elicitation deadline is shared behavior: an unanswered native
|
|
158
|
+
// confirmation is bounded by the same setting on both Hosts.
|
|
159
|
+
const deadline = startConfirmationDeadline({ env: options.env ?? process.env, signal });
|
|
160
|
+
let decision: "accept" | "decline" | "cancel";
|
|
264
161
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// fallback below
|
|
270
|
-
}
|
|
271
|
-
if (authorizedScope.length === 0 && inFlightChild.state === "settled") {
|
|
272
|
-
// A settled child has no live state record; its authority is the immutable
|
|
273
|
-
// terminal audit pair. Read-only, so a refusal still writes nothing.
|
|
274
|
-
try {
|
|
275
|
-
const settled = await readSettledTaskRecord(root, inFlightChild.task_id);
|
|
276
|
-
authorizedScope = settled?.scope_hint ?? [];
|
|
277
|
-
recordedIntentPath = recordedIntentPath ?? settled?.intent_path;
|
|
278
|
-
} catch {
|
|
279
|
-
// fallback below
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
if (authorizedScope.length === 0) {
|
|
283
|
-
// Resolve through the TaskRecord's own intent_ref path: after freeze the
|
|
284
|
-
// sidecar lives under docs/plans/archive/, so the pre-freeze default path
|
|
285
|
-
// is either missing or stale.
|
|
286
|
-
try {
|
|
287
|
-
const read = await readTaskIntent(root, inFlightChild.task_id, recordedIntentPath);
|
|
288
|
-
authorizedScope = read.intent.scope_hint ?? [];
|
|
289
|
-
} catch {
|
|
290
|
-
// fallback below
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
if (authorizedScope.length === 0) {
|
|
162
|
+
decision = await options.confirmBatch({ ...confirmDetails, signal: deadline.signal });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
165
|
+
if (signal?.aborted) return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
294
166
|
return {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
recovery_action: "resolve the child's intent record, then retry in the current Host",
|
|
167
|
+
kind: "host_rejection",
|
|
168
|
+
value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
|
|
298
169
|
};
|
|
170
|
+
} finally {
|
|
171
|
+
deadline.clear();
|
|
299
172
|
}
|
|
300
|
-
}
|
|
301
|
-
const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
|
|
302
|
-
if (dirtyBytes) {
|
|
303
|
-
return {
|
|
304
|
-
state: "rejected",
|
|
305
|
-
reason: "branch preflight failed: working tree has unstaged or untracked changes",
|
|
306
|
-
recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
let outsideScope = false;
|
|
310
|
-
for (const { path } of statusEntries) {
|
|
311
|
-
if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
|
|
312
|
-
// Scope entries may be exact files, directories, or globs; delegate the
|
|
313
|
-
// boundary matching to the Kernel's own helper instead of exact includes.
|
|
314
|
-
let matched = false;
|
|
315
|
-
for (const scopePath of authorizedScope) {
|
|
316
|
-
if (await pathMatchesScope(path, scopePath)) {
|
|
317
|
-
matched = true;
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (!matched) {
|
|
322
|
-
outsideScope = true;
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
if (outsideScope) {
|
|
327
|
-
return {
|
|
328
|
-
state: "rejected",
|
|
329
|
-
reason: "branch preflight failed: working tree has changes outside the authorized child scope",
|
|
330
|
-
recovery_action: "commit or unstage changes outside the active task scope, then retry in the current Host",
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// 3. Project or reconstruct batch plan (pre-confirmation)
|
|
336
|
-
const now = new Date().toISOString();
|
|
337
|
-
let recoveryChildren: BatchPlanChild[] = [];
|
|
338
|
-
let planDigest: string;
|
|
339
|
-
let confirmChildrenDetails: string[] = [];
|
|
340
|
-
let confirmExcludedDetails: string[] = [];
|
|
341
|
-
const recoveryRiskByTask = new Map<string, string>();
|
|
342
|
-
let budget = existingBatch ? existingBatch.budget : { max_children: 10, deadline_at: new Date(Date.now() + 8 * 3600 * 1000).toISOString(), qa_failure_limit: 2 };
|
|
343
173
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
recoveryChildren = await Promise.all(
|
|
348
|
-
existingBatch.children.map(async (c: any) => {
|
|
349
|
-
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
350
|
-
let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
|
|
351
|
-
try {
|
|
352
|
-
const taskRecordRead = await readTaskRecord(root, c.task_id);
|
|
353
|
-
if (taskRecordRead.record) {
|
|
354
|
-
read = {
|
|
355
|
-
intent: taskRecordRead.record.intent_snapshot,
|
|
356
|
-
content_hash: taskRecordRead.record.intent_ref.content_hash,
|
|
357
|
-
};
|
|
358
|
-
} else {
|
|
359
|
-
read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
|
|
363
|
-
try {
|
|
364
|
-
read = (await readTaskIntent(root, c.task_id, archivePath)) as any;
|
|
365
|
-
} catch {
|
|
366
|
-
read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
// Keep the risk captured by the authoritative read above; a stale
|
|
370
|
-
// reconstructed path must never fabricate a risk later.
|
|
371
|
-
recoveryRiskByTask.set(c.task_id, read.intent?.risk ?? "material");
|
|
372
|
-
const isDone = c.state === "committed" || c.state === "settled";
|
|
373
|
-
return {
|
|
374
|
-
task_id: c.task_id,
|
|
375
|
-
slice_id: c.slice_id,
|
|
376
|
-
status: isDone ? ("already_settled" as const) : ("enrollable" as const),
|
|
377
|
-
blocked_by: [...c.blocked_by],
|
|
378
|
-
reason: c.reason ?? null,
|
|
379
|
-
intent_path: intentPath,
|
|
380
|
-
intent_revision: read.intent.revision,
|
|
381
|
-
intent_content_hash: read.content_hash,
|
|
382
|
-
};
|
|
383
|
-
}),
|
|
384
|
-
);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
return {
|
|
387
|
-
state: "rejected",
|
|
388
|
-
reason: `failed to project batch plan: ${err instanceof Error ? err.message : String(err)}`,
|
|
389
|
-
recovery_action: "review initiative issues and planning sidecars in the current Host",
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
planDigest = await computeBatchPlanDigest(recoveryChildren);
|
|
393
|
-
for (const c of recoveryChildren) {
|
|
394
|
-
// Use the risk captured from the authoritative recovery read; never fall
|
|
395
|
-
// back to a fabricated "material" when the reconstructed sidecar path is
|
|
396
|
-
// stale (freeze_artifacts archives the active sidecar).
|
|
397
|
-
const childRisk = recoveryRiskByTask.get(c.task_id) ?? "material";
|
|
398
|
-
if (c.status === "already_settled") {
|
|
399
|
-
confirmChildrenDetails.push(` - ${c.task_id} (${c.slice_id}) [risk: ${childRisk}] [status: completed]`);
|
|
400
|
-
} else {
|
|
401
|
-
confirmChildrenDetails.push(` - ${c.task_id} (${c.slice_id}) [risk: ${childRisk}] [status: pending execution]`);
|
|
174
|
+
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
175
|
+
if (decision === "cancel" || signal?.aborted) {
|
|
176
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
402
177
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
let plan: BatchPlan;
|
|
406
|
-
try {
|
|
407
|
-
plan = await projectBatchPlan(
|
|
408
|
-
root,
|
|
409
|
-
initiativeSlug,
|
|
410
|
-
{ confirmation_time: now },
|
|
411
|
-
options.readInitiative,
|
|
412
|
-
);
|
|
413
|
-
} catch (err) {
|
|
414
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
415
|
-
if (msg.includes("has no enrollable children")) {
|
|
416
|
-
return {
|
|
417
|
-
state: "rejected",
|
|
418
|
-
reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
|
|
419
|
-
recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
|
|
420
|
-
};
|
|
178
|
+
if (decision === "decline") {
|
|
179
|
+
return { kind: "host_rejection", value: batchReason("confirmation_declined") };
|
|
421
180
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
reason: `failed to project batch plan: ${msg}`,
|
|
425
|
-
recovery_action: "review initiative issues and planning sidecars in the current Host",
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (!plan.enrollable.length) {
|
|
430
|
-
return {
|
|
431
|
-
state: "rejected",
|
|
432
|
-
reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
|
|
433
|
-
recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
budget = plan.budget;
|
|
438
|
-
const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
|
|
439
|
-
recoveryChildren = plan.children
|
|
440
|
-
.filter((c) => c.status === "enrollable")
|
|
441
|
-
.map((c) => {
|
|
442
|
-
const digestChild = enrollableChildById.get(c.task_id);
|
|
443
|
-
return {
|
|
444
|
-
...c,
|
|
445
|
-
blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
|
|
446
|
-
};
|
|
447
|
-
});
|
|
448
|
-
planDigest = await computeBatchPlanDigest(plan.enrollable);
|
|
449
|
-
|
|
450
|
-
for (const c of plan.children.filter((item) => item.status === "enrollable")) {
|
|
451
|
-
let childRisk = "material";
|
|
452
|
-
try {
|
|
453
|
-
const intentRead = await readTaskIntent(root, c.task_id, c.intent_path ?? undefined);
|
|
454
|
-
childRisk = intentRead.intent.risk;
|
|
455
|
-
} catch {
|
|
456
|
-
// fallback
|
|
181
|
+
if (decision !== "accept") {
|
|
182
|
+
return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
|
|
457
183
|
}
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
? existingBatch.authorization_expires_at
|
|
466
|
-
: new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
467
|
-
|
|
468
|
-
// 4. Native confirmation
|
|
469
|
-
const confirmDetails = {
|
|
470
|
-
title: `Authorize Unattended Batch: ${initiativeSlug}`,
|
|
471
|
-
summary: `Initiative: ${initiativeSlug}\nBatch branch: ${batchBranch}\nPlan digest: ${planDigest}\nBudget: max_children=${budget.max_children}, deadline_at=${budget.deadline_at}, qa_failure_limit=${budget.qa_failure_limit}\nExpires at: ${expiresAt}`,
|
|
472
|
-
details: `Ordered children (${recoveryChildren.length}):\n${confirmChildrenDetails.join("\n")}${confirmExcludedDetails.length > 0 ? `\n\nExcluded children:\n${confirmExcludedDetails.join("\n")}` : ""}`,
|
|
473
|
-
planDigest,
|
|
474
|
-
signal,
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
// review-2: fail closed with zero writes when confirmation port is missing
|
|
478
|
-
if (!options.confirmBatch) {
|
|
479
|
-
return {
|
|
480
|
-
state: "rejected",
|
|
481
|
-
reason: "native confirmation port is unavailable",
|
|
482
|
-
recovery_action: "retry through a fresh native gate in the current Host",
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
let decision: "accept" | "decline" | "cancel";
|
|
487
|
-
try {
|
|
488
|
-
decision = await options.confirmBatch(confirmDetails);
|
|
489
|
-
} catch (err) {
|
|
490
|
-
if (signal?.aborted) {
|
|
491
|
-
return {
|
|
492
|
-
state: "cancelled",
|
|
493
|
-
reason: "native interaction cancelled",
|
|
494
|
-
recovery_action: "wait for a fresh literal-user request",
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
return {
|
|
498
|
-
state: "rejected",
|
|
499
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
500
|
-
recovery_action: "retry through a fresh native gate in the current Host",
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (decision === "cancel" || signal?.aborted) {
|
|
505
|
-
return {
|
|
506
|
-
state: "cancelled",
|
|
507
|
-
reason: "native interaction cancelled",
|
|
508
|
-
recovery_action: "wait for a fresh literal-user request",
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
if (decision === "decline") {
|
|
512
|
-
return {
|
|
513
|
-
state: "rejected",
|
|
514
|
-
reason: "native interaction declined",
|
|
515
|
-
recovery_action: "wait for a fresh literal-user request",
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
if (decision !== "accept") {
|
|
519
|
-
return {
|
|
520
|
-
state: "rejected",
|
|
521
|
-
reason: "native interaction returned no decision",
|
|
522
|
-
recovery_action: "retry through a fresh native gate in the current Host",
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// 5. Post-confirmation Revalidation (Workspace claim, Plan drift, Git HEAD)
|
|
527
|
-
const postWorkspace = await readWorkspaceState(root);
|
|
528
|
-
const postClaim = await readBackendClaim(root);
|
|
529
|
-
const postActiveTaskId = postWorkspace.state.current_working || (postClaim?.lifecycle_status === "active" ? postClaim.task_id : null);
|
|
530
|
-
// Re-verify the full claim identity now, not the pre-confirmation snapshot: a
|
|
531
|
-
// claim swapped for the same child during confirmation must stay blocked.
|
|
532
|
-
const postIsOwnClaim =
|
|
533
|
-
isResuming &&
|
|
534
|
-
postActiveTaskId !== null &&
|
|
535
|
-
syncIsOwnBatchClaim(root, existingBatch, postActiveTaskId, batchBranch);
|
|
536
|
-
if (postActiveTaskId && !postIsOwnClaim) {
|
|
537
|
-
return {
|
|
538
|
-
state: "blocked",
|
|
539
|
-
reason: `an active workspace claim appeared during confirmation for task: ${postActiveTaskId}`,
|
|
540
|
-
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (isResuming) {
|
|
545
|
-
let recheckedDigest: string;
|
|
546
|
-
try {
|
|
547
|
-
const recheckedChildren = await Promise.all(
|
|
548
|
-
existingBatch.children.map(async (c: any) => {
|
|
549
|
-
const intentPath = `docs/plans/${c.task_id}.intent.json`;
|
|
550
|
-
let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
|
|
551
|
-
try {
|
|
552
|
-
const taskRecordRead = await readTaskRecord(root, c.task_id);
|
|
553
|
-
if (taskRecordRead.record) {
|
|
554
|
-
read = {
|
|
555
|
-
intent: taskRecordRead.record.intent_snapshot,
|
|
556
|
-
content_hash: taskRecordRead.record.intent_ref.content_hash,
|
|
557
|
-
};
|
|
558
|
-
} else {
|
|
559
|
-
read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
|
|
560
|
-
}
|
|
561
|
-
} catch {
|
|
562
|
-
const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
|
|
563
|
-
try {
|
|
564
|
-
read = (await readTaskIntent(root, c.task_id, archivePath)) as any;
|
|
565
|
-
} catch {
|
|
566
|
-
read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
const isDone = c.state === "committed" || c.state === "settled";
|
|
570
|
-
return {
|
|
571
|
-
task_id: c.task_id,
|
|
572
|
-
slice_id: c.slice_id,
|
|
573
|
-
status: isDone ? ("already_settled" as const) : ("enrollable" as const),
|
|
574
|
-
blocked_by: [...c.blocked_by],
|
|
575
|
-
reason: c.reason ?? null,
|
|
576
|
-
intent_path: intentPath,
|
|
577
|
-
intent_revision: read.intent.revision,
|
|
578
|
-
intent_content_hash: read.content_hash,
|
|
579
|
-
};
|
|
580
|
-
}),
|
|
581
|
-
);
|
|
582
|
-
recheckedDigest = await computeBatchPlanDigest(recheckedChildren);
|
|
583
|
-
} catch (err) {
|
|
584
|
-
return {
|
|
585
|
-
state: "rejected",
|
|
586
|
-
reason: "batch plan became unreadable after native confirmation",
|
|
587
|
-
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
if (recheckedDigest !== planDigest) {
|
|
591
|
-
return {
|
|
592
|
-
state: "rejected",
|
|
593
|
-
reason: "batch plan changed after native confirmation",
|
|
594
|
-
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
595
|
-
};
|
|
596
|
-
}
|
|
597
|
-
} else {
|
|
598
|
-
let revalidatedPlan: BatchPlan;
|
|
599
|
-
try {
|
|
600
|
-
revalidatedPlan = await projectBatchPlan(
|
|
601
|
-
root,
|
|
184
|
+
return { kind: "confirmed", request_id: randomUUID() };
|
|
185
|
+
},
|
|
186
|
+
confirmationRef: ({ batch_id, request_id }) =>
|
|
187
|
+
piConfirmationRef({
|
|
188
|
+
toolCallId: `call-${batch_id}`,
|
|
189
|
+
requestId: request_id ?? randomUUID(),
|
|
190
|
+
operation: "start_unattended_batch",
|
|
602
191
|
initiativeSlug,
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
reason: "batch plan became unreadable after native confirmation",
|
|
610
|
-
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const revalidatedDigest = await computeBatchPlanDigest(revalidatedPlan.enrollable);
|
|
615
|
-
if (revalidatedDigest !== planDigest) {
|
|
616
|
-
return {
|
|
617
|
-
state: "rejected",
|
|
618
|
-
reason: "batch plan changed after native confirmation",
|
|
619
|
-
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
let postHead: string;
|
|
625
|
-
try {
|
|
626
|
-
postHead = await readGitHead(root);
|
|
627
|
-
} catch (err) {
|
|
628
|
-
return {
|
|
629
|
-
state: "rejected",
|
|
630
|
-
reason: "Git repository became unreadable after native confirmation",
|
|
631
|
-
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
if (postHead !== baseHead) {
|
|
635
|
-
return {
|
|
636
|
-
state: "rejected",
|
|
637
|
-
reason: "Git HEAD moved after native confirmation",
|
|
638
|
-
recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// review-f72ae870f4f0-1: re-check the workspace claim AFTER the async plan
|
|
643
|
-
// revalidation finishes. The earlier check ran before that await, so a foreign
|
|
644
|
-
// enrollment completing during the Initiative read would otherwise reach
|
|
645
|
-
// authority issuance and let startBatch create the branch and batch state,
|
|
646
|
-
// while Claude returns blocked. Same check, same order, same reason as Claude.
|
|
647
|
-
const finalWorkspace = await readWorkspaceState(root);
|
|
648
|
-
const finalClaim = await readBackendClaim(root);
|
|
649
|
-
const finalActiveTaskId = finalWorkspace.state.current_working || (finalClaim?.lifecycle_status === "active" ? finalClaim.task_id : null);
|
|
650
|
-
const finalIsOwnClaim =
|
|
651
|
-
isResuming &&
|
|
652
|
-
finalActiveTaskId !== null &&
|
|
653
|
-
syncIsOwnBatchClaim(root, existingBatch, finalActiveTaskId, batchBranch);
|
|
654
|
-
if (finalActiveTaskId && !finalIsOwnClaim) {
|
|
655
|
-
return {
|
|
656
|
-
state: "blocked",
|
|
657
|
-
reason: `an active workspace claim appeared during confirmation for task: ${finalActiveTaskId}`,
|
|
658
|
-
recovery_action: "resolve or stop the active task before starting a batch in the current Host",
|
|
659
|
-
};
|
|
660
|
-
}
|
|
192
|
+
planDigest,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
if (authorization.outcome === "host_rejection") return authorization.value;
|
|
196
|
+
if (authorization.outcome === "rejected") return authorization.rejection;
|
|
197
|
+
const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
|
|
661
198
|
|
|
662
199
|
// 6. Issue Batch Authorization through Kernel registry and startBatch
|
|
663
200
|
const batchRegistry: BatchAuthorityRegistry = await createBatchAuthorityRegistry();
|
|
664
201
|
const enrollmentRegistry = await createEnrollmentAuthorityRegistry();
|
|
665
202
|
|
|
666
203
|
// review-2: verify cancellation signal right before authority issuance and startBatch
|
|
667
|
-
if (signal?.aborted)
|
|
668
|
-
return {
|
|
669
|
-
state: "cancelled",
|
|
670
|
-
reason: "user cancelled before batch execution",
|
|
671
|
-
recovery_action: "wait for a fresh literal-user request",
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const batchId = existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${Date.now()}`;
|
|
676
|
-
const confirmation = piConfirmationRef({
|
|
677
|
-
toolCallId: `call-${batchId}`,
|
|
678
|
-
requestId: randomUUID(),
|
|
679
|
-
operation: "start_unattended_batch",
|
|
680
|
-
initiativeSlug,
|
|
681
|
-
planDigest,
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
const binding: BatchAuthorizationBinding = {
|
|
685
|
-
batch_id: batchId,
|
|
686
|
-
initiative_slug: initiativeSlug,
|
|
687
|
-
plan_digest: planDigest,
|
|
688
|
-
branch: batchBranch,
|
|
689
|
-
base_head: isResuming ? existingBatch.base_head : baseHead,
|
|
690
|
-
budget,
|
|
691
|
-
actor_id: "user",
|
|
692
|
-
confirmation_ref: confirmation,
|
|
693
|
-
expires_at: expiresAt,
|
|
694
|
-
nonce: randomUUID(),
|
|
695
|
-
};
|
|
204
|
+
if (signal?.aborted) return batchReason("cancelled_before_execution");
|
|
696
205
|
|
|
697
206
|
const capability = batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
698
207
|
|
|
@@ -745,10 +254,10 @@ export async function executePiUnattendedBatch(
|
|
|
745
254
|
return projectAssuranceForTask(taskRoot, taskId);
|
|
746
255
|
},
|
|
747
256
|
ownsTaskClaim: (taskId) => {
|
|
748
|
-
if (isResuming && taskId ===
|
|
257
|
+
if (isResuming && taskId === readActiveClaimTaskId(root)) {
|
|
749
258
|
// Re-verify the CURRENT claim identity synchronously: a claim swapped
|
|
750
259
|
// during confirmation is never adopted.
|
|
751
|
-
return
|
|
260
|
+
return isOwnBatchClaim(root, existingBatch!, taskId, batchBranch);
|
|
752
261
|
}
|
|
753
262
|
return batchRegistry.isChildConsumed(capability, taskId);
|
|
754
263
|
},
|
|
@@ -781,7 +290,7 @@ export async function executePiUnattendedBatch(
|
|
|
781
290
|
capability,
|
|
782
291
|
children: recoveryChildren,
|
|
783
292
|
plan_digest: planDigest,
|
|
784
|
-
base_head:
|
|
293
|
+
base_head: binding.base_head,
|
|
785
294
|
confirmation_time: now,
|
|
786
295
|
authorization_expires_at: expiresAt,
|
|
787
296
|
budget,
|
|
@@ -791,11 +300,7 @@ export async function executePiUnattendedBatch(
|
|
|
791
300
|
});
|
|
792
301
|
|
|
793
302
|
if (report.batch_state === "rejected") {
|
|
794
|
-
return
|
|
795
|
-
state: "rejected",
|
|
796
|
-
reason: report.reason ?? "batch run rejected",
|
|
797
|
-
recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
|
|
798
|
-
};
|
|
303
|
+
return batchReason("batch_run_rejected", report.reason ?? "");
|
|
799
304
|
}
|
|
800
305
|
|
|
801
306
|
return {
|
|
@@ -843,14 +348,15 @@ export default function (
|
|
|
843
348
|
) => {
|
|
844
349
|
const { initiative_slug: initiativeSlug } = params;
|
|
845
350
|
if (ctx.mode !== "tui") {
|
|
351
|
+
const refusal = nonInteractiveRefusal();
|
|
846
352
|
throwToolFailure({
|
|
847
353
|
tool: "imm_canary_enrollment",
|
|
848
354
|
task_id: initiativeSlug,
|
|
849
355
|
operation: "start_unattended_batch",
|
|
850
356
|
state: "blocked",
|
|
851
357
|
code: "unsupported_host",
|
|
852
|
-
message:
|
|
853
|
-
next_action:
|
|
358
|
+
message: refusal.reason,
|
|
359
|
+
next_action: refusal.recovery_action,
|
|
854
360
|
});
|
|
855
361
|
}
|
|
856
362
|
|