@hcflabs/pi-subagent 0.0.0-stage → 27.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CONTEXT.md +77 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -2
  4. package/dist/composition.d.ts +11 -0
  5. package/dist/composition.js +104 -0
  6. package/dist/direct-herdr.d.ts +71 -0
  7. package/dist/direct-herdr.js +527 -0
  8. package/dist/discard.d.ts +51 -0
  9. package/dist/discard.js +218 -0
  10. package/dist/display-text.d.ts +5 -0
  11. package/dist/display-text.js +9 -0
  12. package/dist/ephemeral.d.ts +79 -0
  13. package/dist/ephemeral.js +916 -0
  14. package/dist/git-process.d.ts +8 -0
  15. package/dist/git-process.js +25 -0
  16. package/dist/git-runtime.d.ts +115 -0
  17. package/dist/git-runtime.js +489 -0
  18. package/dist/herdr-runtime.d.ts +147 -0
  19. package/dist/herdr-runtime.js +1366 -0
  20. package/dist/index.d.ts +170 -0
  21. package/dist/index.js +587 -0
  22. package/dist/integration-git.d.ts +63 -0
  23. package/dist/integration-git.js +504 -0
  24. package/dist/launch-runtime.d.ts +54 -0
  25. package/dist/launch-runtime.js +178 -0
  26. package/dist/mcp-role.d.ts +12 -0
  27. package/dist/mcp-role.js +152 -0
  28. package/dist/process-lease.d.ts +5 -0
  29. package/dist/process-lease.js +43 -0
  30. package/dist/process.d.ts +9 -0
  31. package/dist/process.js +11 -0
  32. package/dist/prune.d.ts +78 -0
  33. package/dist/prune.js +205 -0
  34. package/dist/review-evidence.d.ts +17 -0
  35. package/dist/review-evidence.js +221 -0
  36. package/dist/runner.d.ts +431 -0
  37. package/dist/runner.js +3380 -0
  38. package/dist/schema.d.ts +2560 -0
  39. package/dist/schema.js +1365 -0
  40. package/dist/store.d.ts +66 -0
  41. package/dist/store.js +394 -0
  42. package/dist/worktree.d.ts +65 -0
  43. package/dist/worktree.js +288 -0
  44. package/docs/orchestration-architecture.html +138 -0
  45. package/docs/orchestration-architecture.svg +135 -0
  46. package/docs/orchestration.md +577 -0
  47. package/docs/worker-lifecycle.html +97 -0
  48. package/docs/worker-lifecycle.svg +86 -0
  49. package/example.png +0 -0
  50. package/examples/roles/implementer.md +25 -0
  51. package/examples/roles/reviewer.md +25 -0
  52. package/examples/roles/scout.md +25 -0
  53. package/extensions/admission.ts +82 -0
  54. package/extensions/config.ts +19 -0
  55. package/extensions/git-read.ts +126 -0
  56. package/extensions/isolated.ts +945 -0
  57. package/extensions/model-class-policy.ts +1 -0
  58. package/extensions/result-transport.ts +236 -0
  59. package/extensions/role-mcp.ts +14 -0
  60. package/extensions/role-tools.ts +178 -0
  61. package/extensions/subagent-command.ts +699 -0
  62. package/extensions/subagent.ts +958 -0
  63. package/extensions/workflow.ts +336 -0
  64. package/package.json +84 -4
  65. package/prompts/review.md +20 -0
  66. package/skills/pi-subagent/SKILL.md +41 -0
