immune-brain 3.6.6 → 3.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -51
- package/README.zh-CN.md +153 -49
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/package.json +3 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +149 -4
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
- package/plugins/immune-brain/dist/imm-loop.md +25 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
- package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
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
|
+
import {
|
|
8
|
+
createBatchAuthorityRegistry,
|
|
9
|
+
computeBatchPlanDigest,
|
|
10
|
+
deriveChildEnrollment,
|
|
11
|
+
projectBatchPlan,
|
|
12
|
+
startBatch,
|
|
13
|
+
readGitHead,
|
|
14
|
+
readWorkspaceState,
|
|
15
|
+
readBackendClaim,
|
|
16
|
+
readSettledTaskRecord,
|
|
17
|
+
readTaskIntent,
|
|
18
|
+
readTaskRecord,
|
|
19
|
+
advancePiTask,
|
|
20
|
+
projectAssuranceForTask,
|
|
21
|
+
pathMatchesScope,
|
|
22
|
+
findExistingActiveBatch,
|
|
23
|
+
runEnrollmentRehearsal,
|
|
24
|
+
enrollCanaryTask,
|
|
25
|
+
createEnrollmentAuthorityRegistry,
|
|
26
|
+
type BatchPlan,
|
|
27
|
+
type BatchPlanChild,
|
|
28
|
+
type BatchAuthorityRegistry,
|
|
29
|
+
type BatchAuthorizationBinding,
|
|
30
|
+
type BatchRunnerKernelPort,
|
|
31
|
+
type BatchRunnerGitPort,
|
|
32
|
+
type BatchRunReport,
|
|
33
|
+
type InitiativeObservationReader,
|
|
34
|
+
} from "./runtime-stub";
|
|
35
|
+
import {
|
|
36
|
+
presentTaskRail,
|
|
37
|
+
presentTaskRailResult,
|
|
38
|
+
renderStructuredCall,
|
|
39
|
+
renderStructuredResult,
|
|
40
|
+
requestAuthorityDialog,
|
|
41
|
+
type UserAttentionReason,
|
|
42
|
+
} from "./pi-canary-interaction";
|
|
43
|
+
import { isToolFailureState, throwToolFailure } from "./pi-canary-tool-failure";
|
|
44
|
+
|
|
45
|
+
const INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map the native dialog selection to the gate decision. The registered tool's
|
|
49
|
+
* dialog exposes confirm, decline, and cancel; Escape (undefined) is cancel so
|
|
50
|
+
* the repository and authority stay untouched. Exported for the registered-tool
|
|
51
|
+
* contract test: the dialog sits behind plan projection, so the mapping itself
|
|
52
|
+
* is the testable unit, and the direct-execution suites cover the decision
|
|
53
|
+
* outcomes (decline -> rejected, cancel/Escape -> cancelled) with zero writes.
|
|
54
|
+
*/
|
|
55
|
+
export function mapDialogSelection(selected: string | undefined): "accept" | "decline" | "cancel" {
|
|
56
|
+
if (selected === "confirm") return "accept";
|
|
57
|
+
if (selected === "decline") return "decline";
|
|
58
|
+
return "cancel";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Synchronous re-verification that the currently held claim for `taskId` is this
|
|
63
|
+
* batch's own Kernel enrollment. Positive evidence only: the driver's durable
|
|
64
|
+
* child slot (enrolled/needs_human), the batch Git lineage, the Kernel's own
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
function syncIsOwnBatchClaim(
|
|
74
|
+
root: string,
|
|
75
|
+
existingBatch: any,
|
|
76
|
+
taskId: string,
|
|
77
|
+
batchBranch: string,
|
|
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;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface PiBatchExecutionOptions {
|
|
117
|
+
root: string;
|
|
118
|
+
initiativeSlug: string;
|
|
119
|
+
interactive?: boolean;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
122
|
+
batchGit?: BatchRunnerGitPort;
|
|
123
|
+
readInitiative?: InitiativeObservationReader;
|
|
124
|
+
confirmBatch?: (details: {
|
|
125
|
+
title: string;
|
|
126
|
+
summary: string;
|
|
127
|
+
details: string;
|
|
128
|
+
planDigest: string;
|
|
129
|
+
signal?: AbortSignal;
|
|
130
|
+
}) => Promise<"accept" | "decline" | "cancel">;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type PiBatchExecutionResult =
|
|
134
|
+
| { state: "started"; batch_id: string; report: BatchRunReport }
|
|
135
|
+
| { state: "rejected"; reason: string; recovery_action: string }
|
|
136
|
+
| { state: "cancelled"; reason: string; recovery_action: string }
|
|
137
|
+
| { state: "blocked"; reason: string; recovery_action: string };
|
|
138
|
+
|
|
139
|
+
function piConfirmationRef(input: {
|
|
140
|
+
toolCallId: string;
|
|
141
|
+
requestId: string;
|
|
142
|
+
operation: string;
|
|
143
|
+
initiativeSlug: string;
|
|
144
|
+
planDigest: string;
|
|
145
|
+
}): string {
|
|
146
|
+
return `pi-confirm-${createHash("sha256")
|
|
147
|
+
.update(`${input.toolCallId}\0${input.requestId}\0${input.operation}\0${input.initiativeSlug}\0${input.planDigest}`)
|
|
148
|
+
.digest("hex")
|
|
149
|
+
.slice(0, 16)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function executePiUnattendedBatch(
|
|
153
|
+
options: PiBatchExecutionOptions,
|
|
154
|
+
): Promise<PiBatchExecutionResult> {
|
|
155
|
+
const { root, initiativeSlug, signal } = options;
|
|
156
|
+
const interactive = options.interactive ?? true;
|
|
157
|
+
|
|
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
|
+
}
|
|
201
|
+
|
|
202
|
+
// 2. Git HEAD & read-only preflight check (pre-confirmation)
|
|
203
|
+
let baseHead: string;
|
|
204
|
+
try {
|
|
205
|
+
baseHead = await readGitHead(root);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return {
|
|
208
|
+
state: "rejected",
|
|
209
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
210
|
+
recovery_action: "commit working changes and ensure a committed Git HEAD exists in the current Host",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const branchExists = spawnSync("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
|
|
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) });
|
|
240
|
+
}
|
|
241
|
+
if (statusEntries.length > 0) {
|
|
242
|
+
if (!isResuming) {
|
|
243
|
+
return {
|
|
244
|
+
state: "rejected",
|
|
245
|
+
reason: "branch preflight failed: working tree is dirty",
|
|
246
|
+
recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// Kernel projections accept staged in-flight work inside the active child's
|
|
250
|
+
// authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
|
|
251
|
+
// Mirror that rule here so a doomed resume fails closed with a stable reason
|
|
252
|
+
// instead of surfacing an unstructured projection error.
|
|
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;
|
|
264
|
+
try {
|
|
265
|
+
const recordRead = await readTaskRecord(root, inFlightChild.task_id);
|
|
266
|
+
authorizedScope = recordRead.record?.intent_snapshot?.scope_hint ?? [];
|
|
267
|
+
recordedIntentPath = recordRead.record?.intent_ref?.path;
|
|
268
|
+
} catch {
|
|
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) {
|
|
294
|
+
return {
|
|
295
|
+
state: "rejected",
|
|
296
|
+
reason: "branch preflight failed: cannot derive the in-flight child's authorized scope",
|
|
297
|
+
recovery_action: "resolve the child's intent record, then retry in the current Host",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
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
|
+
|
|
344
|
+
if (isResuming) {
|
|
345
|
+
// Reconstruct unified recovery plan from existingBatch.children
|
|
346
|
+
try {
|
|
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]`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
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
|
+
};
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
state: "rejected",
|
|
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
|
|
457
|
+
}
|
|
458
|
+
confirmChildrenDetails.push(` - ${c.task_id} (${c.slice_id}) [risk: ${childRisk}]`);
|
|
459
|
+
}
|
|
460
|
+
confirmExcludedDetails = plan.children.filter((c) => c.status !== "enrollable").map((c) => ` - ${c.task_id} (${c.slice_id}): ${c.reason ?? c.status}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const isExistingExpired = isResuming && Date.parse(existingBatch.authorization_expires_at) <= Date.now();
|
|
464
|
+
const expiresAt = isResuming && !isExistingExpired && existingBatch.batch_state === "running"
|
|
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,
|
|
602
|
+
initiativeSlug,
|
|
603
|
+
{ confirmation_time: now },
|
|
604
|
+
options.readInitiative,
|
|
605
|
+
);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
return {
|
|
608
|
+
state: "rejected",
|
|
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
|
+
}
|
|
661
|
+
|
|
662
|
+
// 6. Issue Batch Authorization through Kernel registry and startBatch
|
|
663
|
+
const batchRegistry: BatchAuthorityRegistry = await createBatchAuthorityRegistry();
|
|
664
|
+
const enrollmentRegistry = await createEnrollmentAuthorityRegistry();
|
|
665
|
+
|
|
666
|
+
// 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
|
+
};
|
|
696
|
+
|
|
697
|
+
const capability = batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
698
|
+
|
|
699
|
+
let activeReviewDispatch: { operation_id: string; agent_params: Record<string, unknown> } | null = null;
|
|
700
|
+
|
|
701
|
+
const realPort: BatchRunnerKernelPort = {
|
|
702
|
+
enrollTask: async ({ root: taskRoot, task_id, batch: b }) => {
|
|
703
|
+
const derived = await deriveChildEnrollment(taskRoot, b.registry, {
|
|
704
|
+
capability: b.capability,
|
|
705
|
+
binding,
|
|
706
|
+
task_id,
|
|
707
|
+
expected_head: b.binding.expected_head,
|
|
708
|
+
now,
|
|
709
|
+
});
|
|
710
|
+
const enrollmentCapability = enrollmentRegistry.issue(derived.binding);
|
|
711
|
+
const input = {
|
|
712
|
+
task_id,
|
|
713
|
+
intent_path: derived.binding.intent_path,
|
|
714
|
+
intent_revision: derived.binding.intent_revision,
|
|
715
|
+
preparation_digest: derived.binding.preparation_digest,
|
|
716
|
+
capability: enrollmentCapability,
|
|
717
|
+
capability_binding: derived.binding,
|
|
718
|
+
batch: {
|
|
719
|
+
registry: b.registry,
|
|
720
|
+
capability: b.capability,
|
|
721
|
+
binding,
|
|
722
|
+
expected_head: b.binding.expected_head,
|
|
723
|
+
},
|
|
724
|
+
now,
|
|
725
|
+
};
|
|
726
|
+
const rehearsal = await runEnrollmentRehearsal(taskRoot, input, enrollmentCapability, enrollmentRegistry);
|
|
727
|
+
if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready") {
|
|
728
|
+
throw new Error(`Kernel enrollment rehearsal failed: ${rehearsal.evidence.blockers.join("; ")}`);
|
|
729
|
+
}
|
|
730
|
+
await enrollCanaryTask(taskRoot, input, enrollmentRegistry);
|
|
731
|
+
const recordRaw = await readTaskRecord(taskRoot, task_id);
|
|
732
|
+
return { record_revision: recordRaw.revision };
|
|
733
|
+
},
|
|
734
|
+
advanceTask: async (taskRoot, taskId) => {
|
|
735
|
+
const res = await advancePiTask(taskRoot, taskId);
|
|
736
|
+
if (res.state === "review_ready" && res.agent_params) {
|
|
737
|
+
activeReviewDispatch = {
|
|
738
|
+
operation_id: res.operation_id,
|
|
739
|
+
agent_params: res.agent_params,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
return res;
|
|
743
|
+
},
|
|
744
|
+
projectTask: async (taskRoot, taskId) => {
|
|
745
|
+
return projectAssuranceForTask(taskRoot, taskId);
|
|
746
|
+
},
|
|
747
|
+
ownsTaskClaim: (taskId) => {
|
|
748
|
+
if (isResuming && taskId === activeTaskId) {
|
|
749
|
+
// Re-verify the CURRENT claim identity synchronously: a claim swapped
|
|
750
|
+
// during confirmation is never adopted.
|
|
751
|
+
return syncIsOwnBatchClaim(root, existingBatch, taskId, batchBranch);
|
|
752
|
+
}
|
|
753
|
+
return batchRegistry.isChildConsumed(capability, taskId);
|
|
754
|
+
},
|
|
755
|
+
validateBatchAuthorization: (input) => input.registry.inspect(input.capability, input.binding as never),
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const rawAdvance = options.batchKernel?.advanceTask ?? realPort.advanceTask;
|
|
759
|
+
const wrappedAdvance: typeof realPort.advanceTask = async (taskRoot, taskId) => {
|
|
760
|
+
const res = (await rawAdvance(taskRoot, taskId)) as any;
|
|
761
|
+
if (res.state === "review_ready" && res.agent_params) {
|
|
762
|
+
activeReviewDispatch = {
|
|
763
|
+
operation_id: res.operation_id,
|
|
764
|
+
agent_params: res.agent_params,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return res;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const kernelPort: BatchRunnerKernelPort = {
|
|
771
|
+
...realPort,
|
|
772
|
+
...(options.batchKernel ?? {}),
|
|
773
|
+
advanceTask: wrappedAdvance,
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const report = await startBatch({
|
|
777
|
+
root,
|
|
778
|
+
batch_id: batchId,
|
|
779
|
+
initiative_slug: initiativeSlug,
|
|
780
|
+
registry: batchRegistry,
|
|
781
|
+
capability,
|
|
782
|
+
children: recoveryChildren,
|
|
783
|
+
plan_digest: planDigest,
|
|
784
|
+
base_head: isResuming ? existingBatch.base_head : baseHead,
|
|
785
|
+
confirmation_time: now,
|
|
786
|
+
authorization_expires_at: expiresAt,
|
|
787
|
+
budget,
|
|
788
|
+
now,
|
|
789
|
+
kernel: kernelPort,
|
|
790
|
+
git: options.batchGit,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
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
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
state: "started",
|
|
803
|
+
batch_id: batchId,
|
|
804
|
+
report,
|
|
805
|
+
...(activeReviewDispatch ? { review_dispatch: activeReviewDispatch } : {}),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Injectable seams for the batch Tool. Production passes nothing and gets the
|
|
811
|
+
* real Initiative reader, Kernel port and Git port; tests drive the registered
|
|
812
|
+
* Tool `execute` end to end without live GitHub or Kernel access, which is how
|
|
813
|
+
* the model-visible content contract is verified.
|
|
814
|
+
*/
|
|
815
|
+
export interface PiBatchExtensionDependencies {
|
|
816
|
+
readInitiative?: InitiativeObservationReader;
|
|
817
|
+
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
818
|
+
batchGit?: BatchRunnerGitPort;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export default function (
|
|
822
|
+
pi: ExtensionAPI,
|
|
823
|
+
dependencies: PiBatchExtensionDependencies = {},
|
|
824
|
+
) {
|
|
825
|
+
pi.registerTool({
|
|
826
|
+
name: "start_unattended_batch",
|
|
827
|
+
label: "Start unattended batch",
|
|
828
|
+
description: "Start an unattended serial batch run for an Initiative after native confirmation.",
|
|
829
|
+
promptSnippet: "Start unattended batch run: invoke once in foreground after plan confirmation.",
|
|
830
|
+
promptGuidelines: [
|
|
831
|
+
"Call only after plan confirmation; execute once in the foreground and consume the direct terminal result.",
|
|
832
|
+
"Do not run this Tool in background, poll for completion, or issue a cancel subcommand.",
|
|
833
|
+
],
|
|
834
|
+
parameters: Type.Object({
|
|
835
|
+
initiative_slug: Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }),
|
|
836
|
+
}, { additionalProperties: false }),
|
|
837
|
+
execute: async (
|
|
838
|
+
_toolCallId: string,
|
|
839
|
+
params: { initiative_slug: string },
|
|
840
|
+
signal: AbortSignal | undefined,
|
|
841
|
+
_onUpdate: unknown,
|
|
842
|
+
ctx: ExtensionContext,
|
|
843
|
+
) => {
|
|
844
|
+
const { initiative_slug: initiativeSlug } = params;
|
|
845
|
+
if (ctx.mode !== "tui") {
|
|
846
|
+
throwToolFailure({
|
|
847
|
+
tool: "imm_canary_enrollment",
|
|
848
|
+
task_id: initiativeSlug,
|
|
849
|
+
operation: "start_unattended_batch",
|
|
850
|
+
state: "blocked",
|
|
851
|
+
code: "unsupported_host",
|
|
852
|
+
message: "interactive TUI elicitation is unavailable in non-interactive mode",
|
|
853
|
+
next_action: "invoke through an interactive Pi TUI session in the current Host",
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const result = await executePiUnattendedBatch({
|
|
858
|
+
root: ctx.cwd,
|
|
859
|
+
initiativeSlug,
|
|
860
|
+
interactive: ctx.mode === "tui",
|
|
861
|
+
signal,
|
|
862
|
+
readInitiative: dependencies.readInitiative,
|
|
863
|
+
batchKernel: dependencies.batchKernel,
|
|
864
|
+
batchGit: dependencies.batchGit,
|
|
865
|
+
confirmBatch: async (details) => {
|
|
866
|
+
presentTaskRail(ctx, {
|
|
867
|
+
task_id: initiativeSlug,
|
|
868
|
+
state: "Approval required",
|
|
869
|
+
result: details.title,
|
|
870
|
+
next: "Review batch plan evidence",
|
|
871
|
+
});
|
|
872
|
+
const selected = await requestAuthorityDialog(
|
|
873
|
+
pi,
|
|
874
|
+
ctx,
|
|
875
|
+
{
|
|
876
|
+
attention_id: randomUUID(),
|
|
877
|
+
task_id: initiativeSlug,
|
|
878
|
+
reason: "enrollment",
|
|
879
|
+
label: details.title,
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
title: details.title,
|
|
883
|
+
summary: details.summary,
|
|
884
|
+
details: details.details,
|
|
885
|
+
signal: details.signal,
|
|
886
|
+
actions: [
|
|
887
|
+
{ value: "confirm", label: "Authorize batch run", description: "Start the unattended serial batch run" },
|
|
888
|
+
{ value: "decline", label: "Decline batch", description: "Reject this batch authorization; repository and authority stay unchanged" },
|
|
889
|
+
{ value: "cancel", label: "Cancel", description: "Leave repository and authority unchanged" },
|
|
890
|
+
],
|
|
891
|
+
},
|
|
892
|
+
);
|
|
893
|
+
return mapDialogSelection(selected);
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
if (result.state !== "started") {
|
|
898
|
+
const failureState = result.state === "blocked" ? "blocked" : "failed";
|
|
899
|
+
throwToolFailure({
|
|
900
|
+
tool: "imm_canary_enrollment",
|
|
901
|
+
task_id: initiativeSlug,
|
|
902
|
+
operation: "start_unattended_batch",
|
|
903
|
+
state: failureState,
|
|
904
|
+
code: `batch_${result.state}`,
|
|
905
|
+
message: result.reason,
|
|
906
|
+
next_action: result.recovery_action,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// review-f72ae870f4f0-2: the Parent consumes the model-visible content, not
|
|
911
|
+
// Tool details, so the full structured result must be serialized there
|
|
912
|
+
// (the same shape imm-canary-work.ts returns). A batch that pauses for
|
|
913
|
+
// Review exposes report.next_action plus review_dispatch, and a
|
|
914
|
+
// needs_human/failed run exposes the parked child reason; without them the
|
|
915
|
+
// Parent cannot launch the reserved foreground Review or recover.
|
|
916
|
+
return {
|
|
917
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
918
|
+
details: result,
|
|
919
|
+
};
|
|
920
|
+
},
|
|
921
|
+
renderCall(args, theme) {
|
|
922
|
+
const params = args as { initiative_slug?: string };
|
|
923
|
+
return renderStructuredCall("start_unattended_batch", "start", params.initiative_slug, theme);
|
|
924
|
+
},
|
|
925
|
+
renderResult(result, _options, theme) {
|
|
926
|
+
return renderStructuredResult(
|
|
927
|
+
result as Parameters<typeof renderStructuredResult>[0],
|
|
928
|
+
theme,
|
|
929
|
+
);
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
}
|