package/dist/runner.js ADDED
@@ -0,0 +1,3380 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { inspectDiscard, primaryCheckout, removeDiscardResources } from "./discard.js";
4
+ import { utf8Prefix } from "./ephemeral.js";
5
+ import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
6
+ import { runGit } from "./git-process.js";
7
+ import { IntegrationGit } from "./integration-git.js";
8
+ import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, TERMINAL_RUN_STATUSES, } from "./schema.js";
9
+ import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
10
+ const TRUNCATION_MARKER = "\n[truncated]";
11
+ const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
12
+ const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
13
+ const CORRECTION_DELIVERY_RESERVE_BYTES = 1024;
14
+ const MAX_WORKER_PROMPTS = 32;
15
+ const MAX_QUEUED_FOLLOWUPS = 16;
16
+ /** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
17
+ export const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
18
+ export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
19
+ export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
20
+ export const STATUS_INSPECTION_BUDGET_MS = 5_000;
21
+ const ALLOCATION_KINDS = ["worktree", "workspace", "worker_tab", "agent"];
22
+ const FULL_SUITE = { command: "pnpm", args: ["test"] };
23
+ const MAX_RETAINED_INTEGRATION_GENERATIONS = 2;
24
+ function requireRootSuite(root) {
25
+ let manifest;
26
+ try {
27
+ manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
28
+ }
29
+ catch {
30
+ throw new Error("Canonical root package.json with a test script is required for isolated changesets.");
31
+ }
32
+ if (!manifest || typeof manifest !== "object" || !('scripts' in manifest)
33
+ || !manifest.scripts || typeof manifest.scripts !== "object"
34
+ || !('test' in manifest.scripts) || typeof manifest.scripts.test !== "string"
35
+ || !manifest.scripts.test.trim())
36
+ throw new Error("Canonical root test script is missing.");
37
+ return manifest.scripts.test;
38
+ }
39
+ /** Run one launch action and surface cleanup failure, including after action failure or abort. */
40
+ export async function withTransientLaunch(handle, operation) {
41
+ let failed = false;
42
+ let operationError;
43
+ try {
44
+ return await operation(handle.launch);
45
+ }
46
+ catch (error) {
47
+ failed = true;
48
+ operationError = error;
49
+ throw error;
50
+ }
51
+ finally {
52
+ try {
53
+ await handle.cleanup();
54
+ }
55
+ catch (cleanupError) {
56
+ if (failed) {
57
+ throw new AggregateError([operationError, cleanupError], "Transient Role launch failed and its prompt cleanup also failed.");
58
+ }
59
+ throw cleanupError;
60
+ }
61
+ }
62
+ }
63
+ class DeadlineExpired extends Error {
64
+ constructor() {
65
+ super("The safety operation deadline is exhausted.");
66
+ this.name = "DeadlineExpired";
67
+ }
68
+ }
69
+ class DurableRunStopped extends Error {
70
+ state;
71
+ constructor(state) {
72
+ super(`Pi Subagent request ${state.request.id} became terminal (${state.status}).`);
73
+ this.name = "DurableRunStopped";
74
+ this.state = state;
75
+ }
76
+ }
77
+ class ProductiveScope {
78
+ signal;
79
+ constructor(outerSignal) {
80
+ this.signal = outerSignal ?? new AbortController().signal;
81
+ }
82
+ async call(operation) {
83
+ this.signal.throwIfAborted();
84
+ const result = await operation({ signal: this.signal });
85
+ this.signal.throwIfAborted();
86
+ return result;
87
+ }
88
+ }
89
+ class DeadlineScope {
90
+ deadline;
91
+ signal;
92
+ now;
93
+ controller = new AbortController();
94
+ timer;
95
+ constructor(deadline, now, outerSignal) {
96
+ this.deadline = deadline;
97
+ this.now = now;
98
+ this.signal = outerSignal ? AbortSignal.any([this.controller.signal, outerSignal]) : this.controller.signal;
99
+ const remaining = Math.max(0, deadline - now());
100
+ this.timer = setTimeout(() => this.controller.abort(new DeadlineExpired()), remaining);
101
+ this.timer.unref();
102
+ }
103
+ async call(operation) {
104
+ this.throwIfExpired();
105
+ const result = await operation({ signal: this.signal, timeoutMs: this.remaining(), deadline: this.deadline });
106
+ this.throwIfExpired();
107
+ return result;
108
+ }
109
+ close() {
110
+ clearTimeout(this.timer);
111
+ }
112
+ remaining() {
113
+ return Math.max(0, this.deadline - this.now());
114
+ }
115
+ throwIfExpired() {
116
+ if (this.remaining() <= 0)
117
+ throw new DeadlineExpired();
118
+ this.signal.throwIfAborted();
119
+ }
120
+ }
121
+ class FollowupControl {
122
+ queue = [];
123
+ validateFollowup;
124
+ sealed = false;
125
+ constructor(validateFollowup) {
126
+ this.validateFollowup = validateFollowup;
127
+ }
128
+ get pendingFollowups() {
129
+ return this.queue.length;
130
+ }
131
+ get isSealed() {
132
+ return this.sealed;
133
+ }
134
+ followup(instruction) {
135
+ if (this.sealed)
136
+ throw new Error("This task candidate is already sealed for integration.");
137
+ if (this.queue.length >= MAX_QUEUED_FOLLOWUPS)
138
+ throw new Error(`A task may queue at most ${MAX_QUEUED_FOLLOWUPS} follow-ups.`);
139
+ this.validateFollowup(instruction);
140
+ this.queue.push(instruction);
141
+ }
142
+ drainPending() {
143
+ if (this.sealed)
144
+ throw new Error("This task candidate is already sealed for integration.");
145
+ return this.queue.splice(0);
146
+ }
147
+ takeQueuedFollowup() {
148
+ if (this.sealed)
149
+ throw new Error("This task candidate is already sealed for integration.");
150
+ return this.queue.shift();
151
+ }
152
+ /** Atomically take the next admitted revision or seal the candidate. */
153
+ takeQueuedFollowupOrSeal() {
154
+ const queued = this.takeQueuedFollowup();
155
+ if (queued)
156
+ return queued;
157
+ this.sealed = true;
158
+ return undefined;
159
+ }
160
+ close() {
161
+ this.sealed = true;
162
+ this.queue.length = 0;
163
+ }
164
+ }
165
+ function bounded(value, maxBytes = MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
166
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
167
+ return value;
168
+ return `${utf8Prefix(value, maxBytes - Buffer.byteLength(TRUNCATION_MARKER, "utf8"))}${TRUNCATION_MARKER}`;
169
+ }
170
+ function boundedRuntimeText(value, field) {
171
+ if (typeof value !== "string")
172
+ throw new Error(`${field} must be text.`);
173
+ return bounded(value);
174
+ }
175
+ function boundedRuntimeDiagnostic(value, field) {
176
+ if (typeof value !== "string")
177
+ throw new Error(`${field} must be text.`);
178
+ const normalized = value.trim();
179
+ if (!normalized || normalized.includes("\0"))
180
+ throw new Error(`${field} must be non-empty text without NUL.`);
181
+ return bounded(normalized);
182
+ }
183
+ function boundedPossibleResources(values) {
184
+ if (values === undefined)
185
+ return;
186
+ if (!Array.isArray(values))
187
+ return ["Runtime returned an invalid possible-resource list; cleanup remains blocked."];
188
+ if (!values.length)
189
+ return;
190
+ const retained = values.slice(0, MAX_POSSIBLE_RESOURCES).map((value, index) => {
191
+ if (typeof value !== "string")
192
+ return `Runtime returned invalid possible-resource evidence at index ${index}.`;
193
+ const item = bounded(value);
194
+ return item.trim() && !item.includes("\0")
195
+ ? item
196
+ : `Runtime returned invalid possible-resource evidence at index ${index}.`;
197
+ });
198
+ if (values.length > MAX_POSSIBLE_RESOURCES) {
199
+ retained[MAX_POSSIBLE_RESOURCES - 1] = `${values.length - MAX_POSSIBLE_RESOURCES + 1} additional possible resources were omitted; cleanup remains blocked.`;
200
+ }
201
+ return retained;
202
+ }
203
+ function errorText(error) {
204
+ return bounded(error instanceof Error ? error.message : String(error));
205
+ }
206
+ function requireExactAllocationText(value, field) {
207
+ if (typeof value !== "string" || !value.trim() || value.trim() !== value || value.includes("\0")
208
+ || Buffer.byteLength(value, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
209
+ throw new Error(`${field} must be bounded exact non-empty text.`);
210
+ }
211
+ }
212
+ function runtimeIdentity(value, field) {
213
+ if (!value || typeof value !== "object" || Array.isArray(value))
214
+ throw new Error(`${field} must be an exact identity.`);
215
+ const identity = value;
216
+ requireExactAllocationText(identity.branch, `${field} branch`);
217
+ for (const [name, oid] of Object.entries({ head: identity.head, index: identity.index, tree: identity.tree })) {
218
+ if (typeof oid !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) {
219
+ throw new Error(`${field} ${name} is not an exact object ID.`);
220
+ }
221
+ }
222
+ return { branch: identity.branch, head: identity.head, index: identity.index, tree: identity.tree };
223
+ }
224
+ function runtimeToken(value) {
225
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) {
226
+ throw new Error("Runtime correlation token must match the strict token format.");
227
+ }
228
+ return value;
229
+ }
230
+ function runtimeHostAllocationPlan(plan, kind) {
231
+ if (!plan || typeof plan !== "object" || plan.kind !== kind)
232
+ throw new Error(`${kind} allocation planning returned the wrong kind.`);
233
+ const fields = kind === "workspace"
234
+ ? ["label", "worktreeCwd", "mainRoot", "repoKey", "herdrRepoRoot"]
235
+ : kind === "worker_tab"
236
+ ? ["label", "workspaceId", "workspaceRootTabId", "workspaceRootPaneId", "worktreeCwd", "leasePath"]
237
+ : ["agentName", "workspaceId", "tabId", "paneId", "worktreeCwd", "leasePath"];
238
+ const validated = {};
239
+ for (const field of fields) {
240
+ const value = plan[field];
241
+ requireExactAllocationText(value, `${kind} allocation plan ${field}`);
242
+ validated[field] = value;
243
+ }
244
+ return { kind, ...validated };
245
+ }
246
+ function taskRequest(state, id) {
247
+ const task = state.request.tasks.find((candidate) => candidate.id === id);
248
+ if (!task)
249
+ throw new Error(`Unknown task ID: ${id}.`);
250
+ return task;
251
+ }
252
+ function changesetTaskRequest(state, id) {
253
+ const task = taskRequest(state, id);
254
+ if (task.kind !== "changeset")
255
+ throw new Error(`Task ${id} is not a changeset task.`);
256
+ return task;
257
+ }
258
+ function taskState(state, id) {
259
+ const task = state.tasks.find((candidate) => candidate.taskId === id);
260
+ if (!task)
261
+ throw new Error(`Unknown task ID: ${id}.`);
262
+ return task;
263
+ }
264
+ function taskDependenciesCompleted(state, taskId) {
265
+ const request = taskRequest(state, taskId);
266
+ const generation = state.integration.generations.at(-1);
267
+ return request.dependsOn.every((source) => {
268
+ const predecessor = taskState(state, source);
269
+ return predecessor.kind === "text" ? predecessor.status === "completed"
270
+ : generation?.status !== "superseded" && generation?.stages.some((stage) => stage.taskId === source && stage.status === "staged" && predecessor.status === "ready_to_integrate") === true;
271
+ }) && request.contextFrom.every((source) => taskState(state, source).status === "completed");
272
+ }
273
+ /** Context edges carry only prompt text, never the source checkout. */
274
+ function hasChangesetDependency(state, taskId) {
275
+ const request = taskRequest(state, taskId);
276
+ return [...request.dependsOn, ...request.contextFrom].some((source) => taskState(state, source).kind === "changeset" || hasChangesetDependency(state, source));
277
+ }
278
+ /** Include prompt-context consumers when an invalidated text output fed later work. */
279
+ function affectedDependents(state, taskId) {
280
+ const affected = new Set([taskId]);
281
+ let changed = true;
282
+ while (changed) {
283
+ changed = false;
284
+ for (const definition of state.request.tasks) {
285
+ if (!affected.has(definition.id) && [...definition.dependsOn, ...definition.contextFrom].some((source) => affected.has(source))) {
286
+ affected.add(definition.id);
287
+ changed = true;
288
+ }
289
+ }
290
+ }
291
+ return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0
292
+ // Refresh or an earlier rejection already invalidated this attempt; it needs no second invalidation.
293
+ && !(task.status === "pending" && (task.kind === "changeset" ? task.attempts.at(-1).superseded
294
+ : task.attempts.at(-1).status === "superseded")));
295
+ }
296
+ /** Freezes a generation as read-only. The correction stays recorded because it names the checkout's exact tip. */
297
+ function supersedeGeneration(generation, failure) {
298
+ if (generation.status === "promotion_failed") {
299
+ generation.supersededPromotion = generation.promotion;
300
+ delete generation.promotion;
301
+ }
302
+ generation.supersededFrom = generation.status;
303
+ generation.status = "superseded";
304
+ generation.failure = failure;
305
+ delete generation.combinedTip;
306
+ delete generation.checks;
307
+ delete generation.review;
308
+ }
309
+ /** The exact tip a retained integration checkout holds: its correction, last staged merge, or base. */
310
+ function retainedGenerationTip(generation) {
311
+ return generation.correction?.to ?? generation.stages.at(-1)?.tip ?? generation.integrationBase;
312
+ }
313
+ /** Every changeset predecessor must be present in this exact committed snapshot. */
314
+ function stagedDependencySnapshot(state, taskId, tip) {
315
+ const generation = state.integration.generations.at(-1);
316
+ const request = taskRequest(state, taskId);
317
+ return request.dependsOn.every((source) => {
318
+ if (taskState(state, source).kind === "text")
319
+ return true;
320
+ return Boolean(generation && generation.status !== "superseded" && generation.combinedTip
321
+ && sameIdentity(generation.combinedTip, tip) && generation.stages.some((stage) => stage.taskId === source && stage.status === "staged"));
322
+ });
323
+ }
324
+ function textRetryEligible(state, task) {
325
+ const attempt = task.attempts.at(-1);
326
+ return task.status === "needs_attention"
327
+ && (!attempt || attempt.status === "failed" || attempt.status === "superseded")
328
+ && task.attempts.length < 2
329
+ && taskDependenciesCompleted(state, task.taskId);
330
+ }
331
+ function hasPassingPreliminaryEvidence(request, attempt) {
332
+ return Boolean(attempt.candidate && attempt.candidateBase
333
+ && checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)
334
+ && (!request.judgment || reviewEvidencePasses(attempt.preliminaryReview, "preliminary", request.judgment.criterion, attempt.candidateBase, attempt.candidate))
335
+ && !attempt.prompts.some((prompt) => prompt.status === "ambiguous")
336
+ && allocationByKind(attempt, "agent")?.agentName && !attempt.termination);
337
+ }
338
+ function changesetTaskState(state, id) {
339
+ const task = taskState(state, id);
340
+ if (task.kind !== "changeset")
341
+ throw new Error(`Task ${id} is not a changeset task.`);
342
+ return task;
343
+ }
344
+ function latestAttempt(task) {
345
+ const attempt = task.attempts.at(-1);
346
+ if (!attempt)
347
+ throw new Error(`Task ${task.taskId} has no recorded attempt.`);
348
+ return attempt;
349
+ }
350
+ function nextAttemptEventAt(attempt, now) {
351
+ const latest = Math.max(0, ...attempt.prompts.map(({ at }) => at), attempt.readiness?.at ?? 0);
352
+ return Math.max(now, latest + 1);
353
+ }
354
+ function allocationByKind(attempt, kind) {
355
+ return [...attempt.allocations].reverse().find((intent) => intent.kind === kind && intent.status === "owned");
356
+ }
357
+ function applyOwnedAllocationResult(intent, result) {
358
+ if (result.kind !== intent.kind)
359
+ throw new Error(`${intent.kind} allocation returned the wrong result kind.`);
360
+ if (result.outcome !== "owned")
361
+ throw new Error(`${intent.kind} allocation did not return an owned result.`);
362
+ if (intent.kind === "worktree" && result.kind === "worktree") {
363
+ if (!intent.worktree)
364
+ throw new Error("Worktree allocation returned without exact persisted plan fields.");
365
+ }
366
+ else if (intent.kind === "workspace" && result.kind === "workspace") {
367
+ for (const [field, value] of Object.entries({
368
+ workspaceId: result.workspaceId, rootTabId: result.rootTabId, rootPaneId: result.rootPaneId,
369
+ }))
370
+ requireExactAllocationText(value, `Workspace allocation ${field}`);
371
+ intent.workspaceId = result.workspaceId;
372
+ intent.rootTabId = result.rootTabId;
373
+ intent.rootPaneId = result.rootPaneId;
374
+ }
375
+ else if (intent.kind === "worker_tab" && result.kind === "worker_tab") {
376
+ requireExactAllocationText(result.tabId, "Worker-tab allocation tabId");
377
+ requireExactAllocationText(result.paneId, "Worker-tab allocation paneId");
378
+ intent.tabId = result.tabId;
379
+ intent.paneId = result.paneId;
380
+ }
381
+ else if (intent.kind !== "agent" || result.kind !== "agent") {
382
+ throw new Error(`${intent.kind} allocation result could not be matched to its persisted plan.`);
383
+ }
384
+ intent.status = "owned";
385
+ }
386
+ function exactCommandResults(results, checks) {
387
+ return results.length === checks.length && results.every((result, index) => {
388
+ const check = checks[index];
389
+ return result.command === check.command
390
+ && result.args.length === check.args.length
391
+ && result.args.every((arg, argIndex) => arg === check.args[argIndex]);
392
+ });
393
+ }
394
+ function reusedCheckLines(checks) {
395
+ return (checks?.results ?? []).flatMap((result) => result.reusedFrom
396
+ ? [`Combined check ${formatCheckCommand(result)} reused the passed preliminary result of ${result.reusedFrom.taskId}#${result.reusedFrom.attempt}@${result.reusedFrom.candidate.head} on the same tree; it did not run again.`]
397
+ : []);
398
+ }
399
+ function isCommitNudge(prompt) {
400
+ return prompt.kind === "followup" && prompt.instruction === COMMIT_NUDGE_INSTRUCTION;
401
+ }
402
+ function correctionEligible(request, attempt) {
403
+ const initial = attempt.prompts[0];
404
+ if (initial?.kind !== "initial" || attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
405
+ || attempt.termination)
406
+ return false;
407
+ const latestPrompt = attempt.prompts.at(-1);
408
+ if (latestPrompt.status !== "settled")
409
+ return false;
410
+ // A commit nudge continues its turn; it never consumes the correction window.
411
+ const assignments = attempt.prompts.filter((prompt) => !isCommitNudge(prompt)).length;
412
+ if (!attempt.candidate) {
413
+ return assignments === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
414
+ }
415
+ const checks = attempt.preliminaryChecks;
416
+ return assignments === 1 && Boolean(checks
417
+ && checks.phase === "preliminary"
418
+ && exactCommandResults(checks.results, request.checks)
419
+ && sameIdentity(checks.candidate, attempt.candidate)
420
+ && sameIdentity(checks.identityAfter, attempt.candidate)
421
+ && (!checks.passed && checks.results.some((result) => result.code !== 0 || result.killed)
422
+ || checks.passed && request.judgment && attempt.preliminaryReview?.phase === "preliminary"
423
+ && !attempt.preliminaryReview.passed && attempt.preliminaryReview.verdict !== "PASS"
424
+ && attempt.preliminaryReview.criterion === request.judgment.criterion
425
+ && sameIdentity(attempt.preliminaryReview.base, attempt.candidateBase)
426
+ && sameIdentity(attempt.preliminaryReview.tip, attempt.candidate)
427
+ && sameIdentity(attempt.preliminaryReview.identityAfter, attempt.candidate)));
428
+ }
429
+ function terminal(state) {
430
+ return TERMINAL_RUN_STATUSES.some((status) => status === state.status);
431
+ }
432
+ function inventoryStatus(state, orphaned = false) {
433
+ if (orphaned && ["pending", "running"].includes(state.status))
434
+ return `saved ${state.status} · unverified`;
435
+ const retained = state.tasks.some((task) => task.kind === "changeset"
436
+ ? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
437
+ : task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
438
+ || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
439
+ || (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true));
440
+ return terminal(state) && retained ? `${state.status} · retained` : state.status;
441
+ }
442
+ /** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
443
+ export function buildChangesetTaskPrompt(input) {
444
+ if (input.kind === "correction"
445
+ && (typeof input.failure !== "string" || !input.failure.trim() || input.failure.trim() !== input.failure || input.failure.includes("\0"))) {
446
+ throw new Error("correction failure must be a non-empty exact string.");
447
+ }
448
+ if ((input.kind === "followup" || input.instruction !== undefined)
449
+ && (typeof input.instruction !== "string" || !input.instruction.trim() || input.instruction.trim() !== input.instruction
450
+ || input.instruction.includes("\0") || input.instruction.length > 32_000)) {
451
+ throw new Error(`${input.kind === "followup" ? "follow-up" : "correction"} instruction must be non-empty exact text of at most 32000 characters.`);
452
+ }
453
+ const checks = input.task.checks.map((check) => JSON.stringify({ command: check.command, args: check.args })).join("\n");
454
+ const upstreamTaskData = input.contexts.length
455
+ ? ["", "Upstream task data:", formatTextTaskContexts(input.contexts, CHANGESET_TASK_PROMPT_MAX_BYTES)]
456
+ : [];
457
+ const text = input.kind === "followup" ? [
458
+ `Task: ${input.task.id}`,
459
+ `Worktree: ${input.worktreeCwd}`,
460
+ "",
461
+ "Follow-up:",
462
+ input.instruction,
463
+ "",
464
+ "Required checks (direct command/argv):",
465
+ checks,
466
+ "",
467
+ "Continue the same task in the exact worktree above. Commit the complete revision and leave that worktree clean.",
468
+ ].join("\n") : [
469
+ `Task: ${input.task.id}`,
470
+ "Goal:",
471
+ input.goal,
472
+ `Worktree: ${input.worktreeCwd}`,
473
+ `Integrated dependencies: ${input.task.dependsOn.length ? input.task.dependsOn.join(", ") : "none"}`,
474
+ "",
475
+ "Requirements:",
476
+ input.task.requirements,
477
+ "",
478
+ "Deliverable:",
479
+ input.task.deliverable,
480
+ ...upstreamTaskData,
481
+ "",
482
+ "Required checks (direct command/argv):",
483
+ checks,
484
+ ...(input.kind === "correction" ? ["", "Correction failure:", input.failure] : []),
485
+ ...(input.kind === "correction" && input.instruction ? ["", "Main's instruction:", input.instruction] : []),
486
+ "",
487
+ "Work only in the exact worktree above. Commit the complete result and leave that worktree clean.",
488
+ ].join("\n");
489
+ if (Buffer.byteLength(text, "utf8") > CHANGESET_TASK_PROMPT_MAX_BYTES) {
490
+ throw new Error(`Worker assignment exceeds ${CHANGESET_TASK_PROMPT_MAX_BYTES} bytes.`);
491
+ }
492
+ return text;
493
+ }
494
+ /** Resolve completed text outputs in the consumer's declared context order. */
495
+ export function resolveTextTaskContexts(state, task) {
496
+ return task.contextFrom.map((taskId) => {
497
+ const source = state.tasks.find((candidate) => candidate.taskId === taskId);
498
+ if (!source)
499
+ throw new Error(`Context source ${taskId} for task ${task.id} is absent.`);
500
+ if (source.kind !== "text")
501
+ throw new Error(`Context source ${taskId} for task ${task.id} is not a text task.`);
502
+ const attempt = source.attempts.at(-1);
503
+ if (source.status !== "completed" || attempt?.status !== "completed") {
504
+ throw new Error(`Context source ${taskId} for task ${task.id} is incomplete.`);
505
+ }
506
+ if (!attempt.output)
507
+ throw new Error(`Context source ${taskId} for task ${task.id} is missing output.`);
508
+ return { taskId, text: attempt.output.text };
509
+ });
510
+ }
511
+ /** Format complete context blocks or fail; prompt data is never truncated. */
512
+ export function formatTextTaskContexts(contexts, maxBytes) {
513
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
514
+ throw new Error("Text task context byte cap must be a non-negative safe integer.");
515
+ }
516
+ const text = contexts.map(({ taskId, text: output }) => `Context from task ${taskId}:\n${output}`).join("\n\n");
517
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
518
+ throw new Error(`Text task context exceeds ${maxBytes} UTF-8 bytes.`);
519
+ }
520
+ return text;
521
+ }
522
+ /** Build a bounded text-task assignment with upstream output marked as task data. */
523
+ export function buildTextTaskPrompt(goal, task, contexts) {
524
+ const assignment = [
525
+ `Task: ${task.id}`,
526
+ "Goal:",
527
+ goal,
528
+ "",
529
+ "Requirements:",
530
+ task.requirements,
531
+ "",
532
+ "Deliverable:",
533
+ task.deliverable,
534
+ ].join("\n");
535
+ const prefix = contexts.length ? `${assignment}\n\nTask data:\n` : assignment;
536
+ const remainingBytes = TEXT_TASK_PROMPT_MAX_BYTES - Buffer.byteLength(prefix, "utf8");
537
+ if (remainingBytes < 0)
538
+ throw new Error(`Text task prompt exceeds ${TEXT_TASK_PROMPT_MAX_BYTES} UTF-8 bytes.`);
539
+ return `${prefix}${formatTextTaskContexts(contexts, remainingBytes)}`;
540
+ }
541
+ export function readyPendingTasks(state) {
542
+ return state.tasks.filter((task) => task.status === "pending" && taskDependenciesCompleted(state, task.taskId));
543
+ }
544
+ const ADVANCE_HINT = "Main may call subagent_integrate advance with this generation and its exact staged combinedTip to launch ready dependents.";
545
+ const RUNNING_TASK_STATUSES = ["running", "allocating", "working", "integrating", "cleanup"];
546
+ function activeGeneration(state) {
547
+ const generation = state.integration.generations.at(-1);
548
+ return generation?.status === "superseded" ? undefined : generation;
549
+ }
550
+ /** Undecided retained candidates that Main can still stage into the latest open generation. */
551
+ function stageableCandidates(state) {
552
+ const generation = activeGeneration(state);
553
+ if (generation && generation.status !== "staging")
554
+ return [];
555
+ return state.integration.candidates.filter((candidate) => !candidate.decision && candidate.worker === "retained"
556
+ && !generation?.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt));
557
+ }
558
+ function advanceReady(state) {
559
+ const generation = state.integration.generations.at(-1);
560
+ return generation?.status === "staging" && readyPendingTasks(state).some((task) => generation.combinedTip
561
+ && hasChangesetDependency(state, task.taskId) && stagedDependencySnapshot(state, task.taskId, generation.combinedTip));
562
+ }
563
+ /** Main's next integration step while the request waits only for Main; undefined when a task or the final gate needs attention. */
564
+ export function nextIntegrationAction(state) {
565
+ if (state.status !== "needs_attention" || state.final.failure
566
+ || state.tasks.some((task) => task.status === "needs_attention"))
567
+ return undefined;
568
+ const stageable = stageableCandidates(state);
569
+ if (stageable.length) {
570
+ return `Stage ${stageable.map((item) => `${item.taskId}#${item.attempt}`).join(", ")} with subagent_stage using exact status identities; Main remains unchanged.`;
571
+ }
572
+ const generation = activeGeneration(state);
573
+ if (!generation)
574
+ return undefined;
575
+ const exact = `generation ${generation.number} and expectedTip ${JSON.stringify(generation.combinedTip)}`;
576
+ const worktree = generation.worktree?.path ?? "allocation not proven";
577
+ switch (generation.status) {
578
+ case "staging":
579
+ if (!generation.combinedTip || generation.stages.some((stage) => stage.status !== "staged"))
580
+ return undefined;
581
+ if (advanceReady(state))
582
+ return ADVANCE_HINT;
583
+ if (state.tasks.some((task) => RUNNING_TASK_STATUSES.includes(task.status)))
584
+ return undefined;
585
+ return `Every candidate is staged; run subagent_integrate validate with ${exact}.`;
586
+ case "validating":
587
+ return `Combined validation of generation ${generation.number} is running; wait for its follow-up.`;
588
+ case "ready":
589
+ return `Combined validation passed; promote with subagent_integrate promote using ${exact}.`;
590
+ case "conflict": {
591
+ const stage = generation.stages.at(-1);
592
+ return `Resolve and commit the merge of ${stage.taskId}#${stage.attempt} in the retained integration worktree ${worktree}, then call subagent_stage resolve with generation ${generation.number}, its exact candidate, and expectedTip ${JSON.stringify(stage.onto)}.`;
593
+ }
594
+ case "validation_failed":
595
+ if (generation.checks && !generation.correction && (!generation.checks.passed || generation.review?.passed === false)) {
596
+ return `Fix and commit the combined failure in the retained integration worktree ${worktree}, then call subagent_integrate correct with ${exact}.`;
597
+ }
598
+ if (!generation.checks && !generation.review && !generation.correction && generation.combinedTip) {
599
+ return `Call subagent_integrate refresh with generation ${generation.number + 1}, expectedTip ${JSON.stringify(generation.combinedTip)}, and identical expectedMain and newMain; then restage the candidates and validate again.`;
600
+ }
601
+ return undefined;
602
+ case "promoted":
603
+ return `Promotion is proven, but cleanup retained resources; resolve the recorded cleanup failures without discarding work, then call subagent_integrate cleanup with ${exact}.`;
604
+ default:
605
+ return undefined;
606
+ }
607
+ }
608
+ function isDeadline(error, scope) {
609
+ return error instanceof DeadlineExpired || scope.signal.reason instanceof DeadlineExpired;
610
+ }
611
+ export class IsolatedRunner {
612
+ coordinatorRuntime;
613
+ hostRuntime;
614
+ gitRuntime;
615
+ store;
616
+ integrationGit;
617
+ textExecutor;
618
+ currentPolicy;
619
+ productiveLifecycles = new Map();
620
+ followupControls = new Map();
621
+ constructor(coordinatorRuntime, hostRuntime, gitRuntime, store = new FileRunStore(), textExecutor, policy = {
622
+ maxSubagents: 5, maxTurns: 50, childIdleMs: 600_000, childMaxMs: 1_800_000,
623
+ maxCorrections: 1,
624
+ }, currentPolicy = () => policy, integrationGit = new IntegrationGit()) {
625
+ this.coordinatorRuntime = coordinatorRuntime;
626
+ this.hostRuntime = hostRuntime;
627
+ this.gitRuntime = gitRuntime;
628
+ this.store = store;
629
+ this.integrationGit = integrationGit;
630
+ this.textExecutor = textExecutor;
631
+ this.currentPolicy = currentPolicy;
632
+ }
633
+ async listRequests(root) {
634
+ const { states: entries, invalidIds } = await this.store.list(root);
635
+ return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state, path }) => ({
636
+ id: state.request.id, name: state.request.goal, directory: dirname(path), root: state.root,
637
+ status: inventoryStatus(state),
638
+ tasks: state.request.tasks.map((task) => ({
639
+ id: task.id, name: task.requirements, kind: task.kind,
640
+ status: state.tasks.find((item) => item.taskId === task.id).status,
641
+ })),
642
+ })) };
643
+ }
644
+ /** True while a live productive run holds this root's lease; the lease does not name its request. */
645
+ async hasProductiveRunLease(root) {
646
+ return await this.store.hasProductiveRunLease(root);
647
+ }
648
+ /** Classify orphaned work under the same repository lease as productive execution.
649
+ * Status remains read-only; Main alone chooses any subsequent action. */
650
+ async recoverRepository(root) {
651
+ root = realpathSync.native(root);
652
+ let leaseBusy = false;
653
+ let inventory;
654
+ try {
655
+ inventory = await this.withProductiveRun(root, async (lifecycle) => await this.store.withLock(root, async () => {
656
+ const listed = await this.listRequests(root);
657
+ for (const request of listed.requests) {
658
+ if (!["pending", "running", "needs_attention"].includes(request.status))
659
+ continue;
660
+ const handle = await this.store.load(root, request.id);
661
+ if (this.recoverInterrupted(handle.state))
662
+ await handle.save();
663
+ }
664
+ return listed;
665
+ }, { productiveRunLease: lifecycle.lease }));
666
+ }
667
+ catch (error) {
668
+ if (!(error instanceof ProductiveRunLeaseBusyError))
669
+ throw error;
670
+ leaseBusy = true;
671
+ inventory = await this.listRequests(root);
672
+ }
673
+ const requests = [];
674
+ for (const request of inventory.requests) {
675
+ if (["pending", "running", "needs_attention"].includes(request.status) || request.status.endsWith(" · retained")) {
676
+ requests.push(await this.status(request.id, root));
677
+ }
678
+ }
679
+ return { requests, invalidIds: inventory.invalidIds, leaseBusy };
680
+ }
681
+ /** Prune evidence across checkouts, primary first, and orphaned requests whose parent checkout was removed.
682
+ * A non-terminal state owns every resource name its saved text mentions; an unreadable state owns every
683
+ * name its raw text mentions, or every name when unreadable. */
684
+ async pruneOwnership(checkouts) {
685
+ const owners = [];
686
+ let lease;
687
+ const request = ({ state, path }, orphaned) => ({ id: state.request.id, name: state.request.goal,
688
+ status: inventoryStatus(state, orphaned), directory: dirname(path), root: state.root, orphaned });
689
+ const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
690
+ for (const handle of orphans) {
691
+ const { state } = handle;
692
+ if (!terminal(state))
693
+ owners.push({ label: `owned by orphaned ${state.status} request ${state.request.id}; close and delete it from /subagent`, text: JSON.stringify(state), request: request(handle, true) });
694
+ }
695
+ for (const checkout of checkouts) {
696
+ const root = realpathSync.native(checkout);
697
+ if (!lease && await this.store.hasProductiveRunLease(root))
698
+ lease = `productive lease is held by a live run in ${root}`;
699
+ const { states, invalidIds } = await this.store.list(root);
700
+ for (const handle of states) {
701
+ const { state } = handle;
702
+ if (!terminal(state))
703
+ owners.push({ label: `owned by ${state.status} request ${state.request.id}`, text: JSON.stringify(state), request: request(handle, false) });
704
+ }
705
+ for (const id of invalidIds) {
706
+ let text;
707
+ try {
708
+ text = await this.store.readRaw(root, id);
709
+ }
710
+ catch { /* It may own any name. */ }
711
+ owners.push({ label: `unreadable state ${id} in ${root} may own it`, text });
712
+ }
713
+ }
714
+ return { ...(lease ? { lease } : {}), owners: (name) => owners.filter(({ text }) => text === undefined || text.includes(name))
715
+ .map(({ label, request }) => ({ label, ...(request ? { request } : {}) })) };
716
+ }
717
+ /** After pruning, reconcile aborted requests that recorded a removed resource through the guarded abort path. */
718
+ async reconcilePrunedAborts(checkouts, names) {
719
+ const lines = [];
720
+ for (const checkout of checkouts) {
721
+ const root = realpathSync.native(checkout);
722
+ const { requests, states } = await this.listRequests(root);
723
+ for (const [index, state] of states.entries()) {
724
+ const id = state.request.id;
725
+ const text = JSON.stringify(state);
726
+ if (requests[index].status !== "aborted · retained" || !names.some((name) => text.includes(name)))
727
+ continue;
728
+ try {
729
+ await this.abort(id, root);
730
+ const after = (await this.listRequests(root)).requests.find((request) => request.id === id);
731
+ lines.push(after?.status === "aborted" ? `Reconciled cleanup of aborted request ${id}.`
732
+ : `Aborted request ${id} still has pending cleanup; inspect subagent_status.`);
733
+ }
734
+ catch (error) {
735
+ lines.push(`Aborted request ${id} could not be reconciled: ${errorText(error)}`);
736
+ }
737
+ }
738
+ }
739
+ return lines;
740
+ }
741
+ canFollowup(root, requestId, taskId) {
742
+ const active = this.activeControl(realpathSync.native(root), requestId, taskId);
743
+ return active !== undefined && active.task.status === "working" && !active.control.isSealed;
744
+ }
745
+ drainFollowups(root, requestId, taskId) {
746
+ const active = this.activeControl(realpathSync.native(root), requestId, taskId);
747
+ if (!active || active.task.status !== "working" || active.control.isSealed) {
748
+ throw new Error(`Task ${taskId} is not an active unsealed changeset.`);
749
+ }
750
+ return active.control.drainPending();
751
+ }
752
+ queueFollowup(root, requestId, taskId, instruction) {
753
+ if (typeof instruction !== "string" || !instruction.trim() || instruction.trim() !== instruction
754
+ || instruction.includes("\0") || instruction.length > 32_000) {
755
+ throw new Error("Follow-up instruction must be non-empty exact text of at most 32000 characters.");
756
+ }
757
+ const active = this.activeControl(realpathSync.native(root), requestId, taskId);
758
+ if (!active || active.task.status !== "working" || active.control.isSealed) {
759
+ throw new Error(`Task ${taskId} is not an active unsealed changeset.`);
760
+ }
761
+ if (latestAttempt(active.task).prompts.length + active.control.pendingFollowups >= MAX_WORKER_PROMPTS) {
762
+ throw new Error(`A task may submit at most ${MAX_WORKER_PROMPTS} worker prompts.`);
763
+ }
764
+ active.control.followup(instruction);
765
+ return `Queued follow-up for ${requestId}/${taskId}.`;
766
+ }
767
+ async withProductiveRun(root, operation) {
768
+ return await this.store.withProductiveRunLease(root, async (lease) => {
769
+ if (this.productiveLifecycles.has(root)) {
770
+ throw new Error("Another Pi Subagent productive request is active in this runner.");
771
+ }
772
+ const lifecycle = { lease, stages: new Set(), stopped: false };
773
+ this.productiveLifecycles.set(root, lifecycle);
774
+ try {
775
+ return await operation(lifecycle);
776
+ }
777
+ finally {
778
+ await Promise.allSettled(lifecycle.stages);
779
+ this.productiveLifecycles.delete(root);
780
+ }
781
+ });
782
+ }
783
+ async withLifecycleLock(handle, operation) {
784
+ const lifecycle = this.productiveLifecycles.get(handle.state.root);
785
+ if (!lifecycle)
786
+ throw new Error("Productive lifecycle ownership is unavailable.");
787
+ if (lifecycle.stopped)
788
+ throw new DurableRunStopped(handle.state);
789
+ return await this.store.withLock(handle.state.root, async () => {
790
+ const durable = (await this.store.load(handle.state.root, handle.state.request.id)).state;
791
+ if (terminal(durable)
792
+ && (!terminal(handle.state) || durable.status !== handle.state.status || durable.status === "aborted")) {
793
+ lifecycle.stopped = true;
794
+ handle.state = durable;
795
+ throw new DurableRunStopped(durable);
796
+ }
797
+ return await operation();
798
+ }, { productiveRunLease: lifecycle.lease });
799
+ }
800
+ async saveProductive(handle) {
801
+ const lifecycle = this.productiveLifecycles.get(handle.state.root);
802
+ if (!lifecycle) {
803
+ await handle.save();
804
+ return;
805
+ }
806
+ if (lifecycle.stopped)
807
+ return;
808
+ await this.withLifecycleLock(handle, async () => await handle.save());
809
+ }
810
+ async callProductive(handle, scope, operation) {
811
+ await this.withLifecycleLock(handle, async () => { });
812
+ let result;
813
+ try {
814
+ result = await scope.call(operation);
815
+ }
816
+ catch (error) {
817
+ await this.withLifecycleLock(handle, async () => { });
818
+ throw error;
819
+ }
820
+ await this.withLifecycleLock(handle, async () => { });
821
+ return result;
822
+ }
823
+ rethrowStopped(error) {
824
+ if (error instanceof DurableRunStopped)
825
+ throw error;
826
+ }
827
+ /** Every execute check before durable state; a dry run also skips the Herdr host preflight. */
828
+ async admitExecute(value, cwd, scope, dryRun) {
829
+ const request = parseExecuteRequest(value);
830
+ const policy = Object.freeze({ ...this.currentPolicy() });
831
+ const canonicalCwd = realpathSync.native(cwd);
832
+ const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({ request, cwd: canonicalCwd, ...(dryRun ? { dryRun } : {}) }, context));
833
+ const root = realpathSync.native(prepared.root);
834
+ if (root !== prepared.root)
835
+ throw new Error("Preflight repository root must be canonical.");
836
+ if (request.tasks.some((task) => task.kind === "changeset")) {
837
+ requireRootSuite(root);
838
+ if (!request.finalChecks.some((check) => check.command === FULL_SUITE.command
839
+ && check.args.length === 1 && check.args[0] === FULL_SUITE.args[0])) {
840
+ request.finalChecks = [FULL_SUITE, ...request.finalChecks];
841
+ }
842
+ }
843
+ const preparedMain = runtimeIdentity(prepared.main, "Preflight Main identity");
844
+ if (!isCleanCommitted(preparedMain))
845
+ throw new Error("Preflight Main identity must be clean and committed.");
846
+ if (this.hasAnyActiveControl(root)) {
847
+ throw new Error("Another Pi Subagent request has active changeset work in this repository.");
848
+ }
849
+ return { request, policy, root, preparedMain, routes: prepared.routes ?? [] };
850
+ }
851
+ /** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
852
+ async plan(value, cwd, outerSignal) {
853
+ const { request, root, preparedMain, routes } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true);
854
+ return { request, root, main: preparedMain, routes };
855
+ }
856
+ async execute(value, cwd, outerSignal) {
857
+ const scope = new ProductiveScope(outerSignal);
858
+ const { request, policy, root, preparedMain } = await this.admitExecute(value, cwd, scope, false);
859
+ return await this.withProductiveRun(root, async (lifecycle) => {
860
+ const createdAt = this.coordinatorRuntime.now();
861
+ const state = {
862
+ version: RUN_STATE_VERSION,
863
+ request,
864
+ policy,
865
+ correctionCount: 0,
866
+ root,
867
+ requestStartMain: preparedMain,
868
+ main: preparedMain,
869
+ status: "pending",
870
+ tasks: request.tasks.map((task) => task.kind === "changeset"
871
+ ? {
872
+ taskId: task.id,
873
+ kind: "changeset",
874
+ status: "pending",
875
+ attempts: [],
876
+ }
877
+ : {
878
+ taskId: task.id,
879
+ kind: "text",
880
+ status: "pending",
881
+ attempts: [],
882
+ }),
883
+ waves: [],
884
+ integration: { candidates: [], generations: [] },
885
+ final: { status: "pending" },
886
+ accepted: false,
887
+ createdAt,
888
+ updatedAt: createdAt,
889
+ };
890
+ const handle = await this.store.withLock(root, async () => {
891
+ await this.store.assertLegacyAdmissionSafe(root);
892
+ return await this.store.create(state);
893
+ }, { productiveRunLease: lifecycle.lease });
894
+ return await this.run(handle, scope, undefined);
895
+ });
896
+ }
897
+ async resume(value, root, outerSignal) {
898
+ const request = parseResumeRequest(value);
899
+ root = realpathSync.native(root);
900
+ if (this.hasActiveControl(root, request.id)) {
901
+ throw new Error(`Pi Subagent request ${request.id} is still active; use follow-up, status, or abort.`);
902
+ }
903
+ if (this.hasAnyActiveControl(root)) {
904
+ throw new Error("Another Pi Subagent request has active changeset work in this repository.");
905
+ }
906
+ return await this.withProductiveRun(root, async (lifecycle) => {
907
+ const handle = await this.store.withLock(root, async () => {
908
+ const loaded = await this.store.load(root, request.id);
909
+ const state = loaded.state;
910
+ if (this.recoverInterrupted(state))
911
+ await loaded.save();
912
+ if (terminal(state))
913
+ throw new Error(`Pi Subagent request ${request.id} is terminal (${state.status}); create a new request.`);
914
+ if (state.integration.candidates.length)
915
+ throw new Error("Retained candidates require subagent_stage; resume cannot stage or finalize them.");
916
+ state.recovery = {
917
+ kind: "resume",
918
+ action: request.action,
919
+ ...("taskId" in request ? { taskId: request.taskId } : {}),
920
+ };
921
+ state.updatedAt = this.coordinatorRuntime.now();
922
+ await loaded.save();
923
+ return loaded;
924
+ }, { productiveRunLease: lifecycle.lease });
925
+ const state = handle.state;
926
+ const scope = new ProductiveScope(outerSignal);
927
+ try {
928
+ if (request.action === "finalize")
929
+ return await this.finalize(handle, scope);
930
+ const task = taskState(state, request.taskId);
931
+ if (task.kind === "text") {
932
+ if (task.status !== "needs_attention")
933
+ throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
934
+ if (request.action === "verify")
935
+ throw new Error(`Text task ${task.taskId} cannot be verified.`);
936
+ if (request.instruction !== undefined)
937
+ throw new Error(`Text task ${task.taskId} retry cannot take a worker instruction.`);
938
+ if (!textRetryEligible(state, task)) {
939
+ const attempt = task.attempts.at(-1);
940
+ if ((attempt && !["failed", "superseded"].includes(attempt.status)) || task.attempts.length >= 2) {
941
+ throw new Error(`Text task ${task.taskId} retry requires an unstarted, failed or superseded dispatch and fewer than two attempts.`);
942
+ }
943
+ throw new Error(`Text task ${task.taskId} dependencies are not completed.`);
944
+ }
945
+ task.status = "pending";
946
+ task.failure = undefined;
947
+ return await this.run(handle, scope, task.taskId);
948
+ }
949
+ if (task.status !== "needs_attention")
950
+ throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
951
+ if (request.action === "retry")
952
+ return await this.retry(handle, task, scope, request.instruction);
953
+ return await this.verifyRetainedTask(handle, task, scope);
954
+ }
955
+ catch (error) {
956
+ if (error instanceof DurableRunStopped)
957
+ return this.response(handle.state);
958
+ throw error;
959
+ }
960
+ finally {
961
+ this.closeRequestControls(root, request.id);
962
+ if (!lifecycle.stopped) {
963
+ delete state.recovery;
964
+ state.updatedAt = this.coordinatorRuntime.now();
965
+ await this.saveProductive(handle);
966
+ }
967
+ }
968
+ });
969
+ }
970
+ /** Main selects an exact retained candidate; only the owned integration checkout is writable. */
971
+ async stage(value, root, outerSignal) {
972
+ const action = parseStageRequest(value);
973
+ root = realpathSync.native(root);
974
+ const stageSelected = async (handle, duringWave) => {
975
+ const state = handle.state;
976
+ if (terminal(state) || (state.status !== "needs_attention"
977
+ && !(duringWave && state.status === "running" && (action.action === "stage" || action.action === "resolve")))
978
+ || (state.integration.refresh && state.integration.refresh.status !== "ready")) {
979
+ throw new Error("Request is not waiting for Main staging or refresh allocation remains uncertain.");
980
+ }
981
+ const candidate = state.integration.candidates.find((item) => item.taskId === action.taskId && item.attempt === action.attempt
982
+ && sameIdentity(item.tip, action.candidate));
983
+ const task = changesetTaskState(state, action.taskId);
984
+ const attempt = task.attempts[action.attempt - 1];
985
+ const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
986
+ if (action.action === "reject" && candidate?.decision === "rejected" && task.status === "needs_attention"
987
+ && (attempt?.termination?.status === "unknown" || attempt?.termination?.status === "terminating")
988
+ && candidate.worker === "retained") {
989
+ const generation = state.integration.generations.at(-1);
990
+ if (action.generation !== (generation?.status === "superseded" ? generation.number + 1 : generation?.number ?? 1)) {
991
+ throw new Error("Rejection recovery has a stale generation.");
992
+ }
993
+ const expected = generation && generation.status !== "superseded"
994
+ ? generation.combinedTip ?? generation.stages.at(-1)?.tip ?? generation.integrationBase : state.main;
995
+ if (!sameIdentity(action.expectedTip, expected))
996
+ throw new Error("Rejection recovery has a stale integration tip.");
997
+ const scope = new ProductiveScope(outerSignal);
998
+ const termination = attempt.termination;
999
+ const result = await scope.call((context) => this.hostRuntime.reconcileWorkerTermination({
1000
+ task: changesetTaskRequest(state, task.taskId), attempt, workerId: termination.workerId,
1001
+ candidate: termination.candidate,
1002
+ }, context));
1003
+ if (result.outcome === "terminated") {
1004
+ attempt.termination = { status: "terminated", workerId: termination.workerId,
1005
+ candidate: termination.candidate, at: this.coordinatorRuntime.now() };
1006
+ task.status = "rejected";
1007
+ delete task.failure;
1008
+ }
1009
+ else
1010
+ task.failure = bounded(`Exact worker ${termination.workerId} termination remains unproved; inspect the retained agent and retry rejection recovery.`);
1011
+ await this.saveProductive(handle);
1012
+ return this.response(state);
1013
+ }
1014
+ if (!candidate || candidate.worker !== "retained" || candidate.decision || task.status !== "ready_to_integrate" || !worker
1015
+ || !sameIdentity(attempt.readiness.candidate, action.candidate)) {
1016
+ throw new Error("Stage action refers to a stale or unowned candidate.");
1017
+ }
1018
+ const scope = new ProductiveScope(outerSignal);
1019
+ const generations = state.integration.generations;
1020
+ let generation = generations.at(-1);
1021
+ const nextNumber = generation?.status === "superseded" ? generation.number + 1 : generation?.number ?? 1;
1022
+ if (action.generation !== nextNumber || (generation && !["staging", "conflict", "validation_failed", "ready", "superseded"].includes(generation.status))) {
1023
+ throw new Error("Stage action has a stale generation.");
1024
+ }
1025
+ if (action.action === "revise" || action.action === "reject") {
1026
+ if (action.action === "revise" && (!action.instruction?.trim() || action.instruction !== action.instruction.trim() || action.instruction.includes("\0"))) {
1027
+ throw new Error("Revision requires an exact nonempty instruction.");
1028
+ }
1029
+ if (action.action === "reject" && action.instruction !== undefined)
1030
+ throw new Error("Rejection cannot prompt a worker.");
1031
+ const tip = generation && generation.status !== "superseded"
1032
+ ? generation.combinedTip ?? generation.stages.at(-1)?.tip ?? generation.stages.at(-1)?.onto ?? generation.integrationBase
1033
+ : state.main;
1034
+ if (!sameIdentity(action.expectedTip, tip))
1035
+ throw new Error("Arbitration has a stale combined tip.");
1036
+ const affected = affectedDependents(state, action.taskId);
1037
+ if (affected.some((dependent) => dependent.attempts.length >= 2)) {
1038
+ throw new Error("A dependent has exhausted its bounded fresh attempts; arbitration cannot invalidate it safely.");
1039
+ }
1040
+ if (generation && generation.status !== "superseded" && generation.stages.some((stage) => stage.taskId === action.taskId)
1041
+ && generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
1042
+ throw new Error("Retained integration worktree limit is exhausted; reconcile the owned checkouts before another rebuild.");
1043
+ }
1044
+ if (affected.some((dependent) => dependent.kind === "changeset" && (dependent.attempts.at(-1).prompts.some((prompt) => prompt.status === "submitting" || prompt.status === "ambiguous")
1045
+ || dependent.attempts.at(-1).allocations.some((allocation) => allocation.status === "allocating" || allocation.status === "unknown")
1046
+ || dependent.attempts.at(-1).termination))) {
1047
+ throw new Error("A dependent has uncertain or released worker resources; reconcile before arbitration.");
1048
+ }
1049
+ if (generation && generation.status !== "superseded" && generation.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
1050
+ throw new Error("Uncertain stage must be reconciled before superseding its generation.");
1051
+ }
1052
+ if (generation?.status === "superseded" && affected.length) {
1053
+ throw new Error("Dependent attempts belong to a superseded generation; choose a fresh staged candidate.");
1054
+ }
1055
+ if (generation && generation.status !== "superseded" && affected.some((dependent) => dependent.kind === "changeset" && !dependent.attempts.every((item) => item.waveBase.head === state.main.head || generation.stages.some((stage) => stage.status === "staged"
1056
+ && stage.tip && sameIdentity(stage.tip, item.waveBase))))) {
1057
+ throw new Error("A dependent lacks a proven staged source snapshot.");
1058
+ }
1059
+ if (action.action === "revise") {
1060
+ if (!this.correctionAllowed(state, changesetTaskRequest(state, task.taskId), attempt, true)) {
1061
+ throw new Error("Same-worker revision is unavailable; a fresh attempt in this request is not supported.");
1062
+ }
1063
+ await scope.call((context) => this.integrationGit.inspectWorker(root, worker, candidate.tip, context.signal));
1064
+ }
1065
+ const main = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
1066
+ if (!sameIdentity(main, state.main))
1067
+ throw new Error("Main drifted before arbitration.");
1068
+ if (generation && generation.status !== "superseded" && generation.stages.some((stage) => stage.taskId === action.taskId)) {
1069
+ if (generation.stages.some((stage) => stage.status === "staging" || stage.status === "pending")) {
1070
+ throw new Error("Uncertain stage must be reconciled before superseding its generation.");
1071
+ }
1072
+ if (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status))
1073
+ throw new Error("Cannot supersede an unresolved promotion or validation.");
1074
+ supersedeGeneration(generation, `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`);
1075
+ }
1076
+ candidate.decision = "rejected";
1077
+ for (const dependent of affected) {
1078
+ if (dependent.kind === "text") {
1079
+ const last = dependent.attempts.at(-1);
1080
+ last.status = "superseded";
1081
+ delete last.output;
1082
+ delete last.failure;
1083
+ }
1084
+ else {
1085
+ dependent.attempts.at(-1).superseded = true;
1086
+ for (const old of state.integration.candidates.filter((item) => item.taskId === dependent.taskId && !item.decision)) {
1087
+ old.decision = "rejected";
1088
+ }
1089
+ }
1090
+ dependent.status = "pending";
1091
+ delete dependent.failure;
1092
+ }
1093
+ task.status = "needs_attention";
1094
+ task.failure = action.action === "revise" ? action.instruction : "Main rejected this candidate; owned resources remain recorded.";
1095
+ await this.saveProductive(handle); // Freeze before touching the exact worker.
1096
+ if (action.action === "reject" && allocationByKind(attempt, "agent")) {
1097
+ if (await this.terminateWithSafety(handle, task, attempt, action.candidate, scope.signal))
1098
+ task.status = "rejected";
1099
+ else
1100
+ this.attention(task, `Worker termination is unproved: ${attempt.termination.failure}`);
1101
+ await this.saveProductive(handle);
1102
+ }
1103
+ if (action.action === "revise") {
1104
+ task.status = "working";
1105
+ await this.driveWorkerSafely(handle, task, scope, "correction", undefined, true);
1106
+ if (changesetTaskState(state, task.taskId).status === "ready_to_integrate")
1107
+ this.retainCandidate(state, task);
1108
+ state.status = "needs_attention";
1109
+ await this.saveProductive(handle);
1110
+ }
1111
+ return this.response(state);
1112
+ }
1113
+ if (action.instruction !== undefined)
1114
+ throw new Error("Stage/resolve cannot prompt a worker.");
1115
+ if (generation?.status === "superseded")
1116
+ generation = undefined;
1117
+ if (generation && generation.status !== "staging" && generation.status !== "conflict")
1118
+ throw new Error("Stage action has a stale generation.");
1119
+ const previousStage = generation?.stages.at(-1);
1120
+ const pendingAllocation = action.action === "resolve" && previousStage?.status === "pending";
1121
+ const expected = previousStage?.status === "staged"
1122
+ ? previousStage.tip : previousStage?.onto ?? generation?.integrationBase;
1123
+ if (action.action === "stage" && generation?.stages.some((stage) => stage.status !== "staged")) {
1124
+ throw new Error("A previous stage is unresolved; inspect and resolve it first.");
1125
+ }
1126
+ if (action.action === "resolve") {
1127
+ const pending = generation?.stages.at(-1);
1128
+ if (!pending || !["pending", "staging", "conflict"].includes(pending.status)
1129
+ || pending.taskId !== action.taskId || pending.attempt !== action.attempt
1130
+ || !sameIdentity(pending.source, action.candidate)
1131
+ || !sameIdentity(pending.onto, action.expectedTip))
1132
+ throw new Error("Resolve action has no exact pending stage intent.");
1133
+ }
1134
+ else if (!sameIdentity(action.expectedTip, expected ?? state.main)
1135
+ || generation?.stages.some((stage) => stage.taskId === action.taskId)) {
1136
+ throw new Error("Stage action has a stale combined tip or repeated candidate.");
1137
+ }
1138
+ if (!isCleanCommitted(action.expectedTip) || action.expectedTip.head !== (expected?.head ?? state.main.head)) {
1139
+ throw new Error("Stage action has an invalid integration tip.");
1140
+ }
1141
+ if (action.action === "stage" && !stagedDependencySnapshot(state, action.taskId, action.expectedTip)) {
1142
+ throw new Error("Candidate dependencies are not staged on this exact snapshot.");
1143
+ }
1144
+ if (action.action === "stage" && !sameIdentity(attempt.waveBase, state.main)
1145
+ && !(state.integration.refresh?.status === "ready"
1146
+ && (sameIdentity(attempt.waveBase, state.requestStartMain)
1147
+ || generations.some((item) => sameIdentity(item.expectedMain, attempt.waveBase)))
1148
+ && !generations.some((item) => item.stages.some((stage) => stage.tip && sameIdentity(stage.tip, attempt.waveBase))))
1149
+ && !generation?.stages.some((stage) => stage.status === "staged" && stage.tip && sameIdentity(stage.tip, attempt.waveBase))) {
1150
+ throw new Error("Dependent candidate was launched from a superseded or unstaged snapshot.");
1151
+ }
1152
+ const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
1153
+ if (!sameIdentity(actualMain, state.main))
1154
+ throw new Error("Main changed or became dirty; staging is blocked.");
1155
+ if (pendingAllocation) {
1156
+ if (!generation?.worktree || generation.status !== "staging" || generation.stages.length !== 1
1157
+ || !sameIdentity(action.expectedTip, generation.integrationBase)) {
1158
+ throw new Error("Pending integration allocation is not an exact initial stage.");
1159
+ }
1160
+ const checkout = generation.worktree;
1161
+ const base = await scope.call((context) => this.integrationGit.inspectCombined(root, checkout, state.main, [], context.signal));
1162
+ if (!sameIdentity(base, generation.integrationBase))
1163
+ throw new Error("Pending integration checkout is not the clean recorded base.");
1164
+ previousStage.status = "staging";
1165
+ await this.saveProductive(handle); // Explicit resolve proves allocation; merge intent precedes the first merge.
1166
+ }
1167
+ if (!generation) {
1168
+ if (action.action !== "stage")
1169
+ throw new Error("No integration generation exists to resolve.");
1170
+ if (generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
1171
+ throw new Error("Retained integration worktree limit is exhausted; no new generation may be allocated.");
1172
+ }
1173
+ const result = await scope.call((context) => this.integrationGit.allocate(root, `${action.id}-integration-${action.generation}`, state.main, async (worktree) => {
1174
+ const integrationBase = { ...state.main, branch: `refs/heads/${worktree.branch}` };
1175
+ generation = {
1176
+ number: action.generation, status: "staging", expectedMain: state.main,
1177
+ integrationBase,
1178
+ order: [action.taskId], stages: [{ taskId: action.taskId, attempt: action.attempt,
1179
+ source: action.candidate, onto: integrationBase, status: "pending" }],
1180
+ worktree,
1181
+ };
1182
+ generations.push(generation);
1183
+ await this.saveProductive(handle); // Before worktree add.
1184
+ }, context.signal, [action.id, `integration-${action.generation}`]));
1185
+ if (result.outcome !== "ready") {
1186
+ if (!generations.at(-1))
1187
+ throw new Error("Integration allocation did not persist an owned worktree plan.");
1188
+ return this.response(state);
1189
+ }
1190
+ }
1191
+ generation = generations.at(-1);
1192
+ if (!generation?.worktree)
1193
+ throw new Error("Integration worktree allocation has no durable ownership record.");
1194
+ const worktree = generation.worktree;
1195
+ const previousStages = generation.stages.filter((stage) => stage.status === "staged");
1196
+ const receipts = previousStages.map((stage) => ({ previous: stage.onto, worker: stage.source, tip: stage.tip }));
1197
+ let stage = generation.stages.at(-1);
1198
+ if (action.action === "stage") {
1199
+ if (!stage || stage.status === "staged") {
1200
+ stage = { taskId: action.taskId, attempt: action.attempt, source: action.candidate,
1201
+ onto: action.expectedTip, status: "staging" };
1202
+ delete generation.combinedTip;
1203
+ generation.order.push(action.taskId);
1204
+ generation.stages.push(stage);
1205
+ }
1206
+ else {
1207
+ stage.status = "staging";
1208
+ }
1209
+ await this.saveProductive(handle); // Exact candidate and previous tip before merge.
1210
+ }
1211
+ const outcome = await scope.call((context) => action.action === "stage" || pendingAllocation
1212
+ ? this.integrationGit.stage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal)
1213
+ : this.integrationGit.reconcileStage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal));
1214
+ if (outcome.outcome === "ready" && outcome.value !== "not_started") {
1215
+ stage.status = "staged";
1216
+ stage.tip = outcome.value.tip;
1217
+ delete stage.failure;
1218
+ generation.combinedTip = outcome.value.tip;
1219
+ generation.status = "staging";
1220
+ delete generation.failure;
1221
+ }
1222
+ else if (outcome.outcome === "conflict") {
1223
+ stage.status = "conflict";
1224
+ stage.failure = bounded(outcome.failure);
1225
+ generation.status = "conflict";
1226
+ generation.failure = stage.failure;
1227
+ }
1228
+ else {
1229
+ stage.failure = bounded(outcome.outcome === "ready"
1230
+ ? "Merge has not started; intent retained. No automatic retry."
1231
+ : outcome.failure);
1232
+ }
1233
+ // Unproved outcomes keep the intent; never replay a merge automatically.
1234
+ await this.saveProductive(handle);
1235
+ return this.response(state);
1236
+ };
1237
+ const active = this.productiveLifecycles.get(root);
1238
+ if (active?.handle?.state.request.id === action.id) {
1239
+ if (active.stopped)
1240
+ throw new DurableRunStopped(active.handle.state);
1241
+ if (action.action !== "stage" && action.action !== "resolve") {
1242
+ throw new Error("Wait for active workers before revising or rejecting a candidate.");
1243
+ }
1244
+ if (active.stages.size)
1245
+ throw new Error("Another stage or resolution is active; retry after it finishes.");
1246
+ const stage = stageSelected(active.handle, true);
1247
+ active.stages.add(stage);
1248
+ try {
1249
+ return await stage;
1250
+ }
1251
+ finally {
1252
+ active.stages.delete(stage);
1253
+ }
1254
+ }
1255
+ return await this.withProductiveRun(root, async (lifecycle) => {
1256
+ const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
1257
+ return await stageSelected(handle, false);
1258
+ });
1259
+ }
1260
+ stageReceipts(generation) {
1261
+ return generation.stages.map((stage) => ({ previous: stage.onto, worker: stage.source, tip: stage.tip }));
1262
+ }
1263
+ /** Only Main can validate/promote the exact selected, clean combined tip. */
1264
+ async integrate(value, root, outerSignal) {
1265
+ const action = parseIntegrationAction(value);
1266
+ root = realpathSync.native(root);
1267
+ return await this.withProductiveRun(root, async (lifecycle) => {
1268
+ const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
1269
+ const state = handle.state;
1270
+ // Loading state has already normalized optional request fields, including finalChecks.
1271
+ const request = state.request;
1272
+ const generation = state.integration.generations.at(-1);
1273
+ if (action.action === "release") {
1274
+ const scope = new ProductiveScope(outerSignal);
1275
+ if (action.taskId !== undefined || action.attempt !== undefined) {
1276
+ if (!action.taskId || !action.attempt || action.generation !== (generation?.number ?? 1)) {
1277
+ throw new Error("Candidate release requires the latest generation and exact task attempt.");
1278
+ }
1279
+ const task = changesetTaskState(state, action.taskId);
1280
+ const attempt = task.attempts[action.attempt - 1];
1281
+ const candidates = state.integration.candidates.filter((item) => item.taskId === action.taskId && item.attempt === action.attempt);
1282
+ const candidate = candidates.find((item) => sameIdentity(item.tip, action.expectedTip));
1283
+ const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
1284
+ const tip = attempt?.termination?.candidate ?? attempt?.candidate;
1285
+ // A rejected task or an invalidated dependent attempt never runs again; its worker can stop.
1286
+ if (!candidate || candidate.decision !== "rejected" || !attempt || (task.status !== "rejected" && !attempt.superseded)
1287
+ || !tip || !sameIdentity(tip, action.expectedTip) || !worker
1288
+ || candidates.some((item) => !item.decision)
1289
+ || state.integration.generations.some((item) => item.stages.some((stage) => stage.taskId === action.taskId
1290
+ && stage.attempt === action.attempt && item.cleanup?.every((step) => step.status === "completed") !== true))) {
1291
+ throw new Error("Release requires a rejected candidate of a stopped or invalidated worker and no retained staged generation using its checkout.");
1292
+ }
1293
+ await this.proveTermination(handle, task, attempt, action.expectedTip, async () => await this.saveProductive(handle), scope.signal);
1294
+ await this.releaseRejected(handle, task, attempt, worker, scope);
1295
+ return this.response(state);
1296
+ }
1297
+ const superseded = state.integration.generations[action.generation - 1];
1298
+ if (!superseded?.worktree || superseded.status !== "superseded"
1299
+ || !sameIdentity(action.expectedTip, retainedGenerationTip(superseded))
1300
+ || superseded.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
1301
+ throw new Error("Release requires an exact superseded integration checkout without an uncertain stage.");
1302
+ }
1303
+ await this.releaseSuperseded(handle, superseded, action.expectedTip, scope);
1304
+ return this.response(state);
1305
+ }
1306
+ if (action.action === "refresh") {
1307
+ if (terminal(state) || state.status !== "needs_attention" || state.integration.refresh?.status === "pending"
1308
+ || state.integration.refresh?.status === "unknown" || !sameIdentity(state.main, action.expectedMain)
1309
+ || action.generation !== state.integration.generations.length + 1
1310
+ || (generation && (generation.status === "superseded" ? !sameIdentity(action.expectedTip, state.main)
1311
+ : !sameIdentity(action.expectedTip, generation.combinedTip
1312
+ ?? generation.stages.at(-1)?.onto ?? generation.integrationBase)
1313
+ || (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status)
1314
+ && !(generation.status === "promotion_failed" && generation.promotion?.outcome === "drift"))
1315
+ || generation.stages.some((stage) => stage.status === "pending" || stage.status === "staging")))
1316
+ || (!generation && !sameIdentity(action.expectedTip, state.main))) {
1317
+ throw new Error("Refresh has a stale Main, generation, or unresolved integration intent.");
1318
+ }
1319
+ if (!state.integration.candidates.some((candidate) => !candidate.decision && candidate.worker === "retained")) {
1320
+ throw new Error("Refresh requires at least one retained immutable candidate.");
1321
+ }
1322
+ if (state.integration.generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
1323
+ throw new Error("Retained integration worktree limit is exhausted.");
1324
+ }
1325
+ const scope = new ProductiveScope(outerSignal);
1326
+ const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
1327
+ if (!isCleanCommitted(action.newMain) || !sameIdentity(actualMain, action.newMain)) {
1328
+ throw new Error("Refresh requires the exact new clean Main identity.");
1329
+ }
1330
+ const unchangedMain = sameIdentity(action.expectedMain, action.newMain);
1331
+ if (unchangedMain) {
1332
+ if (generation?.status !== "validation_failed" || generation.checks || generation.review || generation.correction) {
1333
+ throw new Error("Unchanged Main refresh requires evidence-less failed validation without a prior correction.");
1334
+ }
1335
+ await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope);
1336
+ }
1337
+ else {
1338
+ await scope.call((context) => this.integrationGit.inspectMainAdvance(root, action.expectedMain, action.newMain, context.signal));
1339
+ }
1340
+ // Old dependent snapshots cannot be replayed on a new Main tip. Retain their resources and
1341
+ // invalidate their readiness before admitting any new stages or dependent launches.
1342
+ const stale = new Set();
1343
+ for (const stage of generation?.stages ?? []) {
1344
+ for (const dependent of affectedDependents(state, stage.taskId)) {
1345
+ if (dependent.attempts.length && dependent.status !== "pending")
1346
+ stale.add(dependent);
1347
+ }
1348
+ }
1349
+ if ([...stale].some((task) => task.attempts.length >= 2 || task.status === "working"
1350
+ || task.kind === "changeset" && (task.attempts.at(-1).termination
1351
+ || task.attempts.at(-1).prompts.some((item) => item.status === "submitting" || item.status === "ambiguous")
1352
+ || task.attempts.at(-1).allocations.some((item) => item.status === "allocating" || item.status === "unknown")))) {
1353
+ throw new Error("A dependent has unproved resources or exhausted fresh attempts; refresh is blocked.");
1354
+ }
1355
+ if (generation && generation.status !== "superseded") {
1356
+ supersedeGeneration(generation, `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`);
1357
+ }
1358
+ for (const task of stale) {
1359
+ if (task.kind === "text") {
1360
+ const last = task.attempts.at(-1);
1361
+ last.status = "superseded";
1362
+ delete last.output;
1363
+ delete last.failure;
1364
+ }
1365
+ else {
1366
+ task.attempts.at(-1).superseded = true;
1367
+ for (const candidate of state.integration.candidates.filter((item) => item.taskId === task.taskId && !item.decision))
1368
+ candidate.decision = "rejected";
1369
+ }
1370
+ task.status = "pending";
1371
+ delete task.failure;
1372
+ }
1373
+ state.main = action.newMain;
1374
+ state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation, status: "pending" };
1375
+ await this.saveProductive(handle); // Durable supersession and refresh intent before worktree creation.
1376
+ let result;
1377
+ try {
1378
+ result = await scope.call((context) => this.integrationGit.allocate(root, `${action.id}-integration-${action.generation}`, action.newMain, async (worktree) => {
1379
+ state.integration.generations.push({ number: action.generation, status: "staging", expectedMain: action.newMain,
1380
+ integrationBase: { ...action.newMain, branch: `refs/heads/${worktree.branch}` },
1381
+ order: [], stages: [], worktree });
1382
+ state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation,
1383
+ status: "unknown", failure: "Integration allocation has not been proved; retain the recorded checkout." };
1384
+ await this.saveProductive(handle);
1385
+ }, context.signal, [action.id, `integration-${action.generation}`]));
1386
+ }
1387
+ catch (error) {
1388
+ // A prepared checkout remains recorded; no repeated allocation from this intent.
1389
+ state.integration.refresh.failure = bounded(`Integration allocation uncertain: ${errorText(error)}`);
1390
+ await this.saveProductive(handle);
1391
+ return this.response(state);
1392
+ }
1393
+ if (result.outcome === "ready" && state.integration.generations.at(-1)?.number === action.generation) {
1394
+ state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation, status: "ready" };
1395
+ }
1396
+ else {
1397
+ state.integration.refresh.failure = bounded(result.outcome === "ready" ? "Allocation lacks a recorded checkout." : result.failure);
1398
+ }
1399
+ await this.saveProductive(handle);
1400
+ return this.response(state);
1401
+ }
1402
+ if (!generation || generation.number !== action.generation || !generation.worktree
1403
+ || !generation.combinedTip || !sameIdentity(generation.combinedTip, action.expectedTip)
1404
+ || (terminal(state) && !(state.status === "completed" && action.action === "cleanup"))
1405
+ || (state.status !== "needs_attention" && !(state.status === "completed" && action.action === "cleanup")))
1406
+ throw new Error("Integration action has a stale generation or combined tip.");
1407
+ const scope = new ProductiveScope(outerSignal);
1408
+ const receipts = this.stageReceipts(generation);
1409
+ if (action.action === "advance") {
1410
+ if (generation.status !== "staging" || generation.stages.some((stage) => stage.status !== "staged")
1411
+ || generation.correction || !isCleanCommitted(action.expectedTip)) {
1412
+ throw new Error("Advance requires an exact clean staged snapshot before validation or correction.");
1413
+ }
1414
+ const ready = readyPendingTasks(state).filter((task) => hasChangesetDependency(state, task.taskId)
1415
+ && stagedDependencySnapshot(state, task.taskId, action.expectedTip));
1416
+ if (!ready.length)
1417
+ throw new Error("No dependent task is ready on this staged snapshot.");
1418
+ await this.requireExactIntegration(handle, generation, receipts, scope, true);
1419
+ return await this.run(handle, scope, undefined, action.expectedTip);
1420
+ }
1421
+ if (action.action === "cleanup") {
1422
+ if (generation.status !== "promoted")
1423
+ throw new Error("Cleanup requires proven exact promotion.");
1424
+ await this.cleanupPromoted(handle, generation, receipts, scope);
1425
+ return this.response(state);
1426
+ }
1427
+ if (action.action === "reconcile") {
1428
+ if (generation.status === "validating") {
1429
+ delete generation.checks;
1430
+ delete generation.review;
1431
+ generation.status = "validation_failed";
1432
+ generation.failure = "Interrupted validation has no definitive exact full-suite result; Main must rebuild and recheck.";
1433
+ await this.saveProductive(handle);
1434
+ return this.response(state);
1435
+ }
1436
+ if (!generation.promotion || !["promoting", "unknown"].includes(generation.promotion.status)) {
1437
+ throw new Error("No interrupted promotion intent to reconcile.");
1438
+ }
1439
+ const result = await scope.call((context) => this.integrationGit.reconcilePromotion(root, generation.worktree, generation.expectedMain, receipts, context.signal, generation.correction));
1440
+ if (result.outcome === "ready") {
1441
+ generation.promotion.status = "promoted";
1442
+ generation.promotion.mainAfter = result.value;
1443
+ delete generation.promotion.failure;
1444
+ generation.status = "promoted";
1445
+ state.main = result.value;
1446
+ }
1447
+ else {
1448
+ generation.promotion.status = "unknown";
1449
+ generation.promotion.failure = bounded(result.failure);
1450
+ generation.status = "promotion_unknown";
1451
+ }
1452
+ await this.saveProductive(handle);
1453
+ if (result.outcome === "ready")
1454
+ await this.cleanupPromoted(handle, generation, receipts, scope);
1455
+ return this.response(state);
1456
+ }
1457
+ if (action.action === "correct") {
1458
+ if (generation.status !== "validation_failed" || generation.correction || !generation.checks
1459
+ || (generation.checks.passed && generation.review?.passed !== false)) {
1460
+ throw new Error("Correction requires a definitive failed combined check or review and no prior correction.");
1461
+ }
1462
+ const from = generation.combinedTip;
1463
+ const to = await scope.call((context) => this.gitRuntime.inspectMain({ root: generation.worktree.path }, context));
1464
+ await scope.call((context) => this.integrationGit.inspectCorrection(root, generation.worktree, generation.expectedMain, receipts, from, to, context.signal));
1465
+ generation.correction = { from, to };
1466
+ generation.combinedTip = to;
1467
+ delete generation.checks;
1468
+ delete generation.review;
1469
+ delete generation.failure;
1470
+ generation.status = "staging";
1471
+ await this.saveProductive(handle);
1472
+ return this.response(state);
1473
+ }
1474
+ if (action.action === "validate") {
1475
+ if (generation.status !== "staging" || !receipts.length
1476
+ || generation.stages.some((stage) => stage.status !== "staged"))
1477
+ throw new Error("Validation requires fully committed explicit stages and no prior validation attempt.");
1478
+ generation.status = "validating";
1479
+ await this.saveProductive(handle); // Record the exact validation intent before running checks.
1480
+ try {
1481
+ if (!request.finalChecks.some((check) => check.command === FULL_SUITE.command
1482
+ && check.args.length === 1 && check.args[0] === FULL_SUITE.args[0]))
1483
+ throw new Error("Canonical full-suite command was not recorded at admission.");
1484
+ if (requireRootSuite(generation.worktree.path) !== requireRootSuite(root)) {
1485
+ throw new Error("Combined tip changed the admitted canonical root test script.");
1486
+ }
1487
+ await this.requireExactIntegration(handle, generation, receipts, scope);
1488
+ const sources = stagedCheckSources(generation, state.integration.candidates);
1489
+ const checks = await this.runCheckBatch(handle, request.finalChecks, action.expectedTip, "final", scope, undefined, generation.worktree.path, request.finalChecks.map((check) => reusableCheckSource(check, action.expectedTip, sources)));
1490
+ generation.checks = checks;
1491
+ await this.saveProductive(handle);
1492
+ if (!checkBatchPasses(checks, request.finalChecks, action.expectedTip, sources))
1493
+ throw new Error("Combined full-suite check failed or changed the integration tip.");
1494
+ await this.requireExactIntegration(handle, generation, receipts, scope);
1495
+ if (request.finalJudgment) {
1496
+ const judgment = request.finalJudgment;
1497
+ generation.review = await this.runReview(handle, judgment.criterion, generation.expectedMain, action.expectedTip, judgment.role, judgment.modelClass, "final", scope, undefined, generation.worktree.path);
1498
+ await this.saveProductive(handle);
1499
+ if (!reviewEvidencePasses(generation.review, "final", judgment.criterion, generation.expectedMain, action.expectedTip)) {
1500
+ throw new Error("Combined judgment failed or changed the integration tip.");
1501
+ }
1502
+ }
1503
+ await this.requireExactIntegration(handle, generation, receipts, scope);
1504
+ if (!integrationGenerationPasses(generation, request, state.integration.candidates))
1505
+ throw new Error("Combined validation is incomplete.");
1506
+ generation.status = "ready";
1507
+ }
1508
+ catch (error) {
1509
+ if (integrationGenerationPasses(generation, request, state.integration.candidates)) {
1510
+ delete generation.checks;
1511
+ delete generation.review; // Drift invalidates otherwise passing evidence.
1512
+ }
1513
+ generation.status = "validation_failed";
1514
+ generation.failure = bounded(`Combined validation requires Main attention: ${errorText(error)}`);
1515
+ }
1516
+ await this.saveProductive(handle);
1517
+ return this.response(state);
1518
+ }
1519
+ if (generation.status !== "ready" || !integrationGenerationPasses(generation, request, state.integration.candidates)) {
1520
+ throw new Error("Promotion requires exact successful combined validation.");
1521
+ }
1522
+ for (const definition of request.tasks.filter((task) => hasChangesetDependency(state, task.id))) {
1523
+ const dependent = taskState(state, definition.id);
1524
+ const launch = [...state.waves].reverse().find((wave) => wave.taskIds.includes(definition.id));
1525
+ const stageIndex = generation.stages.findIndex((stage) => stage.taskId === definition.id);
1526
+ const attempt = dependent.kind === "changeset" ? dependent.attempts.at(-1) : undefined;
1527
+ if (!launch || !dependent.attempts.length || (definition.kind === "text" && dependent.status !== "completed")
1528
+ || (definition.kind === "changeset" && (stageIndex < 0 || !attempt
1529
+ || generation.stages[stageIndex].status !== "staged" || generation.stages[stageIndex].attempt !== dependent.attempts.length
1530
+ || attempt.waveNumber !== launch.number || !sameIdentity(attempt.waveBase, launch.base)))) {
1531
+ throw new Error(`Dependent ${definition.id} lacks a fresh stage from the selected integration snapshot.`);
1532
+ }
1533
+ if (!(stageIndex < 0 ? generation.stages : generation.stages.slice(0, stageIndex)).some((stage) => stage.status === "staged" && sameIdentity(stage.tip, launch.base))) {
1534
+ throw new Error(`Dependent ${definition.id} ran on a superseded snapshot.`);
1535
+ }
1536
+ }
1537
+ await this.requireExactIntegration(handle, generation, receipts, scope);
1538
+ generation.promotion = { status: "promoting", expectedMain: generation.expectedMain, tip: action.expectedTip };
1539
+ generation.status = "promoting";
1540
+ await this.saveProductive(handle); // Intent before any Main mutation.
1541
+ let outcome;
1542
+ try {
1543
+ outcome = await scope.call((context) => this.integrationGit.promote({
1544
+ root, integration: generation.worktree, base: generation.expectedMain, stages: receipts,
1545
+ checks: generation.checks, commands: request.finalChecks,
1546
+ sources: stagedCheckSources(generation, state.integration.candidates),
1547
+ review: generation.review, criterion: request.finalJudgment?.criterion, correction: generation.correction,
1548
+ }, context.signal));
1549
+ }
1550
+ catch (error) {
1551
+ outcome = { outcome: "unknown", failure: `Interrupted promotion: ${errorText(error)}` };
1552
+ }
1553
+ if (outcome.outcome === "ready") {
1554
+ generation.promotion.status = "promoted";
1555
+ generation.promotion.mainAfter = outcome.value;
1556
+ generation.status = "promoted";
1557
+ state.main = outcome.value;
1558
+ }
1559
+ else {
1560
+ generation.promotion.status = outcome.outcome === "unknown" ? "unknown" : "failed";
1561
+ generation.promotion.failure = bounded(outcome.failure);
1562
+ if (outcome.outcome === "drift")
1563
+ generation.promotion.outcome = "drift";
1564
+ generation.status = outcome.outcome === "unknown" ? "promotion_unknown" : "promotion_failed";
1565
+ }
1566
+ await this.saveProductive(handle);
1567
+ if (outcome.outcome === "ready")
1568
+ await this.cleanupPromoted(handle, generation, receipts, scope);
1569
+ return this.response(state);
1570
+ });
1571
+ }
1572
+ async releaseRejected(handle, task, attempt, worker, scope) {
1573
+ const state = handle.state;
1574
+ const candidates = state.integration.candidates.filter((item) => item.taskId === task.taskId && item.attempt === attempt.number);
1575
+ for (const candidate of candidates)
1576
+ candidate.worker = "release_pending";
1577
+ await this.saveProductive(handle);
1578
+ try {
1579
+ for (const kind of CLEANUP_KINDS) {
1580
+ const step = attempt.cleanup.find((item) => item.kind === kind);
1581
+ if (step.status === "completed")
1582
+ continue;
1583
+ step.status = "running";
1584
+ await this.saveProductive(handle);
1585
+ if (kind === "worker_tab" || kind === "workspace") {
1586
+ const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.cleanupHost({
1587
+ requestId: state.request.id, kind, task: changesetTaskRequest(state, task.taskId), attempt,
1588
+ }, context));
1589
+ if (result.outcome === "blocked")
1590
+ throw new Error(result.failure);
1591
+ }
1592
+ else {
1593
+ const candidate = attempt.termination.candidate;
1594
+ const result = await this.callProductive(handle, scope, (context) => this.integrationGit.release(state.root, worker, candidate, kind, context.signal));
1595
+ if (result.outcome !== "ready")
1596
+ throw new Error(result.failure);
1597
+ }
1598
+ step.status = "completed";
1599
+ delete step.failure;
1600
+ await this.saveProductive(handle);
1601
+ }
1602
+ for (const candidate of candidates)
1603
+ candidate.worker = "released";
1604
+ await this.saveProductive(handle);
1605
+ }
1606
+ catch (error) {
1607
+ const pending = attempt.cleanup.find((step) => step.status === "running");
1608
+ if (pending)
1609
+ pending.failure = bounded(`Release requires attention: ${errorText(error)}`);
1610
+ await this.saveProductive(handle);
1611
+ }
1612
+ }
1613
+ async releaseSuperseded(handle, generation, tip, scope) {
1614
+ generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
1615
+ await this.saveProductive(handle);
1616
+ try {
1617
+ for (const kind of ["worktree", "branch"]) {
1618
+ const step = generation.cleanup.find((item) => item.kind === kind);
1619
+ if (step.status === "completed")
1620
+ continue;
1621
+ step.status = "running";
1622
+ await this.saveProductive(handle);
1623
+ const result = await this.callProductive(handle, scope, (context) => this.integrationGit.release(handle.state.root, generation.worktree, tip, kind, context.signal));
1624
+ if (result.outcome !== "ready")
1625
+ throw new Error(result.failure);
1626
+ step.status = "completed";
1627
+ delete step.failure;
1628
+ await this.saveProductive(handle);
1629
+ }
1630
+ }
1631
+ catch (error) {
1632
+ const pending = generation.cleanup.find((step) => step.status === "running");
1633
+ if (pending)
1634
+ pending.failure = bounded(`Release requires attention: ${errorText(error)}`);
1635
+ await this.saveProductive(handle);
1636
+ }
1637
+ }
1638
+ async cleanupPromoted(handle, generation, receipts, scope) {
1639
+ const state = handle.state;
1640
+ const promoted = generation.promotion?.mainAfter;
1641
+ if (generation.status !== "promoted" || !promoted || !generation.worktree)
1642
+ throw new Error("Cleanup requires exact proven promotion.");
1643
+ // Promotion proves selection independently of worker or checkout cleanup.
1644
+ for (const stage of generation.stages)
1645
+ changesetTaskState(state, stage.taskId).status = "completed";
1646
+ const generationCleanup = generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
1647
+ const stepLists = [...generation.stages.map((stage) => changesetTaskState(state, stage.taskId).attempts[stage.attempt - 1].cleanup),
1648
+ generationCleanup];
1649
+ // Record each failure on the step it blocks; other stages and the integration checkout continue.
1650
+ const blocked = (steps, error) => {
1651
+ const step = steps.find((item) => item.status === "running") ?? steps.find((item) => item.status !== "completed");
1652
+ if (step)
1653
+ step.failure = bounded(`Cleanup requires attention: ${errorText(error)}`);
1654
+ };
1655
+ await this.saveProductive(handle);
1656
+ let main;
1657
+ try {
1658
+ main = await this.callProductive(handle, scope, (context) => this.gitRuntime.inspectMain({ root: state.root }, context));
1659
+ }
1660
+ catch (error) {
1661
+ for (const steps of stepLists)
1662
+ blocked(steps, error);
1663
+ }
1664
+ if (main && !sameIdentity(main, promoted)) {
1665
+ for (const steps of stepLists)
1666
+ blocked(steps, new Error("Main changed or became dirty after promotion; cleanup is blocked."));
1667
+ }
1668
+ else if (main) {
1669
+ for (const stage of generation.stages) {
1670
+ const task = changesetTaskState(state, stage.taskId);
1671
+ const attempt = task.attempts[stage.attempt - 1];
1672
+ try {
1673
+ await this.cleanupPromotedStage(handle, generation, receipts, promoted, stage, task, attempt, scope);
1674
+ }
1675
+ catch (error) {
1676
+ blocked(attempt.cleanup, error);
1677
+ }
1678
+ await this.saveProductive(handle);
1679
+ }
1680
+ try {
1681
+ for (const step of generationCleanup) {
1682
+ if (step.status === "completed")
1683
+ continue;
1684
+ step.status = "running";
1685
+ await this.saveProductive(handle);
1686
+ const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, generation.worktree, generation.expectedMain, receipts, promoted, step.kind, context.signal, generation.correction));
1687
+ if (result.outcome !== "ready")
1688
+ throw new Error(result.failure);
1689
+ step.status = "completed";
1690
+ delete step.failure;
1691
+ await this.saveProductive(handle);
1692
+ }
1693
+ }
1694
+ catch (error) {
1695
+ blocked(generationCleanup, error);
1696
+ }
1697
+ }
1698
+ // Promotion remains proven; the request stays visible until every promoted resource is released.
1699
+ if (stepLists.every((steps) => steps.every((step) => step.status === "completed"))
1700
+ && state.tasks.every((task) => task.status === "completed" || task.status === "rejected")
1701
+ && state.integration.candidates.every((candidate) => candidate.decision || generation.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt && sameIdentity(stage.source, candidate.tip)))) {
1702
+ state.final = { status: "passed", identity: promoted, checks: generation.checks,
1703
+ ...(generation.review ? { review: generation.review } : {}) };
1704
+ state.status = "completed";
1705
+ state.accepted = true;
1706
+ state.acceptedAt = this.coordinatorRuntime.now();
1707
+ }
1708
+ await this.saveProductive(handle);
1709
+ }
1710
+ async cleanupPromotedStage(handle, generation, receipts, promoted, stage, task, attempt, scope) {
1711
+ const state = handle.state;
1712
+ const candidate = state.integration.candidates.find((item) => item.taskId === stage.taskId && item.attempt === stage.attempt
1713
+ && sameIdentity(item.tip, stage.source));
1714
+ const worker = allocationByKind(attempt, "worktree")?.worktree;
1715
+ if (!worker)
1716
+ throw new Error("Promoted worker lacks a proven checkout.");
1717
+ candidate.worker = "release_pending";
1718
+ await this.saveProductive(handle);
1719
+ if (attempt.termination?.status !== "terminated") {
1720
+ if (attempt.termination) {
1721
+ const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.reconcileWorkerTermination({
1722
+ task: changesetTaskRequest(state, task.taskId), attempt,
1723
+ workerId: attempt.termination.workerId, candidate: stage.source,
1724
+ }, context));
1725
+ if (result.outcome !== "terminated")
1726
+ throw new Error("Worker termination cannot be proved; never replay termination.");
1727
+ attempt.termination = { status: "terminated", workerId: attempt.termination.workerId,
1728
+ candidate: stage.source, at: this.coordinatorRuntime.now() };
1729
+ await this.saveProductive(handle);
1730
+ }
1731
+ else if (!await this.terminateWithSafety(handle, task, attempt, stage.source, scope.signal)) {
1732
+ throw new Error(`Worker termination was not proved: ${attempt.termination.failure}`);
1733
+ }
1734
+ }
1735
+ for (const kind of CLEANUP_KINDS) {
1736
+ const step = attempt.cleanup.find((item) => item.kind === kind);
1737
+ if (step.status === "completed")
1738
+ continue;
1739
+ step.status = "running";
1740
+ await this.saveProductive(handle);
1741
+ if (kind === "worker_tab" || kind === "workspace") {
1742
+ const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.cleanupHost({
1743
+ requestId: state.request.id, kind, task: changesetTaskRequest(state, task.taskId), attempt,
1744
+ }, context));
1745
+ if (result.outcome === "blocked")
1746
+ throw new Error(result.failure);
1747
+ }
1748
+ else {
1749
+ const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, worker, generation.expectedMain, receipts, promoted, kind, context.signal, generation.correction));
1750
+ if (result.outcome !== "ready")
1751
+ throw new Error(result.failure);
1752
+ }
1753
+ step.status = "completed";
1754
+ delete step.failure;
1755
+ await this.saveProductive(handle);
1756
+ }
1757
+ candidate.worker = "released";
1758
+ // Same-attempt revisions share this exact worker checkout; release historical
1759
+ // rejected tips only after the physical checkout has been proved removed.
1760
+ for (const old of state.integration.candidates.filter((item) => item.decision === "rejected"
1761
+ && item.taskId === stage.taskId && item.attempt === stage.attempt))
1762
+ old.worker = "released";
1763
+ }
1764
+ async requireExactIntegration(handle, generation, receipts, scope, allowDirtyMain = false) {
1765
+ const root = handle.state.root;
1766
+ const worktree = generation.worktree;
1767
+ const tip = generation.correction
1768
+ ? await this.callProductive(handle, scope, async (context) => {
1769
+ await this.integrationGit.inspectCorrection(root, worktree, generation.expectedMain, receipts, generation.correction.from, generation.correction.to, context.signal);
1770
+ return generation.correction.to;
1771
+ })
1772
+ : await this.callProductive(handle, scope, (context) => this.integrationGit.inspectCombined(root, worktree, generation.expectedMain, receipts, context.signal));
1773
+ if (!sameIdentity(tip, generation.combinedTip) || !isCleanCommitted(tip))
1774
+ throw new Error("Combined tip changed or became dirty.");
1775
+ for (const stage of generation.stages) {
1776
+ const task = changesetTaskState(handle.state, stage.taskId);
1777
+ const worker = task.attempts[stage.attempt - 1]?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
1778
+ if (!worker)
1779
+ throw new Error("Selected worker has no owned worktree.");
1780
+ await this.callProductive(handle, scope, (context) => this.integrationGit.inspectWorker(root, worker, stage.source, context.signal));
1781
+ }
1782
+ const main = await this.callProductive(handle, scope, (context) => allowDirtyMain
1783
+ ? this.gitRuntime.inspectMainBase({ root }, context) : this.gitRuntime.inspectMain({ root }, context));
1784
+ if (!sameIdentity(main, generation.expectedMain))
1785
+ throw new Error("Main changed or became dirty before promotion.");
1786
+ }
1787
+ async listOrphanRequests(cwd) {
1788
+ const primary = await primaryCheckout(cwd);
1789
+ const { states, invalidIds } = await this.store.listOrphans(primary);
1790
+ return { invalidIds, requests: states.map((handle) => ({
1791
+ id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state, true),
1792
+ directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
1793
+ })) };
1794
+ }
1795
+ discardGuards(directory, root, guards) {
1796
+ return { ...guards, blocked: async () => {
1797
+ if (this.productiveLifecycles.has(root))
1798
+ return "A local productive job is active.";
1799
+ if (await this.store.hasProductiveRunLeaseInDirectory(directory))
1800
+ return "A live productive lease owns this request root.";
1801
+ return await guards.blocked();
1802
+ } };
1803
+ }
1804
+ async discardPlan(cwd, id, directory, guards) {
1805
+ const primary = await primaryCheckout(cwd);
1806
+ directory ??= this.store.stateDirectory(realpathSync.native(cwd));
1807
+ const handle = await this.store.loadFromDirectory(directory, id);
1808
+ return { directory, root: handle.state.root, requestId: id, items: await inspectDiscard(primary, cwd, handle.state, this.discardGuards(directory, handle.state.root, guards)) };
1809
+ }
1810
+ /** Command-only explicit discard. No tool calls this method. */
1811
+ async discard(cwd, plan, guards) {
1812
+ const primary = await primaryCheckout(cwd);
1813
+ return await this.store.withDirectoryLock(plan.directory, async () => {
1814
+ const handle = await this.store.loadFromDirectory(plan.directory, plan.requestId);
1815
+ const state = handle.state;
1816
+ if (state.root !== plan.root)
1817
+ throw new Error("Request root changed since review.");
1818
+ const checked = this.discardGuards(plan.directory, state.root, guards);
1819
+ const blocked = await checked.blocked();
1820
+ if (blocked || !checked.current())
1821
+ throw new Error(blocked ?? "Session changed; reopen /subagent.");
1822
+ const removals = await removeDiscardResources(handle, primary, cwd, plan.items, checked, async (resource, verify) => {
1823
+ await verify();
1824
+ state.status = "aborted";
1825
+ state.accepted = false;
1826
+ delete state.acceptedAt;
1827
+ if (state.final.status === "passed") {
1828
+ state.final.status = "superseded";
1829
+ state.final.failure = "User confirmed discard from /subagent.";
1830
+ }
1831
+ delete state.recovery;
1832
+ state.updatedAt = this.coordinatorRuntime.now();
1833
+ // Invalidate readiness before termination or supersession; rejected candidates retain exact accounting.
1834
+ for (const candidate of state.integration.candidates) {
1835
+ candidate.decision = "rejected";
1836
+ const task = changesetTaskState(state, candidate.taskId);
1837
+ if (candidate.attempt !== task.attempts.length)
1838
+ continue;
1839
+ if (candidate.worker !== "retained") {
1840
+ task.status = "rejected";
1841
+ delete task.failure;
1842
+ }
1843
+ else
1844
+ this.attention(task, "User confirmed discard of this request.");
1845
+ }
1846
+ for (const generation of state.integration.generations) {
1847
+ if (generation.status === "superseded")
1848
+ continue;
1849
+ generation.status = "superseded";
1850
+ generation.supersededFrom = "discarded";
1851
+ generation.failure = "User confirmed discard from /subagent.";
1852
+ delete generation.promotion;
1853
+ delete generation.supersededPromotion;
1854
+ delete generation.combinedTip;
1855
+ delete generation.checks;
1856
+ delete generation.review;
1857
+ }
1858
+ await handle.save();
1859
+ await this.closeDiscardResource(handle, resource, verify);
1860
+ });
1861
+ for (const candidate of state.integration.candidates) {
1862
+ const task = changesetTaskState(state, candidate.taskId);
1863
+ const attempt = task.attempts[candidate.attempt - 1];
1864
+ if (candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
1865
+ || attempt.cleanup.some((step) => step.status !== "completed"))
1866
+ continue;
1867
+ candidate.worker = "released";
1868
+ if (attempt === task.attempts.at(-1)) {
1869
+ task.status = "rejected";
1870
+ delete task.failure;
1871
+ }
1872
+ }
1873
+ await handle.save();
1874
+ return removals;
1875
+ });
1876
+ }
1877
+ async terminateAbortedAttempt(handle, task, attempt, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
1878
+ if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
1879
+ return;
1880
+ await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), signal, deadline);
1881
+ }
1882
+ async cleanupAbortedHost(handle, task, attempt, kind, scope) {
1883
+ const result = await scope.call((context) => this.hostRuntime.cleanupHost({
1884
+ requestId: handle.state.request.id, kind, task: changesetTaskRequest(handle.state, task.taskId), attempt,
1885
+ }, context));
1886
+ if (result.outcome === "blocked")
1887
+ throw new Error(result.failure);
1888
+ }
1889
+ async closeDiscardResource(handle, resource, verify) {
1890
+ const { task, attempt } = resource;
1891
+ if (!task || !attempt)
1892
+ return;
1893
+ await verify();
1894
+ await this.terminateAbortedAttempt(handle, task, attempt);
1895
+ if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated")
1896
+ throw new Error("Worker termination remains unproved.");
1897
+ const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now());
1898
+ try {
1899
+ for (const step of attempt.cleanup) {
1900
+ if (step.kind !== "worker_tab" && step.kind !== "workspace" || step.status === "completed")
1901
+ continue;
1902
+ await verify();
1903
+ step.status = "running";
1904
+ delete step.failure;
1905
+ await handle.save();
1906
+ await verify();
1907
+ if (allocationByKind(attempt, step.kind))
1908
+ await this.cleanupAbortedHost(handle, task, attempt, step.kind, scope);
1909
+ step.status = "completed";
1910
+ await handle.save();
1911
+ }
1912
+ }
1913
+ finally {
1914
+ scope.close();
1915
+ }
1916
+ }
1917
+ async abort(id, root, outerSignal) {
1918
+ root = realpathSync.native(root);
1919
+ return await this.store.withLock(root, async (lifecycle) => {
1920
+ const handle = await this.store.load(root, id);
1921
+ const state = handle.state;
1922
+ if (terminal(state) && state.status !== "aborted")
1923
+ return this.response(state);
1924
+ const candidates = state.integration.candidates.filter((candidate) => candidate.worker !== "released");
1925
+ const generations = state.integration.generations.filter((generation) => generation.worktree
1926
+ && !generation.cleanup?.every((step) => step.status === "completed"));
1927
+ // Abort removes only unchanged resources: an empty refreshed generation, or candidate work proved already gone.
1928
+ if ((candidates.length || generations.length) && (lifecycle.productiveRunLeaseActive
1929
+ || generations.some((generation) => generation.status === "promotion_failed" ? generation.promotion?.outcome !== "drift"
1930
+ : !["staging", "conflict", "validation_failed", "ready", "superseded"].includes(generation.status))
1931
+ || ((candidates.length || generations.some((generation) => generation.stages.length))
1932
+ && !await this.retainedWorkAbsent(state, candidates, generations, outerSignal)))) {
1933
+ throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort, unless their checkouts and branches are proved absent.");
1934
+ }
1935
+ for (const candidate of candidates.filter((item) => !item.decision)) {
1936
+ candidate.decision = "rejected";
1937
+ this.attention(changesetTaskState(state, candidate.taskId), "Abort rejected this candidate after proving its checkout and branch absent.");
1938
+ }
1939
+ for (const generation of generations.filter((item) => item.status !== "superseded")) {
1940
+ supersedeGeneration(generation, "Request aborted; the integration checkout is released.");
1941
+ }
1942
+ const activeControls = this.activeControls(root, id);
1943
+ for (const { control } of activeControls)
1944
+ control.close();
1945
+ if (!activeControls.length
1946
+ && !lifecycle.productiveRunLeaseActive
1947
+ && this.recoverInterrupted(state)) {
1948
+ await handle.save();
1949
+ }
1950
+ const safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS;
1951
+ for (const task of state.tasks) {
1952
+ if (task.kind !== "changeset")
1953
+ continue;
1954
+ for (const attempt of task.attempts) {
1955
+ try {
1956
+ await this.terminateAbortedAttempt(handle, task, attempt, outerSignal, safetyDeadline);
1957
+ }
1958
+ catch (error) {
1959
+ this.attention(task, errorText(error));
1960
+ }
1961
+ }
1962
+ }
1963
+ state.status = "aborted";
1964
+ delete state.recovery;
1965
+ state.accepted = false;
1966
+ state.updatedAt = this.coordinatorRuntime.now();
1967
+ await handle.save();
1968
+ // In-flight checks or allocation callbacks must settle before removing their checkout.
1969
+ if (!lifecycle.productiveRunLeaseActive)
1970
+ await this.cleanupAborted(handle, outerSignal);
1971
+ return this.response(state);
1972
+ }, { purpose: "abort" });
1973
+ }
1974
+ /** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
1975
+ async retainedWorkAbsent(state, candidates, generations, signal) {
1976
+ const checkouts = [
1977
+ ...candidates.map((candidate) => allocationByKind(changesetTaskState(state, candidate.taskId).attempts[candidate.attempt - 1], "worktree")?.worktree),
1978
+ ...generations.filter((generation) => generation.stages.length).map((generation) => generation.worktree),
1979
+ ];
1980
+ const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
1981
+ try {
1982
+ for (const checkout of checkouts) {
1983
+ if (!checkout || !await scope.call((context) => this.integrationGit.absent(state.root, checkout, context.signal)))
1984
+ return false;
1985
+ }
1986
+ return true;
1987
+ }
1988
+ finally {
1989
+ scope.close();
1990
+ }
1991
+ }
1992
+ async cleanupAborted(handle, signal) {
1993
+ const state = handle.state;
1994
+ const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
1995
+ try {
1996
+ for (const task of state.tasks) {
1997
+ if (task.kind !== "changeset")
1998
+ continue;
1999
+ for (const attempt of task.attempts) {
2000
+ if (attempt.cleanup.every((step) => step.status === "completed"))
2001
+ continue;
2002
+ const worker = allocationByKind(attempt, "worktree")?.worktree;
2003
+ const expected = worker ? { ...attempt.waveBase, branch: `refs/heads/${worker.branch}` } : undefined;
2004
+ let pending = attempt.cleanup.find((step) => step.status !== "completed");
2005
+ try {
2006
+ if (attempt.allocations.some((item) => item.status === "allocating" || item.status === "unknown")) {
2007
+ throw new Error("Allocation ownership is uncertain; preserve the recorded resources for inspection.");
2008
+ }
2009
+ if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated") {
2010
+ throw new Error("Exact worker termination remains unproved.");
2011
+ }
2012
+ if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head
2013
+ && !await scope.call((context) => this.integrationGit.absent(state.root, worker, context.signal))) {
2014
+ throw new Error("Aborted worker may contain committed work; automatic cleanup preserves it.");
2015
+ }
2016
+ for (const step of attempt.cleanup) {
2017
+ if (step.status === "completed")
2018
+ continue;
2019
+ pending = step;
2020
+ const kind = step.kind;
2021
+ step.status = "running";
2022
+ delete step.failure;
2023
+ await handle.save();
2024
+ if ((kind === "worker_tab" || kind === "workspace") && allocationByKind(attempt, kind)) {
2025
+ await this.cleanupAbortedHost(handle, task, attempt, kind, scope);
2026
+ }
2027
+ else if ((kind === "worktree" || kind === "branch") && worker && expected) {
2028
+ const result = await scope.call((context) => this.integrationGit.release(state.root, worker, expected, kind, context.signal));
2029
+ if (result.outcome !== "ready")
2030
+ throw new Error(result.failure);
2031
+ }
2032
+ step.status = "completed";
2033
+ await handle.save();
2034
+ }
2035
+ }
2036
+ catch (error) {
2037
+ if (pending.status === "completed")
2038
+ pending.status = "running";
2039
+ pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
2040
+ await handle.save();
2041
+ }
2042
+ }
2043
+ }
2044
+ // Abort admitted retained candidates only after proving their checkouts absent.
2045
+ for (const candidate of state.integration.candidates) {
2046
+ const task = changesetTaskState(state, candidate.taskId);
2047
+ const attempt = task.attempts[candidate.attempt - 1];
2048
+ if (candidate.worker === "released" || candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
2049
+ || attempt.cleanup.some((step) => step.status !== "completed"))
2050
+ continue;
2051
+ candidate.worker = "released";
2052
+ if (task.status === "needs_attention" && attempt === task.attempts.at(-1) && !attempt.superseded) {
2053
+ task.status = "rejected";
2054
+ delete task.failure;
2055
+ }
2056
+ await handle.save();
2057
+ }
2058
+ for (const generation of state.integration.generations) {
2059
+ if (!generation.worktree || generation.status !== "superseded")
2060
+ continue;
2061
+ const steps = generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
2062
+ try {
2063
+ for (const step of steps) {
2064
+ if (step.status === "completed")
2065
+ continue;
2066
+ step.status = "running";
2067
+ delete step.failure;
2068
+ await handle.save();
2069
+ const result = await scope.call((context) => this.integrationGit.release(state.root, generation.worktree, retainedGenerationTip(generation), step.kind, context.signal));
2070
+ if (result.outcome !== "ready")
2071
+ throw new Error(result.failure);
2072
+ step.status = "completed";
2073
+ await handle.save();
2074
+ }
2075
+ }
2076
+ catch (error) {
2077
+ const pending = steps.find((step) => step.status === "running");
2078
+ if (pending)
2079
+ pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
2080
+ await handle.save();
2081
+ }
2082
+ }
2083
+ }
2084
+ finally {
2085
+ scope.close();
2086
+ }
2087
+ }
2088
+ async status(id, root, outerSignal) {
2089
+ root = realpathSync.native(root);
2090
+ return await this.store.withLock(root, async () => {
2091
+ const handle = await this.store.load(root, id);
2092
+ const deadline = this.coordinatorRuntime.now() + STATUS_INSPECTION_BUDGET_MS;
2093
+ const scope = new DeadlineScope(deadline, () => this.coordinatorRuntime.now(), outerSignal);
2094
+ try {
2095
+ let main;
2096
+ try {
2097
+ const actual = await scope.call(async (context) => await this.gitRuntime.inspectMain({ root: handle.state.root }, context));
2098
+ main = {
2099
+ status: sameIdentity(actual, handle.state.main) ? "current" : "drifted",
2100
+ expected: handle.state.main,
2101
+ actual,
2102
+ };
2103
+ }
2104
+ catch (error) {
2105
+ main = {
2106
+ status: "unavailable",
2107
+ expected: handle.state.main,
2108
+ failure: `Read-only Main inspection failed: ${errorText(error)}`,
2109
+ };
2110
+ }
2111
+ return this.response(handle.state, main);
2112
+ }
2113
+ finally {
2114
+ scope.close();
2115
+ }
2116
+ }, { purpose: "status" });
2117
+ }
2118
+ async run(handle, scope, forceTextTaskId, stagedSnapshot) {
2119
+ const state = handle.state;
2120
+ const lifecycle = this.productiveLifecycles.get(state.root);
2121
+ lifecycle.handle = handle;
2122
+ let saveOnExit = true;
2123
+ state.status = "running";
2124
+ state.updatedAt = this.coordinatorRuntime.now();
2125
+ try {
2126
+ while (state.tasks.some((task) => task.status !== "completed")) {
2127
+ const attention = forceTextTaskId ? undefined : state.tasks.find((task) => task.status === "needs_attention");
2128
+ if (attention) {
2129
+ state.status = "needs_attention";
2130
+ return this.response(state);
2131
+ }
2132
+ const ready = readyPendingTasks(state).filter((task) => (!forceTextTaskId || task.taskId === forceTextTaskId)
2133
+ && (!stagedSnapshot || (hasChangesetDependency(state, task.taskId)
2134
+ && stagedDependencySnapshot(state, task.taskId, stagedSnapshot))));
2135
+ if (!ready.length) {
2136
+ if (!stagedSnapshot && !state.integration.candidates.length)
2137
+ throw new Error("No dependency wave is ready.");
2138
+ state.status = "needs_attention";
2139
+ return this.response(state);
2140
+ }
2141
+ let actualMain;
2142
+ try {
2143
+ actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
2144
+ }
2145
+ catch (error) {
2146
+ this.rethrowStopped(error);
2147
+ const failure = isDeadline(error, scope)
2148
+ ? "The operation safety deadline expired before dependency-wave dispatch."
2149
+ : `Main inspection failed before dependency-wave dispatch: ${errorText(error)}`;
2150
+ for (const task of ready)
2151
+ this.attention(task, failure);
2152
+ state.status = "needs_attention";
2153
+ return this.response(state);
2154
+ }
2155
+ if (!sameIdentity(actualMain, state.main)) {
2156
+ for (const task of ready)
2157
+ this.attention(task, "Main drifted before dependency-wave dispatch.");
2158
+ state.status = "needs_attention";
2159
+ return this.response(state);
2160
+ }
2161
+ if (stagedSnapshot) {
2162
+ const generation = state.integration.generations.at(-1);
2163
+ await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
2164
+ }
2165
+ const wave = {
2166
+ number: state.waves.length + 1,
2167
+ base: stagedSnapshot ?? state.main,
2168
+ taskIds: ready.map((task) => task.taskId),
2169
+ status: "dispatching",
2170
+ };
2171
+ state.waves.push(wave);
2172
+ for (const task of ready) {
2173
+ if (task.kind === "text") {
2174
+ task.status = "running";
2175
+ task.attempts.push({ number: task.attempts.length + 1, status: "running" });
2176
+ continue;
2177
+ }
2178
+ task.status = "allocating";
2179
+ task.attempts.push({
2180
+ number: task.attempts.length + 1,
2181
+ waveNumber: wave.number,
2182
+ waveBase: wave.base,
2183
+ correlationToken: runtimeToken(this.coordinatorRuntime.randomToken()),
2184
+ allocationGeneration: 1,
2185
+ allocations: [],
2186
+ prompts: [],
2187
+ cleanup: CLEANUP_KINDS.map((kind) => ({ kind, status: "pending" })),
2188
+ });
2189
+ }
2190
+ await this.saveProductive(handle);
2191
+ const dispatched = await Promise.allSettled(ready.map(async (task) => {
2192
+ try {
2193
+ await this.dispatchTask(handle, task, scope);
2194
+ if (task.kind === "changeset" && task.status === "ready_to_integrate") {
2195
+ this.retainCandidate(state, task);
2196
+ await this.saveProductive(handle);
2197
+ }
2198
+ }
2199
+ catch (error) {
2200
+ this.rethrowStopped(error);
2201
+ const failure = isDeadline(error, scope)
2202
+ ? "The operation safety deadline expired during task dispatch."
2203
+ : `Task dispatch was interrupted: ${errorText(error)}`;
2204
+ this.attention(task, bounded(failure));
2205
+ }
2206
+ }));
2207
+ const stopped = dispatched.find((result) => result.status === "rejected" && result.reason instanceof DurableRunStopped);
2208
+ if (stopped)
2209
+ throw stopped.reason;
2210
+ const rejected = dispatched.find((result) => result.status === "rejected");
2211
+ if (rejected)
2212
+ throw rejected.reason;
2213
+ if (ready.some((task) => task.kind === "text"
2214
+ ? task.status !== "completed"
2215
+ : task.status !== "ready_to_integrate")) {
2216
+ wave.status = "needs_attention";
2217
+ state.status = "needs_attention";
2218
+ return this.response(state);
2219
+ }
2220
+ if (ready.every((task) => task.kind === "text")) {
2221
+ wave.status = "completed";
2222
+ await this.saveProductive(handle);
2223
+ forceTextTaskId = undefined;
2224
+ continue;
2225
+ }
2226
+ wave.status = "needs_attention";
2227
+ state.status = "needs_attention";
2228
+ await this.saveProductive(handle);
2229
+ return this.response(state);
2230
+ }
2231
+ return await this.runFinal(handle, scope);
2232
+ }
2233
+ catch (error) {
2234
+ if (error instanceof DurableRunStopped) {
2235
+ saveOnExit = false;
2236
+ return this.response(handle.state);
2237
+ }
2238
+ const activeTasks = state.tasks.filter((task) => !["pending", "completed", "needs_attention"].includes(task.status));
2239
+ if (activeTasks.length) {
2240
+ const failure = isDeadline(error, scope) ? "The operation safety deadline expired." : `Execution was interrupted: ${errorText(error)}`;
2241
+ for (const active of activeTasks)
2242
+ this.attention(active, failure);
2243
+ }
2244
+ else if (state.final.status === "running") {
2245
+ state.final.status = "interrupted";
2246
+ state.final.failure = bounded(isDeadline(error, scope) ? "The operation safety deadline expired." : `Final gate was interrupted: ${errorText(error)}`);
2247
+ }
2248
+ state.status = "needs_attention";
2249
+ state.accepted = false;
2250
+ return this.response(state);
2251
+ }
2252
+ finally {
2253
+ this.closeRequestControls(state.root, state.request.id);
2254
+ try {
2255
+ if (saveOnExit) {
2256
+ state.updatedAt = this.coordinatorRuntime.now();
2257
+ await this.saveProductive(handle);
2258
+ }
2259
+ }
2260
+ finally {
2261
+ if (lifecycle.handle === handle)
2262
+ delete lifecycle.handle;
2263
+ }
2264
+ }
2265
+ }
2266
+ async dispatchTask(handle, task, scope) {
2267
+ if (task.kind === "text")
2268
+ return await this.dispatchTextTask(handle, task, scope);
2269
+ const state = handle.state;
2270
+ const request = changesetTaskRequest(state, task.taskId);
2271
+ const attempt = latestAttempt(task);
2272
+ try {
2273
+ for (const kind of ALLOCATION_KINDS) {
2274
+ if (allocationByKind(attempt, kind))
2275
+ continue;
2276
+ let intent;
2277
+ if (kind === "worktree") {
2278
+ intent = {
2279
+ kind,
2280
+ generation: attempt.allocationGeneration,
2281
+ token: attempt.correlationToken,
2282
+ status: "allocating",
2283
+ };
2284
+ }
2285
+ else {
2286
+ const plan = runtimeHostAllocationPlan(await this.callProductive(handle, scope, async (context) => await this.hostRuntime.planHostAllocation({
2287
+ requestId: state.request.id,
2288
+ goal: state.request.goal,
2289
+ kind,
2290
+ task: request,
2291
+ attempt,
2292
+ }, context)), kind);
2293
+ intent = {
2294
+ ...plan,
2295
+ generation: attempt.allocationGeneration,
2296
+ token: attempt.correlationToken,
2297
+ status: "allocating",
2298
+ };
2299
+ }
2300
+ attempt.allocations.push(intent);
2301
+ await this.saveProductive(handle);
2302
+ let result;
2303
+ try {
2304
+ if (intent.kind === "worktree") {
2305
+ result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.allocateWorktree({
2306
+ requestId: state.request.id,
2307
+ root: state.root,
2308
+ ...(attempt.waveBase.head !== state.main.head ? { baseRoot: state.integration.generations.at(-1)?.worktree?.path } : {}),
2309
+ intent,
2310
+ task: request,
2311
+ attempt,
2312
+ onPrepared: async (worktree) => {
2313
+ const prepared = worktree;
2314
+ for (const field of ["path", "cwd", "branch", "repoRoot"]) {
2315
+ requireExactAllocationText(prepared[field], `Prepared worktree ${field}`);
2316
+ }
2317
+ if (typeof prepared.baseCommit !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(prepared.baseCommit)
2318
+ || prepared.baseCommit !== attempt.waveBase.head || prepared.path !== prepared.cwd) {
2319
+ throw new Error("Prepared worktree does not match the exact recorded wave plan.");
2320
+ }
2321
+ const exact = prepared;
2322
+ intent.worktree = {
2323
+ path: exact.path,
2324
+ cwd: exact.cwd,
2325
+ branch: exact.branch,
2326
+ repoRoot: exact.repoRoot,
2327
+ baseCommit: exact.baseCommit,
2328
+ };
2329
+ await this.saveProductive(handle);
2330
+ },
2331
+ }, context));
2332
+ }
2333
+ else {
2334
+ const allocate = async (context) => await this.hostRuntime.allocateHost({
2335
+ requestId: state.request.id,
2336
+ intent,
2337
+ task: request,
2338
+ attempt,
2339
+ ...(intent.kind === "agent" ? { acquireLaunch: async () => {
2340
+ buildChangesetTaskPrompt({
2341
+ goal: state.request.goal,
2342
+ contexts: resolveTextTaskContexts(state, request),
2343
+ task: request,
2344
+ kind: "initial",
2345
+ worktreeCwd: intent.worktreeCwd,
2346
+ });
2347
+ const launch = await this.coordinatorRuntime.acquireLaunch(request.role, request.modelClass, context);
2348
+ if (launch.launch.role !== request.role || launch.launch.modelClass !== request.modelClass) {
2349
+ await withTransientLaunch(launch, async () => {
2350
+ throw new Error("Task launch acquisition returned the wrong Role or model class.");
2351
+ });
2352
+ }
2353
+ return launch;
2354
+ } } : {}),
2355
+ }, context);
2356
+ result = intent.kind === "agent"
2357
+ ? await this.withLifecycleLock(handle, async () => {
2358
+ const allocated = await scope.call(allocate);
2359
+ if (allocated.kind !== intent.kind)
2360
+ throw new Error("Agent allocation returned the wrong result kind.");
2361
+ if (allocated.outcome === "owned") {
2362
+ applyOwnedAllocationResult(intent, allocated);
2363
+ await handle.save();
2364
+ }
2365
+ return allocated;
2366
+ })
2367
+ : await this.callProductive(handle, scope, allocate);
2368
+ }
2369
+ }
2370
+ catch (error) {
2371
+ this.rethrowStopped(error);
2372
+ intent.status = "unknown";
2373
+ intent.failure = bounded(`Allocation result is unknown: ${errorText(error)}`);
2374
+ this.attention(task, intent.failure);
2375
+ await this.saveProductive(handle);
2376
+ return;
2377
+ }
2378
+ if (result.kind !== intent.kind) {
2379
+ throw new Error(`${intent.kind} allocation returned the wrong result kind.`);
2380
+ }
2381
+ if (result.outcome !== "owned") {
2382
+ const failure = boundedRuntimeText(result.failure, `${intent.kind} allocation failure`);
2383
+ const possibleResources = result.outcome === "unknown" ? boundedPossibleResources(result.possibleResources) : undefined;
2384
+ intent.status = result.outcome;
2385
+ intent.failure = failure;
2386
+ if (result.outcome === "unknown")
2387
+ intent.possibleResources = possibleResources;
2388
+ this.attention(task, intent.failure);
2389
+ await this.saveProductive(handle);
2390
+ return;
2391
+ }
2392
+ if (intent.kind !== "agent") {
2393
+ applyOwnedAllocationResult(intent, result);
2394
+ await this.saveProductive(handle);
2395
+ }
2396
+ }
2397
+ task.status = "working";
2398
+ await this.saveProductive(handle);
2399
+ await this.driveWorkerSafely(handle, task, scope, "initial");
2400
+ }
2401
+ catch (error) {
2402
+ this.rethrowStopped(error);
2403
+ this.attention(task, isDeadline(error, scope) ? "The operation safety deadline expired during allocation." : `Task allocation was interrupted: ${errorText(error)}`);
2404
+ await this.saveProductive(handle);
2405
+ }
2406
+ }
2407
+ async dispatchTextTask(handle, task, scope) {
2408
+ const state = handle.state;
2409
+ const request = taskRequest(state, task.taskId);
2410
+ if (request.kind !== "text")
2411
+ throw new Error(`Task ${task.taskId} is not a text task.`);
2412
+ const attempt = task.attempts.at(-1);
2413
+ if (!attempt || attempt.status !== "running")
2414
+ throw new Error(`Text task ${task.taskId} has no running attempt.`);
2415
+ const prompt = buildTextTaskPrompt(state.request.goal, request, resolveTextTaskContexts(state, request));
2416
+ let checkoutFailure;
2417
+ const result = await this.callProductive(handle, scope, async (context) => {
2418
+ const wave = state.waves.at(-1);
2419
+ if (!wave?.taskIds.includes(task.taskId))
2420
+ throw new Error("Text task lacks a recorded launch wave.");
2421
+ const baseRoot = wave.base.head === state.main.head ? state.root : state.integration.generations.at(-1)?.worktree?.path;
2422
+ if (!baseRoot)
2423
+ throw new Error("Staged dependency checkout is unavailable.");
2424
+ const inspectBase = baseRoot === state.root ? this.gitRuntime.inspectMainBase.bind(this.gitRuntime) : this.gitRuntime.inspectMain.bind(this.gitRuntime);
2425
+ if (!sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
2426
+ throw new Error("Staged dependency snapshot drifted before text launch.");
2427
+ }
2428
+ const isolated = await createChildWorktree(baseRoot, `${state.request.id}-${task.taskId}-text-${attempt.number}`, undefined, context.signal, async (worktree) => {
2429
+ if (worktree.baseCommit !== wave.base.head || !sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
2430
+ throw new Error("Staged dependency snapshot drifted before text worktree creation.");
2431
+ }
2432
+ }, [state.request.id, task.taskId, `text-${attempt.number}`]);
2433
+ if (!isolated)
2434
+ throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
2435
+ let result;
2436
+ try {
2437
+ if (!sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
2438
+ throw new Error("Staged dependency snapshot drifted during text worktree creation.");
2439
+ }
2440
+ const launchHandle = await this.coordinatorRuntime.acquireLaunch(request.role, request.modelClass, context);
2441
+ if (launchHandle.launch.role !== request.role || launchHandle.launch.modelClass !== request.modelClass) {
2442
+ await withTransientLaunch(launchHandle, async () => {
2443
+ throw new Error("Text task launch acquisition returned the wrong Role or model class.");
2444
+ });
2445
+ }
2446
+ result = await withTransientLaunch(launchHandle, async (verifiedLaunch) => {
2447
+ const launch = { args: [...verifiedLaunch.args], env: { ...verifiedLaunch.env } };
2448
+ return await this.textExecutor.run({
2449
+ signal: context.signal,
2450
+ prepare: async () => ({ launch, task: prompt, cwd: isolated.cwd }),
2451
+ });
2452
+ });
2453
+ // Read-only evidence excludes ignored artifacts; deletion safety still includes them.
2454
+ const inspection = await inspectWorktreeDirty(isolated.path, runGit, false);
2455
+ const head = await runGit(["rev-parse", "--verify", "HEAD"], isolated.path);
2456
+ const branch = await runGit(["symbolic-ref", "--quiet", "HEAD"], isolated.path);
2457
+ checkoutFailure = inspection.failure ? `read-only checkout inspection failed: ${inspection.failure}`
2458
+ : inspection.dirty ? "read-only contract violated: tracked or untracked changes remain"
2459
+ : head.code !== 0 || branch.code !== 0 ? "read-only checkout identity could not be proved"
2460
+ : head.stdout.trim() !== isolated.baseCommit || branch.stdout.trim() !== `refs/heads/${isolated.branch}`
2461
+ ? "read-only contract violated: checkout HEAD or branch changed" : undefined;
2462
+ }
2463
+ finally {
2464
+ // Retention is evidence, not proof of source edits, and must not mask an answer or executor error.
2465
+ attempt.cleanup = await finalizeChildWorktree(isolated);
2466
+ }
2467
+ return result;
2468
+ });
2469
+ if (result.outcome !== "success" || result.exitCode !== 0) {
2470
+ throw new Error("Text task executor did not complete successfully.");
2471
+ }
2472
+ if (result.outputTruncated !== false)
2473
+ throw new Error("Text task executor output was truncated.");
2474
+ const output = result.output.trim();
2475
+ if (!output)
2476
+ throw new Error("Text task executor returned empty output.");
2477
+ if (Buffer.byteLength(output, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
2478
+ throw new Error(`Text task executor output exceeds ${MAX_PERSISTED_RUNTIME_TEXT_BYTES} UTF-8 bytes.`);
2479
+ }
2480
+ try {
2481
+ if (checkoutFailure)
2482
+ throw new Error(`Isolated text task ${checkoutFailure}; inspect ${attempt.cleanup.path} on ${attempt.cleanup.branch}.`);
2483
+ if (attempt.cleanup?.outcome === "recovery")
2484
+ throw new Error(`Isolated text task cleanup requires recovery: ${attempt.cleanup.note}`);
2485
+ const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
2486
+ if (!sameIdentity(actualMain, state.main))
2487
+ throw new Error("Main drifted during text task execution.");
2488
+ const wave = state.waves.at(-1);
2489
+ if (wave.base.head !== state.main.head) {
2490
+ const generation = state.integration.generations.at(-1);
2491
+ if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
2492
+ throw new Error("Staged dependency snapshot was superseded during text task execution.");
2493
+ }
2494
+ await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
2495
+ }
2496
+ }
2497
+ catch (error) {
2498
+ this.rethrowStopped(error);
2499
+ // Retain the completed answer without admitting it as dependency context.
2500
+ attempt.status = "completed";
2501
+ attempt.output = { text: output };
2502
+ this.attention(task, `Task dispatch was interrupted: ${errorText(error)}`);
2503
+ throw error;
2504
+ }
2505
+ attempt.status = "completed";
2506
+ attempt.failure = undefined;
2507
+ attempt.output = { text: output };
2508
+ task.status = "completed";
2509
+ task.failure = undefined;
2510
+ await this.saveProductive(handle);
2511
+ }
2512
+ correctionAllowed(state, request, attempt, postSeal = false) {
2513
+ const effectiveMaximum = Math.min(state.policy.maxCorrections, this.currentPolicy().maxCorrections);
2514
+ return state.correctionCount < effectiveMaximum && (correctionEligible(request, attempt) || Boolean(postSeal
2515
+ && attempt.readiness && attempt.prompts.at(-1)?.status === "settled" && !attempt.termination
2516
+ && attempt.prompts.length < MAX_WORKER_PROMPTS
2517
+ && !attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
2518
+ && attempt.candidate && sameIdentity(attempt.readiness.candidate, attempt.candidate)
2519
+ && checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)));
2520
+ }
2521
+ async driveWorkerSafely(handle, task, scope, initialKind, initialInstruction, postSeal = false) {
2522
+ const control = this.ensureFollowupControl(handle, task);
2523
+ try {
2524
+ await this.driveWorker(handle, task, scope, initialKind, control, initialInstruction, postSeal);
2525
+ }
2526
+ catch (error) {
2527
+ this.rethrowStopped(error);
2528
+ this.attention(task, isDeadline(error, scope)
2529
+ ? "The child operation safety deadline expired during worker execution."
2530
+ : `Worker execution was interrupted: ${errorText(error)}`);
2531
+ await this.saveProductive(handle);
2532
+ }
2533
+ finally {
2534
+ this.closeFollowupControl(handle.state.root, handle.state.request.id, task.taskId, control);
2535
+ }
2536
+ }
2537
+ async driveWorker(handle, task, scope, initialKind, control, initialInstruction, postSeal = false) {
2538
+ const state = handle.state;
2539
+ const request = changesetTaskRequest(state, task.taskId);
2540
+ const attempt = latestAttempt(task);
2541
+ const workerId = allocationByKind(attempt, "agent")?.agentName;
2542
+ if (!workerId)
2543
+ throw new Error("Worker launch has no durably recorded agent ID.");
2544
+ let kind = initialKind;
2545
+ let failure = task.failure;
2546
+ let instruction = initialInstruction;
2547
+ // A nudge keeps its dirty turn's exact clean pre-prompt lineage.
2548
+ let nudgeFrom;
2549
+ for (;;) {
2550
+ if (kind === "followup" && instruction === undefined) {
2551
+ throw new Error("Follow-up worker execution requires an exact queued instruction.");
2552
+ }
2553
+ if (kind === "correction" && !this.correctionAllowed(state, request, attempt, postSeal)) {
2554
+ this.attention(task, "The same-agent correction is unavailable or already used.");
2555
+ await this.saveProductive(handle);
2556
+ return;
2557
+ }
2558
+ if (kind !== "initial") {
2559
+ attempt.readiness = undefined;
2560
+ attempt.preliminaryChecks = undefined;
2561
+ attempt.preliminaryReview = undefined;
2562
+ }
2563
+ let preCandidate;
2564
+ try {
2565
+ preCandidate = nudgeFrom ?? runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
2566
+ root: state.root,
2567
+ task: request,
2568
+ attempt,
2569
+ }, context)), "Pre-prompt candidate identity");
2570
+ nudgeFrom = undefined;
2571
+ }
2572
+ catch (error) {
2573
+ this.rethrowStopped(error);
2574
+ this.attention(task, `Pre-prompt candidate identity is unavailable: ${errorText(error)}`);
2575
+ await this.saveProductive(handle);
2576
+ return;
2577
+ }
2578
+ const worktree = allocationByKind(attempt, "worktree");
2579
+ if (!worktree?.worktree
2580
+ || preCandidate.branch !== `refs/heads/${worktree.worktree.branch}`
2581
+ || !isCleanCommitted(preCandidate)
2582
+ || (kind === "initial" && preCandidate.head !== attempt.waveBase.head)) {
2583
+ this.attention(task, "Pre-prompt task candidate inspection returned an invalid owned worktree identity.");
2584
+ await this.saveProductive(handle);
2585
+ return;
2586
+ }
2587
+ const retainedCandidate = kind === "initial"
2588
+ ? undefined
2589
+ : attempt.candidate ?? attempt.prompts.at(-1)?.candidate ?? attempt.prompts.at(-1)?.preCandidate;
2590
+ if (retainedCandidate && !sameIdentity(preCandidate, retainedCandidate)) {
2591
+ this.attention(task, "The same-agent correction worktree identity drifted before prompting.");
2592
+ await this.saveProductive(handle);
2593
+ return;
2594
+ }
2595
+ if (attempt.prompts.length >= MAX_WORKER_PROMPTS) {
2596
+ this.attention(task, `A task may submit at most ${MAX_WORKER_PROMPTS} worker prompts.`);
2597
+ await this.saveProductive(handle);
2598
+ return;
2599
+ }
2600
+ const prompt = {
2601
+ kind,
2602
+ status: "submitting",
2603
+ preCandidate,
2604
+ ...(instruction !== undefined ? { instruction } : {}),
2605
+ at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
2606
+ };
2607
+ attempt.prompts.push(prompt);
2608
+ if (kind === "correction")
2609
+ state.correctionCount += 1;
2610
+ task.failure = undefined;
2611
+ await this.saveProductive(handle);
2612
+ let worker;
2613
+ try {
2614
+ worker = await this.callProductive(handle, scope, async (context) => await this.hostRuntime.runWorker({
2615
+ goal: state.request.goal,
2616
+ contexts: resolveTextTaskContexts(state, request),
2617
+ task: request,
2618
+ attempt,
2619
+ workerId,
2620
+ kind,
2621
+ preCandidate,
2622
+ ...(failure ? { failure } : {}),
2623
+ ...(instruction ? { instruction } : {}),
2624
+ }, context));
2625
+ }
2626
+ catch (error) {
2627
+ this.rethrowStopped(error);
2628
+ prompt.status = "ambiguous";
2629
+ prompt.failure = bounded(`Prompt result is ambiguous and will not be replayed: ${errorText(error)}`);
2630
+ this.attention(task, prompt.failure);
2631
+ await this.saveProductive(handle);
2632
+ return;
2633
+ }
2634
+ if (worker.outcome === "unknown" || worker.outcome === "interrupted") {
2635
+ prompt.status = "ambiguous";
2636
+ prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, `${worker.outcome} worker diagnostic`);
2637
+ this.attention(task, `${worker.outcome} worker result will not be replayed: ${prompt.failure}`);
2638
+ await this.saveProductive(handle);
2639
+ return;
2640
+ }
2641
+ if (worker.outcome === "not_prompted") {
2642
+ prompt.status = "not_sent";
2643
+ prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, "not-prompted worker diagnostic");
2644
+ this.attention(task, `Worker prompt was not submitted: ${prompt.failure}`);
2645
+ await this.saveProductive(handle);
2646
+ return;
2647
+ }
2648
+ prompt.status = "settled";
2649
+ if (worker.outcome === "uncommitted") {
2650
+ prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, "uncommitted worker diagnostic");
2651
+ if (attempt.prompts.some(isCommitNudge)) {
2652
+ this.attention(task, "The commit nudge was sent, but the worker turn still left uncommitted changes in its worktree; they are retained for inspection.");
2653
+ await this.saveProductive(handle);
2654
+ return;
2655
+ }
2656
+ await this.saveProductive(handle);
2657
+ kind = "followup";
2658
+ instruction = COMMIT_NUDGE_INSTRUCTION;
2659
+ nudgeFrom = preCandidate;
2660
+ continue;
2661
+ }
2662
+ if (worker.outcome === "blocked") {
2663
+ failure = boundedRuntimeDiagnostic(worker.diagnostic, "blocked worker diagnostic");
2664
+ prompt.failure = failure;
2665
+ await this.saveProductive(handle);
2666
+ }
2667
+ else {
2668
+ if (worker.outcome !== "candidate")
2669
+ throw new Error("Unexpected worker result.");
2670
+ worker.candidate = runtimeIdentity(worker.candidate, "Settled worker candidate identity");
2671
+ if (!isCleanCommitted(worker.candidate) || worker.candidate.head === preCandidate.head) {
2672
+ this.attention(task, "Settled worker did not produce a new clean committed candidate.");
2673
+ await this.saveProductive(handle);
2674
+ return;
2675
+ }
2676
+ prompt.candidate = worker.candidate;
2677
+ attempt.candidate = worker.candidate;
2678
+ attempt.candidateBase ??= attempt.waveBase;
2679
+ const checks = await this.runCheckBatch(handle, request.checks, worker.candidate, "preliminary", scope, task.taskId);
2680
+ attempt.preliminaryChecks = checks;
2681
+ await this.saveProductive(handle);
2682
+ if (!checks.passed) {
2683
+ failure = sameIdentity(checks.identityAfter, worker.candidate)
2684
+ ? "A declared task check failed on the recorded candidate."
2685
+ : "A declared task check changed the recorded candidate.";
2686
+ }
2687
+ else {
2688
+ failure = undefined;
2689
+ }
2690
+ if (!failure) {
2691
+ const queuedFollowup = control.takeQueuedFollowupOrSeal();
2692
+ if (queuedFollowup) {
2693
+ kind = "followup";
2694
+ instruction = queuedFollowup;
2695
+ task.status = "working";
2696
+ continue;
2697
+ }
2698
+ const workerId = allocationByKind(attempt, "agent")?.agentName;
2699
+ if (!workerId || attempt.termination || !attempt.candidate || !attempt.candidateBase
2700
+ || !checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)) {
2701
+ throw new Error("Readiness requires an exact live owned worker and passing candidate lineage.");
2702
+ }
2703
+ if (request.judgment) {
2704
+ attempt.preliminaryReview = await this.runReview(handle, request.judgment.criterion, attempt.candidateBase, attempt.candidate, request.judgment.role, request.judgment.modelClass, "preliminary", scope, task.taskId);
2705
+ await this.saveProductive(handle);
2706
+ if (!attempt.preliminaryReview.passed) {
2707
+ this.attention(task, "Preliminary Judgment did not return exact PASS on the worker candidate.");
2708
+ return;
2709
+ }
2710
+ }
2711
+ attempt.readiness = {
2712
+ candidate: attempt.candidate,
2713
+ base: attempt.candidateBase,
2714
+ at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
2715
+ };
2716
+ task.status = "ready_to_integrate";
2717
+ task.failure = undefined;
2718
+ await this.saveProductive(handle);
2719
+ return;
2720
+ }
2721
+ }
2722
+ if (this.correctionAllowed(state, request, attempt)) {
2723
+ kind = "correction";
2724
+ instruction = undefined;
2725
+ continue;
2726
+ }
2727
+ const queuedFollowup = control.takeQueuedFollowup();
2728
+ if (queuedFollowup) {
2729
+ kind = "followup";
2730
+ instruction = queuedFollowup;
2731
+ task.status = "working";
2732
+ continue;
2733
+ }
2734
+ this.attention(task, failure ?? "Task candidate needs attention after its correction window closed.");
2735
+ await this.saveProductive(handle);
2736
+ return;
2737
+ }
2738
+ }
2739
+ terminationCandidate(attempt) {
2740
+ return attempt.candidate
2741
+ ?? attempt.prompts.at(-1)?.candidate
2742
+ ?? attempt.prompts.at(-1)?.preCandidate
2743
+ ?? attempt.waveBase;
2744
+ }
2745
+ /**
2746
+ * Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
2747
+ * only an observed live worker without saved termination intent is closed.
2748
+ */
2749
+ async proveTermination(handle, task, attempt, candidate, save, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
2750
+ if (attempt.termination?.status === "terminated")
2751
+ return;
2752
+ const workerId = attempt.termination?.workerId ?? allocationByKind(attempt, "agent")?.agentName;
2753
+ if (!workerId)
2754
+ throw new Error("Worker termination requires an exact durably owned agent ID.");
2755
+ const input = { task: changesetTaskRequest(handle.state, task.taskId), attempt, workerId,
2756
+ candidate: attempt.termination?.candidate ?? candidate };
2757
+ const safety = new DeadlineScope(deadline, () => this.coordinatorRuntime.now(), signal);
2758
+ try {
2759
+ let result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination(input, context));
2760
+ if (result.outcome === "active" && !attempt.termination) {
2761
+ attempt.termination = { status: "terminating", workerId, candidate: input.candidate };
2762
+ await save(); // Intent before the exact pane close.
2763
+ result = await safety.call((context) => this.hostRuntime.terminateWorker(input, context));
2764
+ }
2765
+ if (result.outcome !== "terminated") {
2766
+ throw new Error(result.outcome === "unknown" ? result.failure : "Exact worker remains active; termination was not replayed.");
2767
+ }
2768
+ attempt.termination = { status: "terminated", workerId, candidate: input.candidate, at: this.coordinatorRuntime.now() };
2769
+ }
2770
+ catch (error) {
2771
+ const failure = bounded(`Worker termination is unproved: ${errorText(error)}`);
2772
+ // Without saved intent, nothing was closed; a later proof may still close the exact live worker.
2773
+ if (attempt.termination)
2774
+ attempt.termination = { status: "unknown", workerId, candidate: input.candidate, failure };
2775
+ throw new Error(failure, { cause: error });
2776
+ }
2777
+ finally {
2778
+ safety.close();
2779
+ await save();
2780
+ }
2781
+ }
2782
+ async terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS, lifecycleLocked = false) {
2783
+ if (!lifecycleLocked && this.productiveLifecycles.has(handle.state.root)) {
2784
+ return await this.withLifecycleLock(handle, async () => await this.terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline, true));
2785
+ }
2786
+ const save = async () => lifecycleLocked ? await handle.save() : await this.saveProductive(handle);
2787
+ const workerId = allocationByKind(attempt, "agent")?.agentName;
2788
+ if (!workerId)
2789
+ throw new Error("Safety termination requires an exact durably owned agent ID.");
2790
+ attempt.termination = { status: "terminating", workerId, candidate };
2791
+ await save();
2792
+ const safety = new DeadlineScope(safetyDeadline, () => this.coordinatorRuntime.now(), outerSignal);
2793
+ try {
2794
+ const result = await safety.call(async (context) => await this.hostRuntime.terminateWorker({
2795
+ task: changesetTaskRequest(handle.state, task.taskId), attempt, workerId, candidate,
2796
+ }, context));
2797
+ if (result.outcome !== "terminated") {
2798
+ const failure = boundedRuntimeText(result.failure, "Worker termination failure");
2799
+ attempt.termination = { status: "unknown", workerId, candidate, failure };
2800
+ return false;
2801
+ }
2802
+ attempt.termination = { status: "terminated", workerId, candidate, at: this.coordinatorRuntime.now() };
2803
+ return true;
2804
+ }
2805
+ catch (error) {
2806
+ this.rethrowStopped(error);
2807
+ attempt.termination = { status: "unknown", workerId, candidate, failure: errorText(error) };
2808
+ return false;
2809
+ }
2810
+ finally {
2811
+ safety.close();
2812
+ await save();
2813
+ }
2814
+ }
2815
+ async runCheckBatch(handle, checks, candidate, phase, scope, taskId, root = handle.state.root, reuse = []) {
2816
+ const attempt = taskId ? latestAttempt(changesetTaskState(handle.state, taskId)) : undefined;
2817
+ // Run every non-reused check, even none, so the runtime still proves the post-check identity.
2818
+ const run = checks.filter((_, index) => !reuse[index]);
2819
+ const result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.runChecks({
2820
+ root,
2821
+ scope: phase === "final" ? "final" : "task",
2822
+ ...(taskId ? { taskId, attempt } : {}),
2823
+ checks: run,
2824
+ candidate,
2825
+ }, context));
2826
+ const identityAfter = runtimeIdentity(result.identityAfter, "Post-check identity");
2827
+ if (!exactCommandResults(result.results, run)) {
2828
+ throw new Error("Check runtime did not return one result with the exact command and argv for every declared check.");
2829
+ }
2830
+ if (result.results.some((item) => !Number.isSafeInteger(item.code)
2831
+ || (item.killed !== undefined && typeof item.killed !== "boolean")
2832
+ || typeof item.stdout !== "string" || typeof item.stderr !== "string")) {
2833
+ throw new Error("Check runtime returned malformed result evidence.");
2834
+ }
2835
+ let ran = 0;
2836
+ const results = checks.map((check, index) => {
2837
+ const reusedFrom = reuse[index];
2838
+ return reusedFrom ? { ...check, code: 0, killed: false, stdout: "", stderr: "" } : result.results[ran++];
2839
+ });
2840
+ const commandsPassed = results.every((item) => item.code === 0 && !item.killed);
2841
+ const identityMatches = sameIdentity(identityAfter, candidate);
2842
+ const passed = identityMatches && commandsPassed;
2843
+ let diagnosticIndex = passed ? -1 : results.findIndex((item) => item.code !== 0 || item.killed);
2844
+ if (diagnosticIndex < 0 && commandsPassed && !identityMatches)
2845
+ diagnosticIndex = results.length - 1;
2846
+ const evidence = {
2847
+ phase,
2848
+ candidate,
2849
+ identityAfter,
2850
+ results: results.map((item, index) => ({
2851
+ command: item.command,
2852
+ args: [...item.args],
2853
+ code: item.code,
2854
+ killed: item.killed ?? false,
2855
+ stdout: index === diagnosticIndex ? bounded(item.stdout) : "",
2856
+ stderr: index === diagnosticIndex ? bounded(item.stderr) : "",
2857
+ ...(reuse[index] ? { reusedFrom: reuse[index] } : {}),
2858
+ })),
2859
+ passed,
2860
+ at: this.coordinatorRuntime.now(),
2861
+ };
2862
+ return evidence;
2863
+ }
2864
+ async runReview(handle, criterion, base, tip, role, modelClass, phase, scope, taskId, root = handle.state.root) {
2865
+ const attempt = taskId ? latestAttempt(changesetTaskState(handle.state, taskId)) : undefined;
2866
+ const result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.review({
2867
+ root,
2868
+ scope: phase === "final" ? "final" : "task",
2869
+ phase,
2870
+ ...(taskId ? { taskId, attempt } : {}),
2871
+ criterion,
2872
+ base,
2873
+ tip,
2874
+ acquireLaunch: async () => {
2875
+ const launch = await this.coordinatorRuntime.acquireLaunch(role, modelClass, context);
2876
+ if (launch.launch.role !== role || launch.launch.modelClass !== modelClass) {
2877
+ await withTransientLaunch(launch, async () => {
2878
+ throw new Error("Judgment launch acquisition returned the wrong Role or model class.");
2879
+ });
2880
+ }
2881
+ return launch;
2882
+ },
2883
+ }, context));
2884
+ const identityAfter = runtimeIdentity(result.identityAfter, "Post-review identity");
2885
+ const verdict = boundedRuntimeText(result.verdict, "Judgment verdict");
2886
+ const evidence = {
2887
+ phase,
2888
+ criterion,
2889
+ base,
2890
+ tip,
2891
+ identityAfter,
2892
+ verdict,
2893
+ passed: verdict === "PASS" && sameIdentity(identityAfter, tip),
2894
+ at: this.coordinatorRuntime.now(),
2895
+ };
2896
+ return evidence;
2897
+ }
2898
+ retainCandidate(state, task) {
2899
+ const attempt = latestAttempt(task);
2900
+ const request = changesetTaskRequest(state, task.taskId);
2901
+ if (!attempt.readiness || !attempt.candidate || !attempt.candidateBase || !attempt.preliminaryChecks
2902
+ || !sameIdentity(attempt.readiness.candidate, attempt.candidate)
2903
+ || !sameIdentity(attempt.readiness.base, attempt.candidateBase)
2904
+ || !hasPassingPreliminaryEvidence(request, attempt)) {
2905
+ throw new Error(`Task ${task.taskId} has no exact ready live candidate.`);
2906
+ }
2907
+ if (state.integration.candidates.some((candidate) => candidate.taskId === task.taskId && !candidate.decision)) {
2908
+ throw new Error(`Candidate ${task.taskId} is already retained; revision needs a new integration generation.`);
2909
+ }
2910
+ state.integration.candidates.push({
2911
+ taskId: task.taskId, attempt: attempt.number, base: attempt.candidateBase,
2912
+ tip: attempt.candidate, checks: attempt.preliminaryChecks,
2913
+ ...(request.judgment ? { review: attempt.preliminaryReview } : {}), worker: "retained",
2914
+ });
2915
+ state.integration.candidates.sort((a, b) => state.tasks.findIndex((item) => item.taskId === a.taskId)
2916
+ - state.tasks.findIndex((item) => item.taskId === b.taskId));
2917
+ }
2918
+ async verifyRetainedTask(handle, task, scope) {
2919
+ const attempt = latestAttempt(task);
2920
+ if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
2921
+ throw new Error("An ambiguous delivered prompt is retained for inspection or explicit abort and is never replayed.");
2922
+ }
2923
+ const candidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectRetainedTask({
2924
+ root: handle.state.root, task: changesetTaskRequest(handle.state, task.taskId), attempt,
2925
+ }, context)), "Retained task candidate identity");
2926
+ if (!isCleanCommitted(candidate))
2927
+ throw new Error("Retained task candidate is not clean and committed.");
2928
+ const request = changesetTaskRequest(handle.state, task.taskId);
2929
+ if (!attempt.candidate || !attempt.candidateBase || !sameIdentity(candidate, attempt.candidate)
2930
+ || !hasPassingPreliminaryEvidence(request, attempt)) {
2931
+ throw new Error("Retained task work lacks exact passing preliminary checks, judgment or live ownership.");
2932
+ }
2933
+ if (attempt.readiness && (!sameIdentity(candidate, attempt.readiness.candidate)
2934
+ || !sameIdentity(attempt.candidateBase, attempt.readiness.base))) {
2935
+ throw new Error("Ready retained task candidate drifted from its exact lineage.");
2936
+ }
2937
+ attempt.readiness ??= {
2938
+ candidate: attempt.candidate,
2939
+ base: attempt.candidateBase,
2940
+ at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
2941
+ };
2942
+ this.retainCandidate(handle.state, task);
2943
+ task.status = "ready_to_integrate";
2944
+ task.failure = undefined;
2945
+ handle.state.status = "needs_attention";
2946
+ await this.saveProductive(handle);
2947
+ return this.response(handle.state);
2948
+ }
2949
+ async retry(handle, task, scope, instruction) {
2950
+ const attempt = task.attempts.at(-1);
2951
+ if (instruction !== undefined && !attempt?.prompts.length) {
2952
+ throw new Error("A retry instruction requires a same-worker correction of a prompted attempt.");
2953
+ }
2954
+ if (!attempt) {
2955
+ for (const pending of handle.state.tasks) {
2956
+ if (pending.status === "needs_attention" && pending.attempts.length === 0) {
2957
+ pending.status = "pending";
2958
+ pending.failure = undefined;
2959
+ }
2960
+ }
2961
+ await this.saveProductive(handle);
2962
+ return await this.run(handle, scope, undefined);
2963
+ }
2964
+ if (attempt.prompts.length) {
2965
+ if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
2966
+ throw new Error("An ambiguous delivered prompt is never replayed.");
2967
+ }
2968
+ const request = changesetTaskRequest(handle.state, task.taskId);
2969
+ if (!this.correctionAllowed(handle.state, request, attempt)) {
2970
+ throw new Error("The same-agent correction is unavailable or already used.");
2971
+ }
2972
+ // Build the correction before it is recorded: an oversized prompt must not use up the only correction.
2973
+ // The reserve covers the worktree path and turn identity the host adds before delivery.
2974
+ const assignment = buildChangesetTaskPrompt({
2975
+ goal: handle.state.request.goal,
2976
+ contexts: resolveTextTaskContexts(handle.state, request),
2977
+ task: request,
2978
+ kind: "correction",
2979
+ worktreeCwd: allocationByKind(attempt, "agent")?.worktreeCwd ?? "",
2980
+ failure: task.failure,
2981
+ ...(instruction === undefined ? {} : { instruction }),
2982
+ });
2983
+ if (Buffer.byteLength(assignment, "utf8") + CORRECTION_DELIVERY_RESERVE_BYTES > CHANGESET_TASK_PROMPT_MAX_BYTES) {
2984
+ throw new Error(`Worker assignment exceeds ${CHANGESET_TASK_PROMPT_MAX_BYTES} bytes with delivery overhead; shorten the retry instruction.`);
2985
+ }
2986
+ task.status = "working";
2987
+ await this.driveWorkerSafely(handle, task, scope, "correction", instruction);
2988
+ }
2989
+ else {
2990
+ if (attempt.termination || allocationByKind(attempt, "agent")) {
2991
+ throw new Error("A promptless attempt with a terminated or potentially active saved agent cannot be retried productively.");
2992
+ }
2993
+ for (const intent of attempt.allocations.filter((item) => item.status !== "owned")) {
2994
+ let result;
2995
+ try {
2996
+ const request = changesetTaskRequest(handle.state, task.taskId);
2997
+ result = intent.kind === "worktree"
2998
+ ? await this.callProductive(handle, scope, async (context) => await this.gitRuntime.reconcileWorktreeAllocation({
2999
+ root: handle.state.root, intent, task: request, attempt,
3000
+ }, context))
3001
+ : await this.callProductive(handle, scope, async (context) => await this.hostRuntime.reconcileHostAllocation({
3002
+ requestId: handle.state.request.id, intent, task: request, attempt,
3003
+ }, context));
3004
+ }
3005
+ catch (error) {
3006
+ this.rethrowStopped(error);
3007
+ intent.status = "unknown";
3008
+ intent.failure = bounded(`Allocation reconciliation is ambiguous: ${errorText(error)}`);
3009
+ throw new Error(intent.failure);
3010
+ }
3011
+ if (result.outcome !== "absent") {
3012
+ const failure = boundedRuntimeText(result.failure, "Allocation reconciliation failure");
3013
+ const possibleResources = boundedPossibleResources(result.possibleResources);
3014
+ intent.status = "unknown";
3015
+ intent.failure = failure;
3016
+ intent.possibleResources = possibleResources;
3017
+ await this.saveProductive(handle);
3018
+ throw new Error("A possible prior allocation blocks retry; it was not adopted or closed.");
3019
+ }
3020
+ intent.status = "absent";
3021
+ intent.failure = undefined;
3022
+ intent.possibleResources = undefined;
3023
+ }
3024
+ if (attempt.allocationGeneration >= 2)
3025
+ throw new Error("The one reconciled unprompted fresh launch was already used.");
3026
+ attempt.allocationGeneration += 1;
3027
+ task.status = "allocating";
3028
+ task.failure = undefined;
3029
+ await this.saveProductive(handle);
3030
+ await this.dispatchTask(handle, task, scope);
3031
+ }
3032
+ const current = changesetTaskState(handle.state, task.taskId);
3033
+ if (current.status === "ready_to_integrate") {
3034
+ this.retainCandidate(handle.state, current);
3035
+ handle.state.status = "needs_attention";
3036
+ await this.saveProductive(handle);
3037
+ return this.response(handle.state);
3038
+ }
3039
+ if (current.status !== "completed") {
3040
+ handle.state.status = "needs_attention";
3041
+ await this.saveProductive(handle);
3042
+ return this.response(handle.state);
3043
+ }
3044
+ return await this.run(handle, scope, undefined);
3045
+ }
3046
+ async finalize(handle, scope) {
3047
+ const state = handle.state;
3048
+ if (state.tasks.some((task) => task.status !== "completed"))
3049
+ throw new Error("Finalization requires every task to be completed.");
3050
+ if (state.final.status !== "pending" && state.final.status !== "interrupted") {
3051
+ throw new Error(`Final gate is ${state.final.status} and cannot be finalized.`);
3052
+ }
3053
+ if (state.final.identity) {
3054
+ const actual = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
3055
+ if (!sameIdentity(actual, state.final.identity)) {
3056
+ await this.markSuperseded(handle, "Main drifted from the recorded final-gate identity.");
3057
+ return this.response(state);
3058
+ }
3059
+ }
3060
+ return await this.runFinal(handle, scope);
3061
+ }
3062
+ async runFinal(handle, scope) {
3063
+ const state = handle.state;
3064
+ state.status = "running";
3065
+ state.final.status = "running";
3066
+ state.final.failure = undefined;
3067
+ state.accepted = false;
3068
+ await this.saveProductive(handle);
3069
+ try {
3070
+ const identity = state.final.identity ?? runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context)), "Final Main identity");
3071
+ if (!sameIdentity(identity, state.main) || !isCleanCommitted(identity)) {
3072
+ await this.markSuperseded(handle, "Main drifted before the final gate.");
3073
+ return this.response(state);
3074
+ }
3075
+ state.final.identity = identity;
3076
+ await this.saveProductive(handle);
3077
+ const finalChecks = state.request.finalChecks ?? [];
3078
+ const checks = await this.runCheckBatch(handle, finalChecks, identity, "final", scope);
3079
+ state.final.checks = checks;
3080
+ await this.saveProductive(handle);
3081
+ if (!sameIdentity(checks.identityAfter, identity)) {
3082
+ await this.markSuperseded(handle, "Final checks changed Main.");
3083
+ return this.response(state);
3084
+ }
3085
+ if (!checkBatchPasses(checks, finalChecks, identity)) {
3086
+ await this.markFinalFailed(handle, "A definitive final check failed.");
3087
+ return this.response(state);
3088
+ }
3089
+ const afterChecks = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
3090
+ if (!sameIdentity(afterChecks, identity)) {
3091
+ await this.markSuperseded(handle, "Main drifted after final checks.");
3092
+ return this.response(state);
3093
+ }
3094
+ if (state.request.finalJudgment) {
3095
+ const review = await this.runReview(handle, state.request.finalJudgment.criterion, state.requestStartMain, identity, state.request.finalJudgment.role, state.request.finalJudgment.modelClass, "final", scope);
3096
+ state.final.review = review;
3097
+ await this.saveProductive(handle);
3098
+ if (!sameIdentity(review.identityAfter, identity)) {
3099
+ await this.markSuperseded(handle, "Final Judgment changed Main.");
3100
+ return this.response(state);
3101
+ }
3102
+ if (!review.passed) {
3103
+ await this.markFinalFailed(handle, "The definitive final judgment was not exact PASS.");
3104
+ return this.response(state);
3105
+ }
3106
+ }
3107
+ const afterJudgment = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
3108
+ if (!sameIdentity(afterJudgment, identity)) {
3109
+ await this.markSuperseded(handle, "Main drifted after final judgment.");
3110
+ return this.response(state);
3111
+ }
3112
+ const beforeCompletion = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
3113
+ if (!sameIdentity(beforeCompletion, identity)) {
3114
+ await this.markSuperseded(handle, "Main drifted before final completion.");
3115
+ return this.response(state);
3116
+ }
3117
+ state.main = identity;
3118
+ state.final.status = "passed";
3119
+ state.status = "completed";
3120
+ state.accepted = true;
3121
+ state.acceptedAt = this.coordinatorRuntime.now();
3122
+ state.updatedAt = this.coordinatorRuntime.now();
3123
+ await this.saveProductive(handle);
3124
+ return this.response(state);
3125
+ }
3126
+ catch (error) {
3127
+ this.rethrowStopped(error);
3128
+ state.final.status = "interrupted";
3129
+ state.final.failure = bounded(isDeadline(error, scope) ? "The operation safety deadline expired during the final gate." : `Final gate was interrupted without a definitive result: ${errorText(error)}`);
3130
+ state.status = "needs_attention";
3131
+ state.accepted = false;
3132
+ await this.saveProductive(handle);
3133
+ return this.response(state);
3134
+ }
3135
+ }
3136
+ async markFinalFailed(handle, failure) {
3137
+ handle.state.final.status = "final_failed";
3138
+ handle.state.final.failure = bounded(failure);
3139
+ handle.state.status = "final_failed";
3140
+ handle.state.accepted = false;
3141
+ await this.saveProductive(handle);
3142
+ }
3143
+ async markSuperseded(handle, failure) {
3144
+ handle.state.final.status = "superseded";
3145
+ handle.state.final.failure = bounded(failure);
3146
+ handle.state.status = "superseded";
3147
+ handle.state.accepted = false;
3148
+ await this.saveProductive(handle);
3149
+ }
3150
+ recoverInterrupted(state) {
3151
+ if (state.status === "needs_attention") {
3152
+ let changed = false;
3153
+ for (const task of state.tasks) {
3154
+ if (task.kind !== "changeset" || task.status !== "ready_to_integrate"
3155
+ || state.integration.candidates.some((candidate) => candidate.taskId === task.taskId))
3156
+ continue;
3157
+ const attempt = latestAttempt(task);
3158
+ if (hasPassingPreliminaryEvidence(changesetTaskRequest(state, task.taskId), attempt)) {
3159
+ this.attention(task, "Candidate readiness was saved before retention; verify to reconstruct the candidate from exact passing evidence.");
3160
+ changed = true;
3161
+ continue;
3162
+ }
3163
+ delete attempt.readiness;
3164
+ this.attention(task, "Readiness lacked passing preliminary checks, judgment or live ownership; retained work requires attention.");
3165
+ changed = true;
3166
+ }
3167
+ if (changed)
3168
+ state.updatedAt = this.coordinatorRuntime.now();
3169
+ return changed;
3170
+ }
3171
+ if (state.status === "pending") {
3172
+ const ready = readyPendingTasks(state).filter((task) => task.attempts.length === 0);
3173
+ if (!ready.length)
3174
+ return false;
3175
+ for (const task of ready) {
3176
+ this.attention(task, `Dependency scheduling is pending before ${task.taskId} started; resume retry to continue.`);
3177
+ }
3178
+ state.status = "needs_attention";
3179
+ state.accepted = false;
3180
+ state.updatedAt = this.coordinatorRuntime.now();
3181
+ return true;
3182
+ }
3183
+ if (state.status !== "running")
3184
+ return false;
3185
+ for (const task of state.tasks) {
3186
+ if (["pending", "completed", "needs_attention"].includes(task.status))
3187
+ continue;
3188
+ if (task.kind !== "changeset") {
3189
+ this.attention(task, "Execution was interrupted at an ambiguous boundary and will not replay automatically.");
3190
+ continue;
3191
+ }
3192
+ const attempt = task.attempts.at(-1);
3193
+ if (attempt) {
3194
+ for (const intent of attempt.allocations) {
3195
+ if (intent.status === "allocating") {
3196
+ intent.status = "unknown";
3197
+ intent.failure = "Allocation was interrupted after intent persistence; no result was adopted.";
3198
+ }
3199
+ }
3200
+ for (const prompt of attempt.prompts) {
3201
+ if (prompt.status === "submitting") {
3202
+ prompt.status = "ambiguous";
3203
+ prompt.failure = "Prompt delivery was interrupted and will not be replayed.";
3204
+ }
3205
+ }
3206
+ if (attempt.termination?.status === "terminating") {
3207
+ attempt.termination.status = "unknown";
3208
+ attempt.termination.failure = "Worker termination was interrupted and remains unproved.";
3209
+ }
3210
+ for (const step of attempt.cleanup)
3211
+ if (step.status === "running")
3212
+ step.status = "pending";
3213
+ }
3214
+ this.attention(task, state.recovery?.taskId === task.taskId
3215
+ ? "Deliberate recovery was interrupted; exact saved evidence and resources were retained."
3216
+ : "Execution was interrupted at an ambiguous boundary and will not replay automatically.");
3217
+ }
3218
+ if (!state.tasks.some((task) => task.status === "needs_attention")) {
3219
+ for (const task of readyPendingTasks(state)) {
3220
+ this.attention(task, `Dependency scheduling was interrupted before ${task.taskId} started; resume retry to continue.`);
3221
+ }
3222
+ }
3223
+ if (state.final.status === "running") {
3224
+ state.final.status = "interrupted";
3225
+ state.final.failure = "Final verification was interrupted without a definitive result.";
3226
+ }
3227
+ delete state.recovery;
3228
+ state.status = "needs_attention";
3229
+ state.accepted = false;
3230
+ state.updatedAt = this.coordinatorRuntime.now();
3231
+ return true;
3232
+ }
3233
+ ensureFollowupControl(handle, task) {
3234
+ const key = this.controlKey(handle.state.root, handle.state.request.id, task.taskId);
3235
+ const existing = this.followupControls.get(key);
3236
+ if (existing) {
3237
+ if (existing.task !== task)
3238
+ throw new Error(`Task ${task.taskId} follow-up control has stale state ownership.`);
3239
+ return existing.control;
3240
+ }
3241
+ const control = new FollowupControl((instruction) => {
3242
+ const attempt = latestAttempt(task);
3243
+ const agent = allocationByKind(attempt, "agent");
3244
+ if (!agent)
3245
+ throw new Error("Worker launch has no durably recorded agent allocation.");
3246
+ buildChangesetTaskPrompt({
3247
+ goal: handle.state.request.goal,
3248
+ contexts: [],
3249
+ task: changesetTaskRequest(handle.state, task.taskId),
3250
+ kind: "followup",
3251
+ instruction,
3252
+ worktreeCwd: agent.worktreeCwd,
3253
+ });
3254
+ });
3255
+ this.followupControls.set(key, { task, control });
3256
+ return control;
3257
+ }
3258
+ controlKey(root, requestId, taskId) {
3259
+ return `${root}\0${requestId}\0${taskId}`;
3260
+ }
3261
+ activeControl(root, requestId, taskId) {
3262
+ return this.followupControls.get(this.controlKey(root, requestId, taskId));
3263
+ }
3264
+ activeControls(root, requestId) {
3265
+ const prefix = `${root}\0${requestId}\0`;
3266
+ return [...this.followupControls.entries()]
3267
+ .filter(([key]) => key.startsWith(prefix))
3268
+ .map(([, active]) => active);
3269
+ }
3270
+ hasActiveControl(root, requestId) {
3271
+ return this.activeControls(root, requestId).length > 0;
3272
+ }
3273
+ hasAnyActiveControl(root) {
3274
+ const prefix = `${root}\0`;
3275
+ return [...this.followupControls.keys()].some((key) => key.startsWith(prefix));
3276
+ }
3277
+ closeFollowupControl(root, requestId, taskId, control) {
3278
+ const key = this.controlKey(root, requestId, taskId);
3279
+ control.close();
3280
+ if (this.followupControls.get(key)?.control === control)
3281
+ this.followupControls.delete(key);
3282
+ }
3283
+ closeRequestControls(root, requestId) {
3284
+ for (const { task, control } of this.activeControls(root, requestId)) {
3285
+ this.closeFollowupControl(root, requestId, task.taskId, control);
3286
+ }
3287
+ }
3288
+ attention(task, failure) {
3289
+ const boundedFailure = bounded(failure);
3290
+ if (task.kind === "text") {
3291
+ const attempt = task.attempts.at(-1);
3292
+ if (attempt?.status === "running") {
3293
+ attempt.status = "failed";
3294
+ attempt.failure = boundedFailure;
3295
+ }
3296
+ }
3297
+ task.status = "needs_attention";
3298
+ task.failure = boundedFailure;
3299
+ }
3300
+ response(state, main) {
3301
+ const completed = state.tasks.filter((task) => task.status === "completed").length;
3302
+ const rejected = state.tasks.filter((task) => task.status === "rejected").length;
3303
+ const resumable = !terminal(state);
3304
+ let continuation;
3305
+ if (resumable) {
3306
+ const attention = state.tasks.find((task) => task.status === "needs_attention");
3307
+ if (completed === state.tasks.length
3308
+ && (state.final.status === "pending" || state.final.status === "interrupted")) {
3309
+ continuation = { id: state.request.id, action: "finalize" };
3310
+ }
3311
+ else if (attention?.kind === "text" && textRetryEligible(state, attention)) {
3312
+ continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
3313
+ }
3314
+ else if (attention?.kind === "changeset" && attention.attempts.length === 0) {
3315
+ continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
3316
+ }
3317
+ else if (attention?.kind === "changeset" && !attention.attempts.at(-1)?.termination) {
3318
+ const attempt = attention.attempts.at(-1);
3319
+ const hasEvidence = Boolean(attempt && !state.integration.candidates.length
3320
+ && hasPassingPreliminaryEvidence(changesetTaskRequest(state, attention.taskId), attempt));
3321
+ if (attempt?.readiness && hasEvidence) {
3322
+ continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
3323
+ }
3324
+ else if (attempt && this.correctionAllowed(state, changesetTaskRequest(state, attention.taskId), attempt)) {
3325
+ continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
3326
+ }
3327
+ else if (hasEvidence) {
3328
+ continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
3329
+ }
3330
+ }
3331
+ }
3332
+ const attention = continuation && "taskId" in continuation
3333
+ ? taskState(state, continuation.taskId)
3334
+ : state.tasks.find((task) => task.status === "needs_attention");
3335
+ const next = nextIntegrationAction(state);
3336
+ const stageable = stageableCandidates(state).length > 0;
3337
+ const advance = advanceReady(state);
3338
+ return {
3339
+ text: bounded([
3340
+ `Pi Subagent ${state.request.id}: ${state.status}.`,
3341
+ `Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
3342
+ ...(state.status === "aborted" && (state.tasks.some((task) => task.kind === "changeset"
3343
+ && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
3344
+ || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")))
3345
+ ? ["Aborted resources remain retained. Inspect cleanup evidence; retry subagent_abort after active work settles or cleanup blockers are resolved. Unique commits and uncertain work are never discarded."] : []),
3346
+ ...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
3347
+ ...(main?.status === "drifted" ? [
3348
+ `Main: drifted from ${main.expected.branch}@${main.expected.head} to ${main.actual.branch}@${main.actual.head}.`,
3349
+ ] : []),
3350
+ ...(main?.status === "unavailable" ? [`Main: ${main.failure}`] : []),
3351
+ ...(attention?.failure ? [`Needs attention (${attention.taskId}): ${attention.failure}`] : []),
3352
+ ...(state.integration.candidates.length && state.integration.generations.at(-1)?.status !== "promoted" ? [
3353
+ `Candidates: ${state.integration.candidates.map((item) => `${item.taskId}#${item.attempt}@${item.tip.head}${item.decision ? " (rejected)" : ""}`).join(", ")}.${stageable ? " Use subagent_stage with exact status identities; Main remains unchanged." : ""}`,
3354
+ ] : []),
3355
+ ...(state.integration.generations.at(-1) ? [
3356
+ `Integration generation ${state.integration.generations.at(-1).number}: ${state.integration.generations.at(-1).status}; worktree: ${state.integration.generations.at(-1).worktree?.path ?? "allocation not proven"}; stage: ${state.integration.generations.at(-1).stages.at(-1)?.status ?? "none"}.`,
3357
+ ...(advance ? [ADVANCE_HINT] : []),
3358
+ ...(state.integration.generations.at(-1).failure ? [`Integration: ${state.integration.generations.at(-1).failure}`] : []),
3359
+ ...(state.integration.generations.at(-1).promotion?.failure ? [`Promotion: ${state.integration.generations.at(-1).promotion.failure}`] : []),
3360
+ ...(state.integration.generations.at(-1).checks?.passed === false ? ["Combined checks failed; Main was not changed."] : []),
3361
+ ...reusedCheckLines(state.integration.generations.at(-1).checks),
3362
+ ...(state.integration.generations.at(-1).status === "promoted" ? ["Promotion proven; worker cleanup is pending or completed. Use subagent_integrate cleanup with the exact tip if interrupted."] : []),
3363
+ ] : []),
3364
+ ...(state.integration.generations.some((item) => item.status === "superseded" && item.worktree
3365
+ && !item.cleanup?.every((step) => step.status === "completed")) ? [
3366
+ "Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and recorded tip (correction tip, else last staged tip, else integration base); dirty or conflicted work stays retained.",
3367
+ ] : []),
3368
+ ...(state.integration.candidates.some((item) => item.decision === "rejected" && item.worker !== "released") ? [
3369
+ "Rejected candidate resources remain retained. Release every generation that staged it, then use subagent_integrate release with the exact task, attempt and candidate tip; release first proves or performs exact worker termination.",
3370
+ ] : []),
3371
+ ...(state.final.failure ? [`Final: ${state.final.failure}`] : []),
3372
+ ...(next && !stageable && !advance ? [`Next: ${next}`] : []),
3373
+ ...(continuation ? [`Continuation: ${JSON.stringify(continuation)}`] : []),
3374
+ ].join("\n")),
3375
+ state,
3376
+ ...(main ? { main } : {}),
3377
+ ...(continuation ? { continuation } : {}),
3378
+ };
3379
+ }
3380
+ }