@henryqw/pi-subagent 18.0.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +76 -24
- package/README.md +116 -142
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +107 -0
- package/dist/direct-herdr.d.ts +17 -0
- package/dist/direct-herdr.js +219 -0
- package/dist/ephemeral.d.ts +2 -2
- package/dist/ephemeral.js +7 -7
- package/dist/git-runtime.d.ts +110 -0
- package/dist/git-runtime.js +472 -0
- package/dist/herdr-runtime.d.ts +137 -0
- package/dist/herdr-runtime.js +1228 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +20 -16
- package/dist/integration-git.d.ts +59 -0
- package/dist/integration-git.js +484 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +195 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/runner.d.ts +356 -0
- package/dist/runner.js +2710 -0
- package/dist/schema.d.ts +2234 -0
- package/dist/schema.js +1236 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +288 -0
- package/dist/working-evidence.d.ts +25 -0
- package/dist/working-evidence.js +261 -0
- package/docs/adr/001-composable-ephemeral-execution.md +3 -3
- package/docs/delegation-routing.html +89 -0
- package/docs/delegation-routing.svg +78 -0
- package/docs/orchestration-architecture.html +238 -0
- package/docs/orchestration-architecture.svg +189 -0
- package/docs/orchestration.md +122 -331
- package/example.png +0 -0
- package/examples/roles/implementer.md +2 -3
- package/extensions/admission.ts +66 -0
- package/extensions/config.ts +51 -37
- package/extensions/isolated.ts +638 -0
- package/extensions/result-transport.ts +13 -103
- package/extensions/role-tools.ts +11 -11
- package/extensions/subagent-command.ts +167 -0
- package/extensions/subagent.ts +264 -540
- package/extensions/task-name.ts +1 -2
- package/extensions/workflow.ts +60 -96
- package/package.json +14 -6
- package/skills/pi-subagent/SKILL.md +35 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,2710 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { createChildWorktree, finalizeChildWorktree } from "./worktree.js";
|
|
3
|
+
import { IntegrationGit } from "./integration-git.js";
|
|
4
|
+
import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reviewEvidencePasses, sameIdentity, RUN_STATE_VERSION, } from "./schema.js";
|
|
5
|
+
import { FileRunStore } from "./store.js";
|
|
6
|
+
const TRUNCATION_MARKER = "\n[truncated]";
|
|
7
|
+
const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
|
|
8
|
+
const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
|
|
9
|
+
const MAX_WORKER_PROMPTS = 32;
|
|
10
|
+
const MAX_QUEUED_FOLLOWUPS = 16;
|
|
11
|
+
export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
|
|
12
|
+
export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
|
|
13
|
+
export const STATUS_INSPECTION_BUDGET_MS = 5_000;
|
|
14
|
+
const ALLOCATION_KINDS = ["worktree", "workspace", "worker_tab", "agent"];
|
|
15
|
+
const FULL_SUITE = { command: "pnpm", args: ["test"] };
|
|
16
|
+
const MAX_RETAINED_INTEGRATION_GENERATIONS = 2;
|
|
17
|
+
function requireRootSuite(root) {
|
|
18
|
+
let manifest;
|
|
19
|
+
try {
|
|
20
|
+
manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error("Canonical root package.json with a test script is required for isolated changesets.");
|
|
24
|
+
}
|
|
25
|
+
if (!manifest || typeof manifest !== "object" || !('scripts' in manifest)
|
|
26
|
+
|| !manifest.scripts || typeof manifest.scripts !== "object"
|
|
27
|
+
|| !('test' in manifest.scripts) || typeof manifest.scripts.test !== "string"
|
|
28
|
+
|| !manifest.scripts.test.trim())
|
|
29
|
+
throw new Error("Canonical root test script is missing.");
|
|
30
|
+
return manifest.scripts.test;
|
|
31
|
+
}
|
|
32
|
+
/** Run one launch action and surface cleanup failure, including after action failure or abort. */
|
|
33
|
+
export async function withTransientLaunch(handle, operation) {
|
|
34
|
+
let failed = false;
|
|
35
|
+
let operationError;
|
|
36
|
+
try {
|
|
37
|
+
return await operation(handle.launch);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
failed = true;
|
|
41
|
+
operationError = error;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
try {
|
|
46
|
+
await handle.cleanup();
|
|
47
|
+
}
|
|
48
|
+
catch (cleanupError) {
|
|
49
|
+
if (failed) {
|
|
50
|
+
throw new AggregateError([operationError, cleanupError], "Transient Role launch failed and its prompt cleanup also failed.");
|
|
51
|
+
}
|
|
52
|
+
throw cleanupError;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
class DeadlineExpired extends Error {
|
|
57
|
+
constructor() {
|
|
58
|
+
super("The safety operation deadline is exhausted.");
|
|
59
|
+
this.name = "DeadlineExpired";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
class DurableRunStopped extends Error {
|
|
63
|
+
state;
|
|
64
|
+
constructor(state) {
|
|
65
|
+
super(`Pi Subagent request ${state.request.id} became terminal (${state.status}).`);
|
|
66
|
+
this.name = "DurableRunStopped";
|
|
67
|
+
this.state = state;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
class ProductiveScope {
|
|
71
|
+
signal;
|
|
72
|
+
constructor(_timeoutMs, _now, outerSignal) {
|
|
73
|
+
this.signal = outerSignal ?? new AbortController().signal;
|
|
74
|
+
}
|
|
75
|
+
async call(operation) {
|
|
76
|
+
this.signal.throwIfAborted();
|
|
77
|
+
const result = await operation({ signal: this.signal });
|
|
78
|
+
this.signal.throwIfAborted();
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
class DeadlineScope {
|
|
83
|
+
deadline;
|
|
84
|
+
signal;
|
|
85
|
+
now;
|
|
86
|
+
controller = new AbortController();
|
|
87
|
+
timer;
|
|
88
|
+
constructor(deadline, now, outerSignal) {
|
|
89
|
+
this.deadline = deadline;
|
|
90
|
+
this.now = now;
|
|
91
|
+
this.signal = outerSignal ? AbortSignal.any([this.controller.signal, outerSignal]) : this.controller.signal;
|
|
92
|
+
const remaining = Math.max(0, deadline - now());
|
|
93
|
+
this.timer = setTimeout(() => this.controller.abort(new DeadlineExpired()), remaining);
|
|
94
|
+
this.timer.unref();
|
|
95
|
+
}
|
|
96
|
+
async call(operation) {
|
|
97
|
+
this.throwIfExpired();
|
|
98
|
+
const result = await operation({ signal: this.signal, timeoutMs: this.remaining(), deadline: this.deadline });
|
|
99
|
+
this.throwIfExpired();
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
close() {
|
|
103
|
+
clearTimeout(this.timer);
|
|
104
|
+
}
|
|
105
|
+
remaining() {
|
|
106
|
+
return Math.max(0, this.deadline - this.now());
|
|
107
|
+
}
|
|
108
|
+
throwIfExpired() {
|
|
109
|
+
if (this.remaining() <= 0)
|
|
110
|
+
throw new DeadlineExpired();
|
|
111
|
+
this.signal.throwIfAborted();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
class FollowupControl {
|
|
115
|
+
queue = [];
|
|
116
|
+
validateFollowup;
|
|
117
|
+
sealed = false;
|
|
118
|
+
constructor(validateFollowup) {
|
|
119
|
+
this.validateFollowup = validateFollowup;
|
|
120
|
+
}
|
|
121
|
+
get pendingFollowups() {
|
|
122
|
+
return this.queue.length;
|
|
123
|
+
}
|
|
124
|
+
get isSealed() {
|
|
125
|
+
return this.sealed;
|
|
126
|
+
}
|
|
127
|
+
followup(instruction) {
|
|
128
|
+
if (this.sealed)
|
|
129
|
+
throw new Error("This task candidate is already sealed for integration.");
|
|
130
|
+
if (this.queue.length >= MAX_QUEUED_FOLLOWUPS)
|
|
131
|
+
throw new Error(`A task may queue at most ${MAX_QUEUED_FOLLOWUPS} follow-ups.`);
|
|
132
|
+
this.validateFollowup(instruction);
|
|
133
|
+
this.queue.push({ kind: "followup", instruction });
|
|
134
|
+
}
|
|
135
|
+
drainPending() {
|
|
136
|
+
if (this.sealed)
|
|
137
|
+
throw new Error("This task candidate is already sealed for integration.");
|
|
138
|
+
return this.queue.splice(0).map(({ instruction }) => instruction);
|
|
139
|
+
}
|
|
140
|
+
takeQueuedFollowup() {
|
|
141
|
+
if (this.sealed)
|
|
142
|
+
throw new Error("This task candidate is already sealed for integration.");
|
|
143
|
+
return this.queue.shift();
|
|
144
|
+
}
|
|
145
|
+
/** Atomically take the next admitted revision or seal the candidate. */
|
|
146
|
+
takeQueuedFollowupOrSeal() {
|
|
147
|
+
const queued = this.takeQueuedFollowup();
|
|
148
|
+
if (queued)
|
|
149
|
+
return queued;
|
|
150
|
+
this.sealed = true;
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
invalidate() {
|
|
154
|
+
this.sealed = true;
|
|
155
|
+
this.queue.length = 0;
|
|
156
|
+
}
|
|
157
|
+
close() {
|
|
158
|
+
this.sealed = true;
|
|
159
|
+
this.queue.length = 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function bounded(value, maxBytes = MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
|
|
163
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes)
|
|
164
|
+
return value;
|
|
165
|
+
const contentBytes = maxBytes - Buffer.byteLength(TRUNCATION_MARKER, "utf8");
|
|
166
|
+
let end = Math.min(value.length, contentBytes);
|
|
167
|
+
while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > contentBytes)
|
|
168
|
+
end -= 1;
|
|
169
|
+
return `${value.slice(0, end)}${TRUNCATION_MARKER}`;
|
|
170
|
+
}
|
|
171
|
+
function boundedRuntimeText(value, field) {
|
|
172
|
+
if (typeof value !== "string")
|
|
173
|
+
throw new Error(`${field} must be text.`);
|
|
174
|
+
return bounded(value);
|
|
175
|
+
}
|
|
176
|
+
function boundedRuntimeDiagnostic(value, field) {
|
|
177
|
+
if (typeof value !== "string")
|
|
178
|
+
throw new Error(`${field} must be text.`);
|
|
179
|
+
const normalized = value.trim();
|
|
180
|
+
if (!normalized || normalized.includes("\0"))
|
|
181
|
+
throw new Error(`${field} must be non-empty text without NUL.`);
|
|
182
|
+
return bounded(normalized);
|
|
183
|
+
}
|
|
184
|
+
function boundedPossibleResources(values) {
|
|
185
|
+
if (values === undefined)
|
|
186
|
+
return;
|
|
187
|
+
if (!Array.isArray(values))
|
|
188
|
+
return ["Runtime returned an invalid possible-resource list; cleanup remains blocked."];
|
|
189
|
+
if (!values.length)
|
|
190
|
+
return;
|
|
191
|
+
const retained = values.slice(0, MAX_POSSIBLE_RESOURCES).map((value, index) => {
|
|
192
|
+
if (typeof value !== "string")
|
|
193
|
+
return `Runtime returned invalid possible-resource evidence at index ${index}.`;
|
|
194
|
+
const item = bounded(value);
|
|
195
|
+
return item.trim() && !item.includes("\0")
|
|
196
|
+
? item
|
|
197
|
+
: `Runtime returned invalid possible-resource evidence at index ${index}.`;
|
|
198
|
+
});
|
|
199
|
+
if (values.length > MAX_POSSIBLE_RESOURCES) {
|
|
200
|
+
retained[MAX_POSSIBLE_RESOURCES - 1] = `${values.length - MAX_POSSIBLE_RESOURCES + 1} additional possible resources were omitted; cleanup remains blocked.`;
|
|
201
|
+
}
|
|
202
|
+
return retained;
|
|
203
|
+
}
|
|
204
|
+
function errorText(error) {
|
|
205
|
+
return bounded(error instanceof Error ? error.message : String(error));
|
|
206
|
+
}
|
|
207
|
+
function requireExactAllocationText(value, field) {
|
|
208
|
+
if (typeof value !== "string" || !value.trim() || value.trim() !== value || value.includes("\0")
|
|
209
|
+
|| Buffer.byteLength(value, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
|
|
210
|
+
throw new Error(`${field} must be bounded exact non-empty text.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function runtimeIdentity(value, field) {
|
|
214
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
215
|
+
throw new Error(`${field} must be an exact identity.`);
|
|
216
|
+
const identity = value;
|
|
217
|
+
requireExactAllocationText(identity.branch, `${field} branch`);
|
|
218
|
+
for (const [name, oid] of Object.entries({ head: identity.head, index: identity.index, tree: identity.tree })) {
|
|
219
|
+
if (typeof oid !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) {
|
|
220
|
+
throw new Error(`${field} ${name} is not an exact object ID.`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { branch: identity.branch, head: identity.head, index: identity.index, tree: identity.tree };
|
|
224
|
+
}
|
|
225
|
+
function runtimeToken(value) {
|
|
226
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
227
|
+
throw new Error("Runtime correlation token must match the strict token format.");
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
function runtimeHostAllocationPlan(plan, kind) {
|
|
232
|
+
if (!plan || typeof plan !== "object" || plan.kind !== kind)
|
|
233
|
+
throw new Error(`${kind} allocation planning returned the wrong kind.`);
|
|
234
|
+
const fields = kind === "workspace"
|
|
235
|
+
? ["label", "worktreeCwd", "mainRoot", "repoKey", "herdrRepoRoot"]
|
|
236
|
+
: kind === "worker_tab"
|
|
237
|
+
? ["label", "workspaceId", "workspaceRootTabId", "workspaceRootPaneId", "worktreeCwd", "leasePath"]
|
|
238
|
+
: ["agentName", "workspaceId", "tabId", "paneId", "worktreeCwd", "leasePath"];
|
|
239
|
+
const validated = {};
|
|
240
|
+
for (const field of fields) {
|
|
241
|
+
const value = plan[field];
|
|
242
|
+
requireExactAllocationText(value, `${kind} allocation plan ${field}`);
|
|
243
|
+
validated[field] = value;
|
|
244
|
+
}
|
|
245
|
+
return { kind, ...validated };
|
|
246
|
+
}
|
|
247
|
+
function taskRequest(state, id) {
|
|
248
|
+
const task = state.request.tasks.find((candidate) => candidate.id === id);
|
|
249
|
+
if (!task)
|
|
250
|
+
throw new Error(`Unknown task ID: ${id}.`);
|
|
251
|
+
return task;
|
|
252
|
+
}
|
|
253
|
+
function changesetTaskRequest(state, id) {
|
|
254
|
+
const task = taskRequest(state, id);
|
|
255
|
+
if (task.kind !== "changeset")
|
|
256
|
+
throw new Error(`Task ${id} is not a changeset task.`);
|
|
257
|
+
return task;
|
|
258
|
+
}
|
|
259
|
+
function taskState(state, id) {
|
|
260
|
+
const task = state.tasks.find((candidate) => candidate.taskId === id);
|
|
261
|
+
if (!task)
|
|
262
|
+
throw new Error(`Unknown task ID: ${id}.`);
|
|
263
|
+
return task;
|
|
264
|
+
}
|
|
265
|
+
function taskDependenciesCompleted(state, taskId) {
|
|
266
|
+
const request = taskRequest(state, taskId);
|
|
267
|
+
const generation = state.integration.generations.at(-1);
|
|
268
|
+
return request.dependsOn.every((source) => {
|
|
269
|
+
const predecessor = taskState(state, source);
|
|
270
|
+
return predecessor.kind === "text" ? predecessor.status === "completed"
|
|
271
|
+
: generation?.status !== "superseded" && generation?.stages.some((stage) => stage.taskId === source && stage.status === "staged" && predecessor.status === "ready_to_integrate") === true;
|
|
272
|
+
}) && request.contextFrom.every((source) => taskState(state, source).status === "completed");
|
|
273
|
+
}
|
|
274
|
+
/** Context edges carry only prompt text, never the source checkout. */
|
|
275
|
+
function hasChangesetDependency(state, taskId) {
|
|
276
|
+
const request = taskRequest(state, taskId);
|
|
277
|
+
return [...request.dependsOn, ...request.contextFrom].some((source) => taskState(state, source).kind === "changeset" || hasChangesetDependency(state, source));
|
|
278
|
+
}
|
|
279
|
+
/** Include prompt-context consumers when an invalidated text output fed later work. */
|
|
280
|
+
function affectedDependents(state, taskId) {
|
|
281
|
+
const affected = new Set([taskId]);
|
|
282
|
+
let changed = true;
|
|
283
|
+
while (changed) {
|
|
284
|
+
changed = false;
|
|
285
|
+
for (const definition of state.request.tasks) {
|
|
286
|
+
if (!affected.has(definition.id) && [...definition.dependsOn, ...definition.contextFrom].some((source) => affected.has(source))) {
|
|
287
|
+
affected.add(definition.id);
|
|
288
|
+
changed = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0);
|
|
293
|
+
}
|
|
294
|
+
/** Every changeset predecessor must be present in this exact committed snapshot. */
|
|
295
|
+
function stagedDependencySnapshot(state, taskId, tip) {
|
|
296
|
+
const generation = state.integration.generations.at(-1);
|
|
297
|
+
const request = taskRequest(state, taskId);
|
|
298
|
+
return request.dependsOn.every((source) => {
|
|
299
|
+
if (taskState(state, source).kind === "text")
|
|
300
|
+
return true;
|
|
301
|
+
return Boolean(generation && generation.status !== "superseded" && generation.combinedTip
|
|
302
|
+
&& sameIdentity(generation.combinedTip, tip) && generation.stages.some((stage) => stage.taskId === source && stage.status === "staged"));
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function textRetryEligible(state, task) {
|
|
306
|
+
const attempt = task.attempts.at(-1);
|
|
307
|
+
return task.status === "needs_attention"
|
|
308
|
+
&& attempt?.status === "failed"
|
|
309
|
+
&& task.attempts.length < 2
|
|
310
|
+
&& taskDependenciesCompleted(state, task.taskId);
|
|
311
|
+
}
|
|
312
|
+
function changesetTaskState(state, id) {
|
|
313
|
+
const task = taskState(state, id);
|
|
314
|
+
if (task.kind !== "changeset")
|
|
315
|
+
throw new Error(`Task ${id} is not a changeset task.`);
|
|
316
|
+
return task;
|
|
317
|
+
}
|
|
318
|
+
function latestAttempt(task) {
|
|
319
|
+
const attempt = task.attempts.at(-1);
|
|
320
|
+
if (!attempt)
|
|
321
|
+
throw new Error(`Task ${task.taskId} has no recorded attempt.`);
|
|
322
|
+
return attempt;
|
|
323
|
+
}
|
|
324
|
+
function nextAttemptEventAt(attempt, now) {
|
|
325
|
+
const latest = Math.max(0, ...attempt.prompts.map(({ at }) => at), attempt.readiness?.at ?? 0);
|
|
326
|
+
return Math.max(now, latest + 1);
|
|
327
|
+
}
|
|
328
|
+
function allocationByKind(attempt, kind) {
|
|
329
|
+
return [...attempt.allocations].reverse().find((intent) => intent.kind === kind && intent.status === "owned");
|
|
330
|
+
}
|
|
331
|
+
function applyOwnedAllocationResult(intent, result) {
|
|
332
|
+
if (result.kind !== intent.kind)
|
|
333
|
+
throw new Error(`${intent.kind} allocation returned the wrong result kind.`);
|
|
334
|
+
if (result.outcome !== "owned")
|
|
335
|
+
throw new Error(`${intent.kind} allocation did not return an owned result.`);
|
|
336
|
+
if (intent.kind === "worktree" && result.kind === "worktree") {
|
|
337
|
+
if (!intent.worktree)
|
|
338
|
+
throw new Error("Worktree allocation returned without exact persisted plan fields.");
|
|
339
|
+
}
|
|
340
|
+
else if (intent.kind === "workspace" && result.kind === "workspace") {
|
|
341
|
+
for (const [field, value] of Object.entries({
|
|
342
|
+
workspaceId: result.workspaceId, rootTabId: result.rootTabId, rootPaneId: result.rootPaneId,
|
|
343
|
+
}))
|
|
344
|
+
requireExactAllocationText(value, `Workspace allocation ${field}`);
|
|
345
|
+
intent.workspaceId = result.workspaceId;
|
|
346
|
+
intent.rootTabId = result.rootTabId;
|
|
347
|
+
intent.rootPaneId = result.rootPaneId;
|
|
348
|
+
}
|
|
349
|
+
else if (intent.kind === "worker_tab" && result.kind === "worker_tab") {
|
|
350
|
+
requireExactAllocationText(result.tabId, "Worker-tab allocation tabId");
|
|
351
|
+
requireExactAllocationText(result.paneId, "Worker-tab allocation paneId");
|
|
352
|
+
intent.tabId = result.tabId;
|
|
353
|
+
intent.paneId = result.paneId;
|
|
354
|
+
}
|
|
355
|
+
else if (intent.kind !== "agent" || result.kind !== "agent") {
|
|
356
|
+
throw new Error(`${intent.kind} allocation result could not be matched to its persisted plan.`);
|
|
357
|
+
}
|
|
358
|
+
intent.status = "owned";
|
|
359
|
+
}
|
|
360
|
+
function exactCommandResults(results, checks) {
|
|
361
|
+
return results.length === checks.length && results.every((result, index) => {
|
|
362
|
+
const check = checks[index];
|
|
363
|
+
return result.command === check.command
|
|
364
|
+
&& result.args.length === check.args.length
|
|
365
|
+
&& result.args.every((arg, argIndex) => arg === check.args[argIndex]);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function correctionEligible(request, attempt) {
|
|
369
|
+
const initial = attempt.prompts[0];
|
|
370
|
+
if (initial?.kind !== "initial" || attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
|
|
371
|
+
|| attempt.termination)
|
|
372
|
+
return false;
|
|
373
|
+
const latestPrompt = attempt.prompts.at(-1);
|
|
374
|
+
if (latestPrompt.status !== "settled")
|
|
375
|
+
return false;
|
|
376
|
+
if (!attempt.candidate) {
|
|
377
|
+
return attempt.prompts.length === 1 && !attempt.preliminaryChecks && Boolean(initial.failure?.trim());
|
|
378
|
+
}
|
|
379
|
+
const checks = attempt.preliminaryChecks;
|
|
380
|
+
return attempt.prompts.length === 1 && Boolean(checks
|
|
381
|
+
&& checks.phase === "preliminary"
|
|
382
|
+
&& exactCommandResults(checks.results, request.checks)
|
|
383
|
+
&& sameIdentity(checks.candidate, attempt.candidate)
|
|
384
|
+
&& sameIdentity(checks.identityAfter, attempt.candidate)
|
|
385
|
+
&& (!checks.passed && checks.results.some((result) => result.code !== 0 || result.killed)
|
|
386
|
+
|| checks.passed && request.judgment && attempt.preliminaryReview?.phase === "preliminary"
|
|
387
|
+
&& !attempt.preliminaryReview.passed && attempt.preliminaryReview.verdict !== "PASS"
|
|
388
|
+
&& attempt.preliminaryReview.criterion === request.judgment.criterion
|
|
389
|
+
&& sameIdentity(attempt.preliminaryReview.base, attempt.candidateBase)
|
|
390
|
+
&& sameIdentity(attempt.preliminaryReview.tip, attempt.candidate)
|
|
391
|
+
&& sameIdentity(attempt.preliminaryReview.identityAfter, attempt.candidate)));
|
|
392
|
+
}
|
|
393
|
+
function terminal(state) {
|
|
394
|
+
return state.status === "completed" || state.status === "final_failed" || state.status === "superseded" || state.status === "aborted";
|
|
395
|
+
}
|
|
396
|
+
/** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
|
|
397
|
+
export function buildChangesetTaskPrompt(input) {
|
|
398
|
+
if (input.kind === "correction"
|
|
399
|
+
&& (typeof input.failure !== "string" || !input.failure.trim() || input.failure.trim() !== input.failure || input.failure.includes("\0"))) {
|
|
400
|
+
throw new Error("correction failure must be a non-empty exact string.");
|
|
401
|
+
}
|
|
402
|
+
if (input.kind === "followup"
|
|
403
|
+
&& (typeof input.instruction !== "string" || !input.instruction.trim() || input.instruction.trim() !== input.instruction
|
|
404
|
+
|| input.instruction.includes("\0") || input.instruction.length > 32_000)) {
|
|
405
|
+
throw new Error("follow-up instruction must be non-empty exact text of at most 32000 characters.");
|
|
406
|
+
}
|
|
407
|
+
const checks = input.task.checks.map((check) => JSON.stringify({ command: check.command, args: check.args })).join("\n");
|
|
408
|
+
const upstreamTaskData = input.contexts.length
|
|
409
|
+
? ["", "Upstream task data:", formatTextTaskContexts(input.contexts, CHANGESET_TASK_PROMPT_MAX_BYTES)]
|
|
410
|
+
: [];
|
|
411
|
+
const text = input.kind === "followup" ? [
|
|
412
|
+
`Task: ${input.task.id}`,
|
|
413
|
+
`Worktree: ${input.worktreeCwd}`,
|
|
414
|
+
"",
|
|
415
|
+
"Follow-up:",
|
|
416
|
+
input.instruction,
|
|
417
|
+
"",
|
|
418
|
+
"Required checks (direct command/argv):",
|
|
419
|
+
checks,
|
|
420
|
+
"",
|
|
421
|
+
"Continue the same task in the exact worktree above. Commit the complete revision and leave that worktree clean.",
|
|
422
|
+
].join("\n") : [
|
|
423
|
+
`Task: ${input.task.id}`,
|
|
424
|
+
"Goal:",
|
|
425
|
+
input.goal,
|
|
426
|
+
`Worktree: ${input.worktreeCwd}`,
|
|
427
|
+
`Integrated dependencies: ${input.task.dependsOn.length ? input.task.dependsOn.join(", ") : "none"}`,
|
|
428
|
+
"",
|
|
429
|
+
"Requirements:",
|
|
430
|
+
input.task.requirements,
|
|
431
|
+
"",
|
|
432
|
+
"Deliverable:",
|
|
433
|
+
input.task.deliverable,
|
|
434
|
+
...upstreamTaskData,
|
|
435
|
+
"",
|
|
436
|
+
"Required checks (direct command/argv):",
|
|
437
|
+
checks,
|
|
438
|
+
...(input.kind === "correction" ? ["", "Correction failure:", input.failure] : []),
|
|
439
|
+
"",
|
|
440
|
+
"Work only in the exact worktree above. Commit the complete result and leave that worktree clean.",
|
|
441
|
+
].join("\n");
|
|
442
|
+
if (Buffer.byteLength(text, "utf8") > CHANGESET_TASK_PROMPT_MAX_BYTES) {
|
|
443
|
+
throw new Error(`Worker assignment exceeds ${CHANGESET_TASK_PROMPT_MAX_BYTES} bytes.`);
|
|
444
|
+
}
|
|
445
|
+
return text;
|
|
446
|
+
}
|
|
447
|
+
/** Resolve completed text outputs in the consumer's declared context order. */
|
|
448
|
+
export function resolveTextTaskContexts(state, task) {
|
|
449
|
+
return task.contextFrom.map((taskId) => {
|
|
450
|
+
const source = state.tasks.find((candidate) => candidate.taskId === taskId);
|
|
451
|
+
if (!source)
|
|
452
|
+
throw new Error(`Context source ${taskId} for task ${task.id} is absent.`);
|
|
453
|
+
if (source.kind !== "text")
|
|
454
|
+
throw new Error(`Context source ${taskId} for task ${task.id} is not a text task.`);
|
|
455
|
+
const attempt = source.attempts.at(-1);
|
|
456
|
+
if (source.status !== "completed" || attempt?.status !== "completed") {
|
|
457
|
+
throw new Error(`Context source ${taskId} for task ${task.id} is incomplete.`);
|
|
458
|
+
}
|
|
459
|
+
if (!attempt.output)
|
|
460
|
+
throw new Error(`Context source ${taskId} for task ${task.id} is missing output.`);
|
|
461
|
+
return { taskId, text: attempt.output.text };
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/** Format complete context blocks or fail; prompt data is never truncated. */
|
|
465
|
+
export function formatTextTaskContexts(contexts, maxBytes) {
|
|
466
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
467
|
+
throw new Error("Text task context byte cap must be a non-negative safe integer.");
|
|
468
|
+
}
|
|
469
|
+
const text = contexts.map(({ taskId, text: output }) => `Context from task ${taskId}:\n${output}`).join("\n\n");
|
|
470
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) {
|
|
471
|
+
throw new Error(`Text task context exceeds ${maxBytes} UTF-8 bytes.`);
|
|
472
|
+
}
|
|
473
|
+
return text;
|
|
474
|
+
}
|
|
475
|
+
/** Build a bounded text-task assignment with upstream output marked as task data. */
|
|
476
|
+
export function buildTextTaskPrompt(goal, task, contexts) {
|
|
477
|
+
const assignment = [
|
|
478
|
+
`Task: ${task.id}`,
|
|
479
|
+
"Goal:",
|
|
480
|
+
goal,
|
|
481
|
+
"",
|
|
482
|
+
"Requirements:",
|
|
483
|
+
task.requirements,
|
|
484
|
+
"",
|
|
485
|
+
"Deliverable:",
|
|
486
|
+
task.deliverable,
|
|
487
|
+
].join("\n");
|
|
488
|
+
const prefix = contexts.length ? `${assignment}\n\nTask data:\n` : assignment;
|
|
489
|
+
const remainingBytes = TEXT_TASK_PROMPT_MAX_BYTES - Buffer.byteLength(prefix, "utf8");
|
|
490
|
+
if (remainingBytes < 0)
|
|
491
|
+
throw new Error(`Text task prompt exceeds ${TEXT_TASK_PROMPT_MAX_BYTES} UTF-8 bytes.`);
|
|
492
|
+
return `${prefix}${formatTextTaskContexts(contexts, remainingBytes)}`;
|
|
493
|
+
}
|
|
494
|
+
export function readyPendingTasks(state) {
|
|
495
|
+
return state.tasks.filter((task) => task.status === "pending" && taskDependenciesCompleted(state, task.taskId));
|
|
496
|
+
}
|
|
497
|
+
function isDeadline(error, scope) {
|
|
498
|
+
return error instanceof DeadlineExpired || scope.signal.reason instanceof DeadlineExpired;
|
|
499
|
+
}
|
|
500
|
+
export class IsolatedRunner {
|
|
501
|
+
coordinatorRuntime;
|
|
502
|
+
hostRuntime;
|
|
503
|
+
gitRuntime;
|
|
504
|
+
store;
|
|
505
|
+
integrationGit;
|
|
506
|
+
textExecutor;
|
|
507
|
+
currentPolicy;
|
|
508
|
+
productiveLifecycles = new Map();
|
|
509
|
+
followupControls = new Map();
|
|
510
|
+
constructor(coordinatorRuntime, hostRuntime, gitRuntime, store = new FileRunStore(), textExecutor, policy = {
|
|
511
|
+
maxSubagents: 5, maxTurns: 50, childIdleMs: 600_000, childMaxMs: 1_800_000,
|
|
512
|
+
maxCorrections: 1,
|
|
513
|
+
}, currentPolicy = () => policy, integrationGit = new IntegrationGit()) {
|
|
514
|
+
this.coordinatorRuntime = coordinatorRuntime;
|
|
515
|
+
this.hostRuntime = hostRuntime;
|
|
516
|
+
this.gitRuntime = gitRuntime;
|
|
517
|
+
this.store = store;
|
|
518
|
+
this.integrationGit = integrationGit;
|
|
519
|
+
this.textExecutor = textExecutor;
|
|
520
|
+
this.currentPolicy = currentPolicy;
|
|
521
|
+
}
|
|
522
|
+
async listRequests(root) {
|
|
523
|
+
const { states, invalidIds } = await this.store.list(root);
|
|
524
|
+
return { invalidIds, requests: states.map(({ state }) => ({
|
|
525
|
+
id: state.request.id, name: state.request.goal,
|
|
526
|
+
status: state.status === "completed" && (state.tasks.some((task) => task.kind === "changeset" && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
|
|
527
|
+
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
528
|
+
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
|
|
529
|
+
? "completed · retained" : state.status,
|
|
530
|
+
tasks: state.request.tasks.map((task) => ({
|
|
531
|
+
id: task.id, name: task.requirements, kind: task.kind,
|
|
532
|
+
status: state.tasks.find((item) => item.taskId === task.id).status,
|
|
533
|
+
})),
|
|
534
|
+
})) };
|
|
535
|
+
}
|
|
536
|
+
canFollowup(root, requestId, taskId) {
|
|
537
|
+
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
538
|
+
return active !== undefined && active.task.status === "working" && !active.control.isSealed;
|
|
539
|
+
}
|
|
540
|
+
drainFollowups(root, requestId, taskId) {
|
|
541
|
+
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
542
|
+
if (!active || !this.canFollowup(root, requestId, taskId)) {
|
|
543
|
+
throw new Error(`Task ${taskId} is not an active unsealed changeset.`);
|
|
544
|
+
}
|
|
545
|
+
return active.control.drainPending();
|
|
546
|
+
}
|
|
547
|
+
queueFollowup(root, requestId, taskId, instruction) {
|
|
548
|
+
if (typeof instruction !== "string" || !instruction.trim() || instruction.trim() !== instruction
|
|
549
|
+
|| instruction.includes("\0") || instruction.length > 32_000) {
|
|
550
|
+
throw new Error("Follow-up instruction must be non-empty exact text of at most 32000 characters.");
|
|
551
|
+
}
|
|
552
|
+
const active = this.activeControl(realpathSync.native(root), requestId, taskId);
|
|
553
|
+
if (!active || !this.canFollowup(realpathSync.native(root), requestId, taskId)) {
|
|
554
|
+
throw new Error(`Task ${taskId} is not an active unsealed changeset.`);
|
|
555
|
+
}
|
|
556
|
+
if (latestAttempt(active.task).prompts.length + active.control.pendingFollowups >= MAX_WORKER_PROMPTS) {
|
|
557
|
+
throw new Error(`A task may submit at most ${MAX_WORKER_PROMPTS} worker prompts.`);
|
|
558
|
+
}
|
|
559
|
+
active.control.followup(instruction);
|
|
560
|
+
return `Queued follow-up for ${requestId}/${taskId}.`;
|
|
561
|
+
}
|
|
562
|
+
async withProductiveRun(root, operation) {
|
|
563
|
+
return await this.store.withProductiveRunLease(root, async (lease) => {
|
|
564
|
+
if (this.productiveLifecycles.has(root)) {
|
|
565
|
+
throw new Error("Another Pi Subagent productive request is active in this runner.");
|
|
566
|
+
}
|
|
567
|
+
const lifecycle = { lease, stopped: false };
|
|
568
|
+
this.productiveLifecycles.set(root, lifecycle);
|
|
569
|
+
try {
|
|
570
|
+
return await operation(lifecycle);
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
this.productiveLifecycles.delete(root);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
async withLifecycleLock(handle, operation) {
|
|
578
|
+
const lifecycle = this.productiveLifecycles.get(handle.state.root);
|
|
579
|
+
if (!lifecycle)
|
|
580
|
+
throw new Error("Productive lifecycle ownership is unavailable.");
|
|
581
|
+
if (lifecycle.stopped)
|
|
582
|
+
throw new DurableRunStopped(handle.state);
|
|
583
|
+
return await this.store.withLock(handle.state.root, async () => {
|
|
584
|
+
const durable = (await this.store.load(handle.state.root, handle.state.request.id)).state;
|
|
585
|
+
if (terminal(durable)
|
|
586
|
+
&& (!terminal(handle.state) || durable.status !== handle.state.status || durable.status === "aborted")) {
|
|
587
|
+
lifecycle.stopped = true;
|
|
588
|
+
handle.state = durable;
|
|
589
|
+
throw new DurableRunStopped(durable);
|
|
590
|
+
}
|
|
591
|
+
return await operation();
|
|
592
|
+
}, { productiveRunLease: lifecycle.lease });
|
|
593
|
+
}
|
|
594
|
+
async saveProductive(handle) {
|
|
595
|
+
const lifecycle = this.productiveLifecycles.get(handle.state.root);
|
|
596
|
+
if (!lifecycle) {
|
|
597
|
+
await handle.save();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (lifecycle.stopped)
|
|
601
|
+
return;
|
|
602
|
+
await this.withLifecycleLock(handle, async () => await handle.save());
|
|
603
|
+
}
|
|
604
|
+
async callProductive(handle, scope, operation) {
|
|
605
|
+
await this.withLifecycleLock(handle, async () => { });
|
|
606
|
+
let result;
|
|
607
|
+
try {
|
|
608
|
+
result = await scope.call(operation);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
await this.withLifecycleLock(handle, async () => { });
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
await this.withLifecycleLock(handle, async () => { });
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
rethrowStopped(error) {
|
|
618
|
+
if (error instanceof DurableRunStopped)
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
async execute(value, cwd, outerSignal) {
|
|
622
|
+
const request = parseExecuteRequest(value);
|
|
623
|
+
const policy = Object.freeze({ ...this.currentPolicy() });
|
|
624
|
+
const scope = new ProductiveScope(policy.childMaxMs, () => this.coordinatorRuntime.now(), outerSignal);
|
|
625
|
+
const canonicalCwd = realpathSync.native(cwd);
|
|
626
|
+
const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({ request, cwd: canonicalCwd }, context));
|
|
627
|
+
const root = realpathSync.native(prepared.root);
|
|
628
|
+
if (root !== prepared.root)
|
|
629
|
+
throw new Error("Preflight repository root must be canonical.");
|
|
630
|
+
if (request.tasks.some((task) => task.kind === "changeset")) {
|
|
631
|
+
requireRootSuite(root);
|
|
632
|
+
if (!request.finalChecks.some((check) => check.command === FULL_SUITE.command
|
|
633
|
+
&& check.args.length === 1 && check.args[0] === FULL_SUITE.args[0])) {
|
|
634
|
+
request.finalChecks = [FULL_SUITE, ...request.finalChecks];
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const preparedMain = runtimeIdentity(prepared.main, "Preflight Main identity");
|
|
638
|
+
if (!isCleanCommitted(preparedMain))
|
|
639
|
+
throw new Error("Preflight Main identity must be clean and committed.");
|
|
640
|
+
if (this.hasAnyActiveControl(root)) {
|
|
641
|
+
throw new Error("Another Pi Subagent request has active changeset work in this repository.");
|
|
642
|
+
}
|
|
643
|
+
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
644
|
+
const createdAt = this.coordinatorRuntime.now();
|
|
645
|
+
const state = {
|
|
646
|
+
version: RUN_STATE_VERSION,
|
|
647
|
+
request,
|
|
648
|
+
policy,
|
|
649
|
+
correctionCount: 0,
|
|
650
|
+
root,
|
|
651
|
+
requestStartMain: preparedMain,
|
|
652
|
+
main: preparedMain,
|
|
653
|
+
status: "pending",
|
|
654
|
+
tasks: request.tasks.map((task) => task.kind === "changeset"
|
|
655
|
+
? {
|
|
656
|
+
taskId: task.id,
|
|
657
|
+
kind: "changeset",
|
|
658
|
+
status: "pending",
|
|
659
|
+
attempts: [],
|
|
660
|
+
}
|
|
661
|
+
: {
|
|
662
|
+
taskId: task.id,
|
|
663
|
+
kind: "text",
|
|
664
|
+
status: "pending",
|
|
665
|
+
attempts: [],
|
|
666
|
+
}),
|
|
667
|
+
waves: [],
|
|
668
|
+
integration: { candidates: [], generations: [] },
|
|
669
|
+
final: { status: "pending" },
|
|
670
|
+
accepted: false,
|
|
671
|
+
createdAt,
|
|
672
|
+
updatedAt: createdAt,
|
|
673
|
+
};
|
|
674
|
+
const handle = await this.store.withLock(root, async () => {
|
|
675
|
+
await this.store.assertLegacyAdmissionSafe(root);
|
|
676
|
+
return await this.store.create(state);
|
|
677
|
+
}, { productiveRunLease: lifecycle.lease });
|
|
678
|
+
return await this.run(handle, scope, undefined);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
async resume(value, root, outerSignal) {
|
|
682
|
+
const request = parseResumeRequest(value);
|
|
683
|
+
root = realpathSync.native(root);
|
|
684
|
+
if (this.hasActiveControl(root, request.id)) {
|
|
685
|
+
throw new Error(`Pi Subagent request ${request.id} is still active; use follow-up, status, or abort.`);
|
|
686
|
+
}
|
|
687
|
+
if (this.hasAnyActiveControl(root)) {
|
|
688
|
+
throw new Error("Another Pi Subagent request has active changeset work in this repository.");
|
|
689
|
+
}
|
|
690
|
+
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
691
|
+
const handle = await this.store.withLock(root, async () => {
|
|
692
|
+
const loaded = await this.store.load(root, request.id);
|
|
693
|
+
const state = loaded.state;
|
|
694
|
+
if (this.recoverInterrupted(state))
|
|
695
|
+
await loaded.save();
|
|
696
|
+
if (terminal(state))
|
|
697
|
+
throw new Error(`Pi Subagent request ${request.id} is terminal (${state.status}); create a new request.`);
|
|
698
|
+
if (state.integration.candidates.length)
|
|
699
|
+
throw new Error("Retained candidates require subagent_stage; resume cannot stage or finalize them.");
|
|
700
|
+
state.recovery = {
|
|
701
|
+
kind: "resume",
|
|
702
|
+
action: request.action,
|
|
703
|
+
...("taskId" in request ? { taskId: request.taskId } : {}),
|
|
704
|
+
};
|
|
705
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
706
|
+
await loaded.save();
|
|
707
|
+
return loaded;
|
|
708
|
+
}, { productiveRunLease: lifecycle.lease });
|
|
709
|
+
const state = handle.state;
|
|
710
|
+
const current = this.currentPolicy();
|
|
711
|
+
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, current.childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
712
|
+
try {
|
|
713
|
+
if (request.action === "finalize")
|
|
714
|
+
return await this.finalize(handle, scope);
|
|
715
|
+
const task = taskState(state, request.taskId);
|
|
716
|
+
if (task.kind === "text") {
|
|
717
|
+
if (task.status !== "needs_attention")
|
|
718
|
+
throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
|
|
719
|
+
if (request.action === "verify")
|
|
720
|
+
throw new Error(`Text task ${task.taskId} cannot be verified.`);
|
|
721
|
+
if (!textRetryEligible(state, task)) {
|
|
722
|
+
const attempt = task.attempts.at(-1);
|
|
723
|
+
if (attempt?.status !== "failed" || task.attempts.length >= 2) {
|
|
724
|
+
throw new Error(`Text task ${task.taskId} retry requires a failed latest attempt and fewer than two attempts.`);
|
|
725
|
+
}
|
|
726
|
+
throw new Error(`Text task ${task.taskId} dependencies are not completed.`);
|
|
727
|
+
}
|
|
728
|
+
task.status = "pending";
|
|
729
|
+
task.failure = undefined;
|
|
730
|
+
return await this.run(handle, scope, task.taskId);
|
|
731
|
+
}
|
|
732
|
+
if (task.status !== "needs_attention")
|
|
733
|
+
throw new Error(`Task ${task.taskId} is not waiting for deliberate attention.`);
|
|
734
|
+
if (request.action === "retry")
|
|
735
|
+
return await this.retry(handle, task, scope);
|
|
736
|
+
return await this.verifyRetainedTask(handle, task, scope);
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
if (error instanceof DurableRunStopped)
|
|
740
|
+
return this.response(handle.state);
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
if (scope instanceof DeadlineScope)
|
|
745
|
+
scope.close();
|
|
746
|
+
this.closeRequestControls(root, request.id);
|
|
747
|
+
if (!lifecycle.stopped) {
|
|
748
|
+
delete state.recovery;
|
|
749
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
750
|
+
await this.saveProductive(handle);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/** Main selects an exact retained candidate; only the owned integration checkout is writable. */
|
|
756
|
+
async stage(value, root, outerSignal) {
|
|
757
|
+
const action = parseStageRequest(value);
|
|
758
|
+
root = realpathSync.native(root);
|
|
759
|
+
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
760
|
+
const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
|
|
761
|
+
const state = handle.state;
|
|
762
|
+
if (terminal(state) || state.status !== "needs_attention" || (state.integration.refresh && state.integration.refresh.status !== "ready")) {
|
|
763
|
+
throw new Error("Request is not waiting for Main staging or refresh allocation remains uncertain.");
|
|
764
|
+
}
|
|
765
|
+
const candidate = state.integration.candidates.find((item) => item.taskId === action.taskId && item.attempt === action.attempt
|
|
766
|
+
&& sameIdentity(item.tip, action.candidate));
|
|
767
|
+
const task = changesetTaskState(state, action.taskId);
|
|
768
|
+
const attempt = task.attempts[action.attempt - 1];
|
|
769
|
+
const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
|
|
770
|
+
if (action.action === "reject" && candidate?.decision === "rejected" && task.status === "needs_attention"
|
|
771
|
+
&& (attempt?.termination?.status === "unknown" || attempt?.termination?.status === "terminating")
|
|
772
|
+
&& candidate.worker === "retained") {
|
|
773
|
+
const generation = state.integration.generations.at(-1);
|
|
774
|
+
if (action.generation !== (generation?.status === "superseded" ? generation.number + 1 : generation?.number ?? 1)) {
|
|
775
|
+
throw new Error("Rejection recovery has a stale generation.");
|
|
776
|
+
}
|
|
777
|
+
const expected = generation && generation.status !== "superseded"
|
|
778
|
+
? generation.combinedTip ?? generation.stages.at(-1)?.tip ?? generation.integrationBase : state.main;
|
|
779
|
+
if (!sameIdentity(action.expectedTip, expected))
|
|
780
|
+
throw new Error("Rejection recovery has a stale integration tip.");
|
|
781
|
+
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
782
|
+
const termination = attempt.termination;
|
|
783
|
+
const result = await scope.call((context) => this.hostRuntime.reconcileWorkerTermination({
|
|
784
|
+
task: changesetTaskRequest(state, task.taskId), attempt, workerId: termination.workerId,
|
|
785
|
+
candidate: termination.candidate,
|
|
786
|
+
}, context));
|
|
787
|
+
if (result.outcome === "terminated") {
|
|
788
|
+
attempt.termination = { status: "terminated", workerId: termination.workerId,
|
|
789
|
+
candidate: termination.candidate, at: this.coordinatorRuntime.now() };
|
|
790
|
+
task.status = "rejected";
|
|
791
|
+
delete task.failure;
|
|
792
|
+
}
|
|
793
|
+
else
|
|
794
|
+
task.failure = bounded(`Exact worker ${termination.workerId} termination remains unproved; inspect the retained agent and retry rejection recovery.`);
|
|
795
|
+
await this.saveProductive(handle);
|
|
796
|
+
return this.response(state);
|
|
797
|
+
}
|
|
798
|
+
if (!candidate || candidate.worker !== "retained" || candidate.decision || task.status !== "ready_to_integrate" || !worker
|
|
799
|
+
|| !sameIdentity(attempt.readiness.candidate, action.candidate)) {
|
|
800
|
+
throw new Error("Stage action refers to a stale or unowned candidate.");
|
|
801
|
+
}
|
|
802
|
+
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
803
|
+
const generations = state.integration.generations;
|
|
804
|
+
let generation = generations.at(-1);
|
|
805
|
+
const nextNumber = generation?.status === "superseded" ? generation.number + 1 : generation?.number ?? 1;
|
|
806
|
+
if (action.generation !== nextNumber || (generation && !["staging", "conflict", "validation_failed", "ready", "superseded"].includes(generation.status))) {
|
|
807
|
+
throw new Error("Stage action has a stale generation.");
|
|
808
|
+
}
|
|
809
|
+
if (action.action === "revise" || action.action === "reject") {
|
|
810
|
+
if (action.action === "revise" && (!action.instruction?.trim() || action.instruction !== action.instruction.trim() || action.instruction.includes("\0"))) {
|
|
811
|
+
throw new Error("Revision requires an exact nonempty instruction.");
|
|
812
|
+
}
|
|
813
|
+
if (action.action === "reject" && action.instruction !== undefined)
|
|
814
|
+
throw new Error("Rejection cannot prompt a worker.");
|
|
815
|
+
const tip = generation && generation.status !== "superseded"
|
|
816
|
+
? generation.combinedTip ?? generation.stages.at(-1)?.tip ?? generation.stages.at(-1)?.onto ?? generation.integrationBase
|
|
817
|
+
: state.main;
|
|
818
|
+
if (!sameIdentity(action.expectedTip, tip))
|
|
819
|
+
throw new Error("Arbitration has a stale combined tip.");
|
|
820
|
+
const affected = affectedDependents(state, action.taskId);
|
|
821
|
+
if (affected.some((dependent) => dependent.attempts.length >= 2)) {
|
|
822
|
+
throw new Error("A dependent has exhausted its bounded fresh attempts; arbitration cannot invalidate it safely.");
|
|
823
|
+
}
|
|
824
|
+
if (generation && generation.status !== "superseded" && generation.stages.some((stage) => stage.taskId === action.taskId)
|
|
825
|
+
&& generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
|
|
826
|
+
throw new Error("Retained integration worktree limit is exhausted; reconcile the owned checkouts before another rebuild.");
|
|
827
|
+
}
|
|
828
|
+
if (affected.some((dependent) => dependent.kind === "changeset" && (dependent.attempts.at(-1).prompts.some((prompt) => prompt.status === "submitting" || prompt.status === "ambiguous")
|
|
829
|
+
|| dependent.attempts.at(-1).allocations.some((allocation) => allocation.status === "allocating" || allocation.status === "unknown")
|
|
830
|
+
|| dependent.attempts.at(-1).termination))) {
|
|
831
|
+
throw new Error("A dependent has uncertain or released worker resources; reconcile before arbitration.");
|
|
832
|
+
}
|
|
833
|
+
if (generation && generation.status !== "superseded" && generation.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
|
|
834
|
+
throw new Error("Uncertain stage must be reconciled before superseding its generation.");
|
|
835
|
+
}
|
|
836
|
+
if (generation?.status === "superseded" && affected.length) {
|
|
837
|
+
throw new Error("Dependent attempts belong to a superseded generation; choose a fresh staged candidate.");
|
|
838
|
+
}
|
|
839
|
+
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"
|
|
840
|
+
&& stage.tip && sameIdentity(stage.tip, item.waveBase))))) {
|
|
841
|
+
throw new Error("A dependent lacks a proven staged source snapshot.");
|
|
842
|
+
}
|
|
843
|
+
if (action.action === "revise") {
|
|
844
|
+
if (!this.correctionAllowed(state, changesetTaskRequest(state, task.taskId), attempt, true)) {
|
|
845
|
+
throw new Error("Same-worker revision is unavailable; a fresh attempt in this request is not supported.");
|
|
846
|
+
}
|
|
847
|
+
await scope.call((context) => this.integrationGit.inspectWorker(root, worker, candidate.tip, context.signal));
|
|
848
|
+
}
|
|
849
|
+
const main = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
|
|
850
|
+
if (!sameIdentity(main, state.main))
|
|
851
|
+
throw new Error("Main drifted before arbitration.");
|
|
852
|
+
if (generation && generation.status !== "superseded" && generation.stages.some((stage) => stage.taskId === action.taskId)) {
|
|
853
|
+
if (generation.stages.some((stage) => stage.status === "staging" || stage.status === "pending")) {
|
|
854
|
+
throw new Error("Uncertain stage must be reconciled before superseding its generation.");
|
|
855
|
+
}
|
|
856
|
+
if (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status))
|
|
857
|
+
throw new Error("Cannot supersede an unresolved promotion or validation.");
|
|
858
|
+
generation.supersededFrom = generation.status;
|
|
859
|
+
generation.status = "superseded";
|
|
860
|
+
generation.failure = `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`;
|
|
861
|
+
delete generation.combinedTip;
|
|
862
|
+
delete generation.checks;
|
|
863
|
+
delete generation.review;
|
|
864
|
+
delete generation.correction;
|
|
865
|
+
}
|
|
866
|
+
candidate.decision = "rejected";
|
|
867
|
+
for (const dependent of affected) {
|
|
868
|
+
if (dependent.kind === "text") {
|
|
869
|
+
const last = dependent.attempts.at(-1);
|
|
870
|
+
last.status = "superseded";
|
|
871
|
+
delete last.output;
|
|
872
|
+
delete last.failure;
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
dependent.attempts.at(-1).superseded = true;
|
|
876
|
+
for (const old of state.integration.candidates.filter((item) => item.taskId === dependent.taskId && !item.decision)) {
|
|
877
|
+
old.decision = "rejected";
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
dependent.status = "pending";
|
|
881
|
+
delete dependent.failure;
|
|
882
|
+
}
|
|
883
|
+
task.status = "needs_attention";
|
|
884
|
+
task.failure = action.action === "revise" ? action.instruction : "Main rejected this candidate; owned resources remain recorded.";
|
|
885
|
+
await this.saveProductive(handle); // Freeze before touching the exact worker.
|
|
886
|
+
if (action.action === "reject" && allocationByKind(attempt, "agent")) {
|
|
887
|
+
if (await this.terminateWithSafety(handle, task, attempt, action.candidate, scope.signal))
|
|
888
|
+
task.status = "rejected";
|
|
889
|
+
await this.saveProductive(handle);
|
|
890
|
+
}
|
|
891
|
+
if (action.action === "revise") {
|
|
892
|
+
task.status = "working";
|
|
893
|
+
await this.driveWorkerSafely(handle, task, scope, "correction", undefined, true);
|
|
894
|
+
if (changesetTaskState(state, task.taskId).status === "ready_to_integrate")
|
|
895
|
+
this.retainCandidate(state, task);
|
|
896
|
+
state.status = "needs_attention";
|
|
897
|
+
await this.saveProductive(handle);
|
|
898
|
+
}
|
|
899
|
+
return this.response(state);
|
|
900
|
+
}
|
|
901
|
+
if (action.instruction !== undefined)
|
|
902
|
+
throw new Error("Stage/resolve cannot prompt a worker.");
|
|
903
|
+
if (generation?.status === "superseded")
|
|
904
|
+
generation = undefined;
|
|
905
|
+
if (generation && generation.status !== "staging" && generation.status !== "conflict")
|
|
906
|
+
throw new Error("Stage action has a stale generation.");
|
|
907
|
+
const previousStage = generation?.stages.at(-1);
|
|
908
|
+
const expected = previousStage?.status === "staged"
|
|
909
|
+
? previousStage.tip : previousStage?.onto ?? generation?.integrationBase;
|
|
910
|
+
if (action.action === "stage" && generation?.stages.some((stage) => stage.status !== "staged")) {
|
|
911
|
+
throw new Error("A previous stage is unresolved; inspect and resolve it first.");
|
|
912
|
+
}
|
|
913
|
+
if (action.action === "resolve") {
|
|
914
|
+
const pending = generation?.stages.at(-1);
|
|
915
|
+
if (!pending || !["staging", "conflict"].includes(pending.status)
|
|
916
|
+
|| pending.taskId !== action.taskId || pending.attempt !== action.attempt
|
|
917
|
+
|| !sameIdentity(pending.source, action.candidate)
|
|
918
|
+
|| !sameIdentity(pending.onto, action.expectedTip))
|
|
919
|
+
throw new Error("Resolve action has no exact pending stage intent.");
|
|
920
|
+
}
|
|
921
|
+
else if (!sameIdentity(action.expectedTip, expected ?? state.main)
|
|
922
|
+
|| generation?.stages.some((stage) => stage.taskId === action.taskId)) {
|
|
923
|
+
throw new Error("Stage action has a stale combined tip or repeated candidate.");
|
|
924
|
+
}
|
|
925
|
+
if (!isCleanCommitted(action.expectedTip) || action.expectedTip.head !== (expected?.head ?? state.main.head)) {
|
|
926
|
+
throw new Error("Stage action has an invalid integration tip.");
|
|
927
|
+
}
|
|
928
|
+
if (action.action === "stage" && !stagedDependencySnapshot(state, action.taskId, action.expectedTip)) {
|
|
929
|
+
throw new Error("Candidate dependencies are not staged on this exact snapshot.");
|
|
930
|
+
}
|
|
931
|
+
if (action.action === "stage" && !sameIdentity(attempt.waveBase, state.main)
|
|
932
|
+
&& !(state.integration.refresh?.status === "ready"
|
|
933
|
+
&& (sameIdentity(attempt.waveBase, state.requestStartMain)
|
|
934
|
+
|| generations.some((item) => sameIdentity(item.expectedMain, attempt.waveBase)))
|
|
935
|
+
&& !generations.some((item) => item.stages.some((stage) => stage.tip && sameIdentity(stage.tip, attempt.waveBase))))
|
|
936
|
+
&& !generation?.stages.some((stage) => stage.status === "staged" && stage.tip && sameIdentity(stage.tip, attempt.waveBase))) {
|
|
937
|
+
throw new Error("Dependent candidate was launched from a superseded or unstaged snapshot.");
|
|
938
|
+
}
|
|
939
|
+
const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
|
|
940
|
+
if (!sameIdentity(actualMain, state.main))
|
|
941
|
+
throw new Error("Main changed or became dirty; staging is blocked.");
|
|
942
|
+
if (!generation) {
|
|
943
|
+
if (action.action !== "stage")
|
|
944
|
+
throw new Error("No integration generation exists to resolve.");
|
|
945
|
+
if (generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
|
|
946
|
+
throw new Error("Retained integration worktree limit is exhausted; no new generation may be allocated.");
|
|
947
|
+
}
|
|
948
|
+
const result = await scope.call((context) => this.integrationGit.allocate(root, `${action.id}-integration-${action.generation}`, state.main, async (worktree) => {
|
|
949
|
+
const integrationBase = { ...state.main, branch: `refs/heads/${worktree.branch}` };
|
|
950
|
+
generation = {
|
|
951
|
+
number: action.generation, status: "staging", expectedMain: state.main,
|
|
952
|
+
integrationBase,
|
|
953
|
+
order: [action.taskId], stages: [{ taskId: action.taskId, attempt: action.attempt,
|
|
954
|
+
source: action.candidate, onto: integrationBase, status: "pending" }],
|
|
955
|
+
worktree,
|
|
956
|
+
};
|
|
957
|
+
generations.push(generation);
|
|
958
|
+
await this.saveProductive(handle); // Before worktree add.
|
|
959
|
+
}, context.signal));
|
|
960
|
+
if (result.outcome !== "ready") {
|
|
961
|
+
if (!generations.at(-1))
|
|
962
|
+
throw new Error("Integration allocation did not persist an owned worktree plan.");
|
|
963
|
+
return this.response(state);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
generation = generations.at(-1);
|
|
967
|
+
if (!generation?.worktree)
|
|
968
|
+
throw new Error("Integration worktree allocation has no durable ownership record.");
|
|
969
|
+
const worktree = generation.worktree;
|
|
970
|
+
const previousStages = generation.stages.filter((stage) => stage.status === "staged");
|
|
971
|
+
const receipts = previousStages.map((stage) => ({ previous: stage.onto, worker: stage.source, tip: stage.tip }));
|
|
972
|
+
let stage = generation.stages.at(-1);
|
|
973
|
+
if (action.action === "stage") {
|
|
974
|
+
if (!stage || stage.status === "staged") {
|
|
975
|
+
stage = { taskId: action.taskId, attempt: action.attempt, source: action.candidate,
|
|
976
|
+
onto: action.expectedTip, status: "staging" };
|
|
977
|
+
delete generation.combinedTip;
|
|
978
|
+
generation.order.push(action.taskId);
|
|
979
|
+
generation.stages.push(stage);
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
stage.status = "staging";
|
|
983
|
+
}
|
|
984
|
+
await this.saveProductive(handle); // Exact candidate and previous tip before merge.
|
|
985
|
+
}
|
|
986
|
+
const outcome = await scope.call((context) => action.action === "stage"
|
|
987
|
+
? this.integrationGit.stage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal)
|
|
988
|
+
: this.integrationGit.reconcileStage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal));
|
|
989
|
+
if (outcome.outcome === "ready" && outcome.value !== "not_started") {
|
|
990
|
+
stage.status = "staged";
|
|
991
|
+
stage.tip = outcome.value.tip;
|
|
992
|
+
delete stage.failure;
|
|
993
|
+
generation.combinedTip = outcome.value.tip;
|
|
994
|
+
generation.status = "staging";
|
|
995
|
+
delete generation.failure;
|
|
996
|
+
}
|
|
997
|
+
else if (outcome.outcome === "conflict") {
|
|
998
|
+
stage.status = "conflict";
|
|
999
|
+
stage.failure = bounded(outcome.failure);
|
|
1000
|
+
generation.status = "conflict";
|
|
1001
|
+
generation.failure = stage.failure;
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
stage.failure = bounded(outcome.outcome === "ready"
|
|
1005
|
+
? "Merge has not started; intent retained. No automatic retry."
|
|
1006
|
+
: outcome.failure);
|
|
1007
|
+
}
|
|
1008
|
+
// Unproved outcomes keep the intent; never replay a merge automatically.
|
|
1009
|
+
await this.saveProductive(handle);
|
|
1010
|
+
return this.response(state);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
stageReceipts(generation) {
|
|
1014
|
+
return generation.stages.map((stage) => ({ previous: stage.onto, worker: stage.source, tip: stage.tip }));
|
|
1015
|
+
}
|
|
1016
|
+
/** Only Main can validate/promote the exact selected, clean combined tip. */
|
|
1017
|
+
async integrate(value, root, outerSignal) {
|
|
1018
|
+
const action = parseIntegrationAction(value);
|
|
1019
|
+
root = realpathSync.native(root);
|
|
1020
|
+
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
1021
|
+
const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
|
|
1022
|
+
const state = handle.state;
|
|
1023
|
+
const request = parseExecuteRequest(state.request);
|
|
1024
|
+
const generation = state.integration.generations.at(-1);
|
|
1025
|
+
if (action.action === "release") {
|
|
1026
|
+
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
1027
|
+
if (action.taskId !== undefined || action.attempt !== undefined) {
|
|
1028
|
+
if (!action.taskId || !action.attempt || action.generation !== (generation?.number ?? 1)) {
|
|
1029
|
+
throw new Error("Candidate release requires the latest generation and exact task attempt.");
|
|
1030
|
+
}
|
|
1031
|
+
const task = changesetTaskState(state, action.taskId);
|
|
1032
|
+
const attempt = task.attempts[action.attempt - 1];
|
|
1033
|
+
const candidates = state.integration.candidates.filter((item) => item.taskId === action.taskId && item.attempt === action.attempt);
|
|
1034
|
+
const candidate = candidates.find((item) => sameIdentity(item.tip, action.expectedTip));
|
|
1035
|
+
const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
|
|
1036
|
+
if (!candidate || candidate.decision !== "rejected" || task.status !== "rejected"
|
|
1037
|
+
|| attempt?.termination?.status !== "terminated"
|
|
1038
|
+
|| !sameIdentity(attempt.termination.candidate, action.expectedTip) || !worker
|
|
1039
|
+
|| candidates.some((item) => !item.decision)
|
|
1040
|
+
|| state.integration.generations.some((item) => item.stages.some((stage) => stage.taskId === action.taskId
|
|
1041
|
+
&& stage.attempt === action.attempt && item.cleanup?.every((step) => step.status === "completed") !== true))) {
|
|
1042
|
+
throw new Error("Release requires a rejected terminated worker and no retained staged generation using its checkout.");
|
|
1043
|
+
}
|
|
1044
|
+
await this.releaseRejected(handle, task, attempt, worker, scope);
|
|
1045
|
+
return this.response(state);
|
|
1046
|
+
}
|
|
1047
|
+
const superseded = state.integration.generations[action.generation - 1];
|
|
1048
|
+
if (!superseded?.worktree || superseded.status !== "superseded"
|
|
1049
|
+
|| !sameIdentity(action.expectedTip, superseded.stages.at(-1)?.tip ?? superseded.integrationBase)
|
|
1050
|
+
|| superseded.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
|
|
1051
|
+
throw new Error("Release requires an exact superseded integration checkout without an uncertain stage.");
|
|
1052
|
+
}
|
|
1053
|
+
await this.releaseSuperseded(handle, superseded, action.expectedTip, scope);
|
|
1054
|
+
return this.response(state);
|
|
1055
|
+
}
|
|
1056
|
+
if (action.action === "refresh") {
|
|
1057
|
+
if (terminal(state) || state.status !== "needs_attention" || state.integration.refresh?.status === "pending"
|
|
1058
|
+
|| state.integration.refresh?.status === "unknown" || !sameIdentity(state.main, action.expectedMain)
|
|
1059
|
+
|| action.generation !== state.integration.generations.length + 1
|
|
1060
|
+
|| (generation && (generation.status === "superseded" ? !sameIdentity(action.expectedTip, state.main)
|
|
1061
|
+
: !sameIdentity(action.expectedTip, generation.combinedTip
|
|
1062
|
+
?? generation.stages.at(-1)?.onto ?? generation.integrationBase)
|
|
1063
|
+
|| (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status)
|
|
1064
|
+
&& !(generation.status === "promotion_failed" && generation.promotion?.outcome === "drift"))
|
|
1065
|
+
|| generation.stages.some((stage) => stage.status === "pending" || stage.status === "staging")))
|
|
1066
|
+
|| (!generation && !sameIdentity(action.expectedTip, state.main))) {
|
|
1067
|
+
throw new Error("Refresh has a stale Main, generation, or unresolved integration intent.");
|
|
1068
|
+
}
|
|
1069
|
+
if (!state.integration.candidates.some((candidate) => !candidate.decision && candidate.worker === "retained")) {
|
|
1070
|
+
throw new Error("Refresh requires at least one retained immutable candidate.");
|
|
1071
|
+
}
|
|
1072
|
+
if (state.integration.generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
|
|
1073
|
+
throw new Error("Retained integration worktree limit is exhausted.");
|
|
1074
|
+
}
|
|
1075
|
+
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
1076
|
+
const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
|
|
1077
|
+
if (!isCleanCommitted(action.newMain) || !sameIdentity(actualMain, action.newMain)) {
|
|
1078
|
+
throw new Error("Refresh requires the exact new clean Main identity.");
|
|
1079
|
+
}
|
|
1080
|
+
await scope.call((context) => this.integrationGit.inspectMainAdvance(root, action.expectedMain, action.newMain, context.signal));
|
|
1081
|
+
// Old dependent snapshots cannot be replayed on a new Main tip. Retain their resources and
|
|
1082
|
+
// invalidate their readiness before admitting any new stages or dependent launches.
|
|
1083
|
+
const stale = new Set();
|
|
1084
|
+
for (const stage of generation?.stages ?? []) {
|
|
1085
|
+
for (const dependent of affectedDependents(state, stage.taskId)) {
|
|
1086
|
+
if (dependent.attempts.length && dependent.status !== "pending")
|
|
1087
|
+
stale.add(dependent);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if ([...stale].some((task) => task.attempts.length >= 2 || task.status === "working"
|
|
1091
|
+
|| task.kind === "changeset" && (task.attempts.at(-1).termination
|
|
1092
|
+
|| task.attempts.at(-1).prompts.some((item) => item.status === "submitting" || item.status === "ambiguous")
|
|
1093
|
+
|| task.attempts.at(-1).allocations.some((item) => item.status === "allocating" || item.status === "unknown")))) {
|
|
1094
|
+
throw new Error("A dependent has unproved resources or exhausted fresh attempts; refresh is blocked.");
|
|
1095
|
+
}
|
|
1096
|
+
if (generation && generation.status !== "superseded") {
|
|
1097
|
+
if (generation.status === "promotion_failed") {
|
|
1098
|
+
generation.supersededPromotion = generation.promotion;
|
|
1099
|
+
delete generation.promotion;
|
|
1100
|
+
}
|
|
1101
|
+
generation.supersededFrom = generation.status;
|
|
1102
|
+
generation.status = "superseded";
|
|
1103
|
+
generation.failure = "Main advanced; retained generation is read-only. Restage immutable candidates in a new generation.";
|
|
1104
|
+
delete generation.combinedTip;
|
|
1105
|
+
delete generation.checks;
|
|
1106
|
+
delete generation.review;
|
|
1107
|
+
if (!generation.supersededPromotion)
|
|
1108
|
+
delete generation.correction;
|
|
1109
|
+
}
|
|
1110
|
+
for (const task of stale) {
|
|
1111
|
+
if (task.kind === "text") {
|
|
1112
|
+
const last = task.attempts.at(-1);
|
|
1113
|
+
last.status = "superseded";
|
|
1114
|
+
delete last.output;
|
|
1115
|
+
delete last.failure;
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
task.attempts.at(-1).superseded = true;
|
|
1119
|
+
for (const candidate of state.integration.candidates.filter((item) => item.taskId === task.taskId && !item.decision))
|
|
1120
|
+
candidate.decision = "rejected";
|
|
1121
|
+
}
|
|
1122
|
+
task.status = "pending";
|
|
1123
|
+
delete task.failure;
|
|
1124
|
+
}
|
|
1125
|
+
state.main = action.newMain;
|
|
1126
|
+
state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation, status: "pending" };
|
|
1127
|
+
await this.saveProductive(handle); // Durable supersession and refresh intent before worktree creation.
|
|
1128
|
+
let result;
|
|
1129
|
+
try {
|
|
1130
|
+
result = await scope.call((context) => this.integrationGit.allocate(root, `${action.id}-integration-${action.generation}`, action.newMain, async (worktree) => {
|
|
1131
|
+
state.integration.generations.push({ number: action.generation, status: "staging", expectedMain: action.newMain,
|
|
1132
|
+
integrationBase: { ...action.newMain, branch: `refs/heads/${worktree.branch}` },
|
|
1133
|
+
order: [], stages: [], worktree });
|
|
1134
|
+
state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation,
|
|
1135
|
+
status: "unknown", failure: "Integration allocation has not been proved; retain the recorded checkout." };
|
|
1136
|
+
await this.saveProductive(handle);
|
|
1137
|
+
}, context.signal));
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
// A prepared checkout remains recorded; no repeated allocation from this intent.
|
|
1141
|
+
state.integration.refresh.failure = bounded(`Integration allocation uncertain: ${errorText(error)}`);
|
|
1142
|
+
await this.saveProductive(handle);
|
|
1143
|
+
return this.response(state);
|
|
1144
|
+
}
|
|
1145
|
+
if (result.outcome === "ready" && state.integration.generations.at(-1)?.number === action.generation) {
|
|
1146
|
+
state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation, status: "ready" };
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
state.integration.refresh.failure = bounded(result.outcome === "ready" ? "Allocation lacks a recorded checkout." : result.failure);
|
|
1150
|
+
}
|
|
1151
|
+
await this.saveProductive(handle);
|
|
1152
|
+
return this.response(state);
|
|
1153
|
+
}
|
|
1154
|
+
if (!generation || generation.number !== action.generation || !generation.worktree
|
|
1155
|
+
|| !generation.combinedTip || !sameIdentity(generation.combinedTip, action.expectedTip)
|
|
1156
|
+
|| (terminal(state) && !(state.status === "completed" && action.action === "cleanup"))
|
|
1157
|
+
|| (state.status !== "needs_attention" && !(state.status === "completed" && action.action === "cleanup")))
|
|
1158
|
+
throw new Error("Integration action has a stale generation or combined tip.");
|
|
1159
|
+
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
1160
|
+
const receipts = this.stageReceipts(generation);
|
|
1161
|
+
if (action.action === "advance") {
|
|
1162
|
+
if (generation.status !== "staging" || generation.stages.some((stage) => stage.status !== "staged")
|
|
1163
|
+
|| generation.correction || !isCleanCommitted(action.expectedTip)) {
|
|
1164
|
+
throw new Error("Advance requires an exact clean staged snapshot before validation or correction.");
|
|
1165
|
+
}
|
|
1166
|
+
const ready = readyPendingTasks(state).filter((task) => hasChangesetDependency(state, task.taskId)
|
|
1167
|
+
&& stagedDependencySnapshot(state, task.taskId, action.expectedTip));
|
|
1168
|
+
if (!ready.length)
|
|
1169
|
+
throw new Error("No dependent task is ready on this staged snapshot.");
|
|
1170
|
+
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1171
|
+
return await this.run(handle, scope, undefined, action.expectedTip);
|
|
1172
|
+
}
|
|
1173
|
+
if (action.action === "cleanup") {
|
|
1174
|
+
if (generation.status !== "promoted")
|
|
1175
|
+
throw new Error("Cleanup requires proven exact promotion.");
|
|
1176
|
+
await this.cleanupPromoted(handle, generation, receipts, scope);
|
|
1177
|
+
return this.response(state);
|
|
1178
|
+
}
|
|
1179
|
+
if (action.action === "reconcile") {
|
|
1180
|
+
if (generation.status === "validating") {
|
|
1181
|
+
delete generation.checks;
|
|
1182
|
+
delete generation.review;
|
|
1183
|
+
generation.status = "validation_failed";
|
|
1184
|
+
generation.failure = "Interrupted validation has no definitive exact full-suite result; Main must rebuild and recheck.";
|
|
1185
|
+
await this.saveProductive(handle);
|
|
1186
|
+
return this.response(state);
|
|
1187
|
+
}
|
|
1188
|
+
if (!generation.promotion || !["promoting", "unknown"].includes(generation.promotion.status)) {
|
|
1189
|
+
throw new Error("No interrupted promotion intent to reconcile.");
|
|
1190
|
+
}
|
|
1191
|
+
const result = await scope.call((context) => this.integrationGit.reconcilePromotion(root, generation.worktree, generation.expectedMain, receipts, context.signal, generation.correction));
|
|
1192
|
+
if (result.outcome === "ready") {
|
|
1193
|
+
generation.promotion.status = "promoted";
|
|
1194
|
+
generation.promotion.mainAfter = result.value;
|
|
1195
|
+
delete generation.promotion.failure;
|
|
1196
|
+
generation.status = "promoted";
|
|
1197
|
+
state.main = result.value;
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
generation.promotion.status = "unknown";
|
|
1201
|
+
generation.promotion.failure = bounded(result.failure);
|
|
1202
|
+
generation.status = "promotion_unknown";
|
|
1203
|
+
}
|
|
1204
|
+
await this.saveProductive(handle);
|
|
1205
|
+
if (result.outcome === "ready")
|
|
1206
|
+
await this.cleanupPromoted(handle, generation, receipts, scope);
|
|
1207
|
+
return this.response(state);
|
|
1208
|
+
}
|
|
1209
|
+
if (action.action === "correct") {
|
|
1210
|
+
if (generation.status !== "validation_failed" || generation.correction || !generation.checks
|
|
1211
|
+
|| (generation.checks.passed && generation.review?.passed !== false)) {
|
|
1212
|
+
throw new Error("Correction requires a definitive failed combined check or review and no prior correction.");
|
|
1213
|
+
}
|
|
1214
|
+
const from = generation.combinedTip;
|
|
1215
|
+
const to = await scope.call((context) => this.gitRuntime.inspectMain({ root: generation.worktree.path }, context));
|
|
1216
|
+
await scope.call((context) => this.integrationGit.inspectCorrection(root, generation.worktree, generation.expectedMain, receipts, from, to, context.signal));
|
|
1217
|
+
generation.correction = { from, to };
|
|
1218
|
+
generation.combinedTip = to;
|
|
1219
|
+
delete generation.checks;
|
|
1220
|
+
delete generation.review;
|
|
1221
|
+
delete generation.failure;
|
|
1222
|
+
generation.status = "staging";
|
|
1223
|
+
await this.saveProductive(handle);
|
|
1224
|
+
return this.response(state);
|
|
1225
|
+
}
|
|
1226
|
+
if (action.action === "validate") {
|
|
1227
|
+
if (generation.status !== "staging" || !receipts.length
|
|
1228
|
+
|| generation.stages.some((stage) => stage.status !== "staged"))
|
|
1229
|
+
throw new Error("Validation requires fully committed explicit stages and no prior validation attempt.");
|
|
1230
|
+
generation.status = "validating";
|
|
1231
|
+
await this.saveProductive(handle); // Record the exact validation intent before running checks.
|
|
1232
|
+
try {
|
|
1233
|
+
if (!request.finalChecks.some((check) => check.command === FULL_SUITE.command
|
|
1234
|
+
&& check.args.length === 1 && check.args[0] === FULL_SUITE.args[0]))
|
|
1235
|
+
throw new Error("Canonical full-suite command was not recorded at admission.");
|
|
1236
|
+
if (requireRootSuite(generation.worktree.path) !== requireRootSuite(root)) {
|
|
1237
|
+
throw new Error("Combined tip changed the admitted canonical root test script.");
|
|
1238
|
+
}
|
|
1239
|
+
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1240
|
+
const checks = await this.runCheckBatch(handle, request.finalChecks, action.expectedTip, "final", scope, undefined, generation.worktree.path);
|
|
1241
|
+
generation.checks = checks;
|
|
1242
|
+
await this.saveProductive(handle);
|
|
1243
|
+
if (!checkBatchPasses(checks, request.finalChecks, action.expectedTip))
|
|
1244
|
+
throw new Error("Combined full-suite check failed or changed the integration tip.");
|
|
1245
|
+
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1246
|
+
if (request.finalJudgment) {
|
|
1247
|
+
const judgment = request.finalJudgment;
|
|
1248
|
+
generation.review = await this.runReview(handle, judgment.criterion, generation.expectedMain, action.expectedTip, judgment.role, judgment.modelClass, "final", scope, undefined, generation.worktree.path);
|
|
1249
|
+
await this.saveProductive(handle);
|
|
1250
|
+
if (!reviewEvidencePasses(generation.review, "final", judgment.criterion, generation.expectedMain, action.expectedTip)) {
|
|
1251
|
+
throw new Error("Combined judgment failed or changed the integration tip.");
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1255
|
+
if (!integrationGenerationPasses(generation, request))
|
|
1256
|
+
throw new Error("Combined validation is incomplete.");
|
|
1257
|
+
generation.status = "ready";
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
if (integrationGenerationPasses(generation, request)) {
|
|
1261
|
+
delete generation.checks;
|
|
1262
|
+
delete generation.review; // Drift invalidates otherwise passing evidence.
|
|
1263
|
+
}
|
|
1264
|
+
generation.status = "validation_failed";
|
|
1265
|
+
generation.failure = bounded(`Combined validation requires Main attention: ${errorText(error)}`);
|
|
1266
|
+
}
|
|
1267
|
+
await this.saveProductive(handle);
|
|
1268
|
+
return this.response(state);
|
|
1269
|
+
}
|
|
1270
|
+
if (generation.status !== "ready" || !integrationGenerationPasses(generation, request)) {
|
|
1271
|
+
throw new Error("Promotion requires exact successful combined validation.");
|
|
1272
|
+
}
|
|
1273
|
+
for (const definition of request.tasks.filter((task) => hasChangesetDependency(state, task.id))) {
|
|
1274
|
+
const dependent = taskState(state, definition.id);
|
|
1275
|
+
const launch = [...state.waves].reverse().find((wave) => wave.taskIds.includes(definition.id));
|
|
1276
|
+
const stageIndex = generation.stages.findIndex((stage) => stage.taskId === definition.id);
|
|
1277
|
+
const attempt = dependent.kind === "changeset" ? dependent.attempts.at(-1) : undefined;
|
|
1278
|
+
if (!launch || !dependent.attempts.length || (definition.kind === "text" && dependent.status !== "completed")
|
|
1279
|
+
|| (definition.kind === "changeset" && (stageIndex < 0 || !attempt
|
|
1280
|
+
|| generation.stages[stageIndex].status !== "staged" || generation.stages[stageIndex].attempt !== dependent.attempts.length
|
|
1281
|
+
|| attempt.waveNumber !== launch.number || !sameIdentity(attempt.waveBase, launch.base)))) {
|
|
1282
|
+
throw new Error(`Dependent ${definition.id} lacks a fresh stage from the selected integration snapshot.`);
|
|
1283
|
+
}
|
|
1284
|
+
if (!(stageIndex < 0 ? generation.stages : generation.stages.slice(0, stageIndex)).some((stage) => stage.status === "staged" && sameIdentity(stage.tip, launch.base))) {
|
|
1285
|
+
throw new Error(`Dependent ${definition.id} ran on a superseded snapshot.`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1289
|
+
generation.promotion = { status: "promoting", expectedMain: generation.expectedMain, tip: action.expectedTip };
|
|
1290
|
+
generation.status = "promoting";
|
|
1291
|
+
await this.saveProductive(handle); // Intent before any Main mutation.
|
|
1292
|
+
let outcome;
|
|
1293
|
+
try {
|
|
1294
|
+
outcome = await scope.call((context) => this.integrationGit.promote({
|
|
1295
|
+
root, integration: generation.worktree, base: generation.expectedMain, stages: receipts,
|
|
1296
|
+
checks: generation.checks, commands: request.finalChecks,
|
|
1297
|
+
review: generation.review, criterion: request.finalJudgment?.criterion, correction: generation.correction,
|
|
1298
|
+
}, context.signal));
|
|
1299
|
+
}
|
|
1300
|
+
catch (error) {
|
|
1301
|
+
outcome = { outcome: "unknown", failure: `Interrupted promotion: ${errorText(error)}` };
|
|
1302
|
+
}
|
|
1303
|
+
if (outcome.outcome === "ready") {
|
|
1304
|
+
generation.promotion.status = "promoted";
|
|
1305
|
+
generation.promotion.mainAfter = outcome.value;
|
|
1306
|
+
generation.status = "promoted";
|
|
1307
|
+
state.main = outcome.value;
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
generation.promotion.status = outcome.outcome === "unknown" ? "unknown" : "failed";
|
|
1311
|
+
generation.promotion.failure = bounded(outcome.failure);
|
|
1312
|
+
if (outcome.outcome === "drift")
|
|
1313
|
+
generation.promotion.outcome = "drift";
|
|
1314
|
+
generation.status = outcome.outcome === "unknown" ? "promotion_unknown" : "promotion_failed";
|
|
1315
|
+
}
|
|
1316
|
+
await this.saveProductive(handle);
|
|
1317
|
+
if (outcome.outcome === "ready")
|
|
1318
|
+
await this.cleanupPromoted(handle, generation, receipts, scope);
|
|
1319
|
+
return this.response(state);
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
async releaseRejected(handle, task, attempt, worker, scope) {
|
|
1323
|
+
const state = handle.state;
|
|
1324
|
+
const candidates = state.integration.candidates.filter((item) => item.taskId === task.taskId && item.attempt === attempt.number);
|
|
1325
|
+
for (const candidate of candidates)
|
|
1326
|
+
candidate.worker = "release_pending";
|
|
1327
|
+
await this.saveProductive(handle);
|
|
1328
|
+
try {
|
|
1329
|
+
for (const kind of CLEANUP_KINDS) {
|
|
1330
|
+
const step = attempt.cleanup.find((item) => item.kind === kind);
|
|
1331
|
+
if (step.status === "completed")
|
|
1332
|
+
continue;
|
|
1333
|
+
step.status = "running";
|
|
1334
|
+
await this.saveProductive(handle);
|
|
1335
|
+
if (kind === "worker_tab" || kind === "workspace") {
|
|
1336
|
+
const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.cleanupHost({
|
|
1337
|
+
requestId: state.request.id, kind, task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1338
|
+
}, context));
|
|
1339
|
+
if (result.outcome === "blocked")
|
|
1340
|
+
throw new Error(result.failure);
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
const candidate = attempt.termination.candidate;
|
|
1344
|
+
const result = await this.callProductive(handle, scope, (context) => this.integrationGit.release(state.root, worker, candidate, kind, context.signal));
|
|
1345
|
+
if (result.outcome !== "ready")
|
|
1346
|
+
throw new Error(result.failure);
|
|
1347
|
+
}
|
|
1348
|
+
step.status = "completed";
|
|
1349
|
+
delete step.failure;
|
|
1350
|
+
await this.saveProductive(handle);
|
|
1351
|
+
}
|
|
1352
|
+
for (const candidate of candidates)
|
|
1353
|
+
candidate.worker = "released";
|
|
1354
|
+
await this.saveProductive(handle);
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
const pending = attempt.cleanup.find((step) => step.status === "running");
|
|
1358
|
+
if (pending)
|
|
1359
|
+
pending.failure = bounded(`Release requires attention: ${errorText(error)}`);
|
|
1360
|
+
await this.saveProductive(handle);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
async releaseSuperseded(handle, generation, tip, scope) {
|
|
1364
|
+
generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
|
|
1365
|
+
await this.saveProductive(handle);
|
|
1366
|
+
try {
|
|
1367
|
+
for (const kind of ["worktree", "branch"]) {
|
|
1368
|
+
const step = generation.cleanup.find((item) => item.kind === kind);
|
|
1369
|
+
if (step.status === "completed")
|
|
1370
|
+
continue;
|
|
1371
|
+
step.status = "running";
|
|
1372
|
+
await this.saveProductive(handle);
|
|
1373
|
+
const result = await this.callProductive(handle, scope, (context) => this.integrationGit.release(handle.state.root, generation.worktree, tip, kind, context.signal));
|
|
1374
|
+
if (result.outcome !== "ready")
|
|
1375
|
+
throw new Error(result.failure);
|
|
1376
|
+
step.status = "completed";
|
|
1377
|
+
delete step.failure;
|
|
1378
|
+
await this.saveProductive(handle);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch (error) {
|
|
1382
|
+
const pending = generation.cleanup.find((step) => step.status === "running");
|
|
1383
|
+
if (pending)
|
|
1384
|
+
pending.failure = bounded(`Release requires attention: ${errorText(error)}`);
|
|
1385
|
+
await this.saveProductive(handle);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async cleanupPromoted(handle, generation, receipts, scope) {
|
|
1389
|
+
const state = handle.state;
|
|
1390
|
+
const promoted = generation.promotion?.mainAfter;
|
|
1391
|
+
if (generation.status !== "promoted" || !promoted || !generation.worktree)
|
|
1392
|
+
throw new Error("Cleanup requires exact proven promotion.");
|
|
1393
|
+
// Promotion proves selection independently of worker or checkout cleanup.
|
|
1394
|
+
for (const stage of generation.stages)
|
|
1395
|
+
changesetTaskState(state, stage.taskId).status = "completed";
|
|
1396
|
+
await this.saveProductive(handle);
|
|
1397
|
+
try {
|
|
1398
|
+
const main = await this.callProductive(handle, scope, (context) => this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
1399
|
+
if (!sameIdentity(main, promoted))
|
|
1400
|
+
throw new Error("Main changed or became dirty after promotion; cleanup is blocked.");
|
|
1401
|
+
for (const stage of generation.stages) {
|
|
1402
|
+
const task = changesetTaskState(state, stage.taskId);
|
|
1403
|
+
const attempt = task.attempts[stage.attempt - 1];
|
|
1404
|
+
const candidate = state.integration.candidates.find((item) => item.taskId === stage.taskId && item.attempt === stage.attempt
|
|
1405
|
+
&& sameIdentity(item.tip, stage.source));
|
|
1406
|
+
const worker = allocationByKind(attempt, "worktree")?.worktree;
|
|
1407
|
+
if (!worker)
|
|
1408
|
+
throw new Error("Promoted worker lacks a proven checkout.");
|
|
1409
|
+
candidate.worker = "release_pending";
|
|
1410
|
+
await this.saveProductive(handle);
|
|
1411
|
+
if (attempt.termination?.status !== "terminated") {
|
|
1412
|
+
if (attempt.termination) {
|
|
1413
|
+
const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.reconcileWorkerTermination({
|
|
1414
|
+
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1415
|
+
workerId: attempt.termination.workerId, candidate: stage.source,
|
|
1416
|
+
}, context));
|
|
1417
|
+
if (result.outcome !== "terminated")
|
|
1418
|
+
throw new Error("Worker termination cannot be proved; never replay termination.");
|
|
1419
|
+
attempt.termination = { status: "terminated", workerId: attempt.termination.workerId,
|
|
1420
|
+
candidate: stage.source, at: this.coordinatorRuntime.now() };
|
|
1421
|
+
await this.saveProductive(handle);
|
|
1422
|
+
}
|
|
1423
|
+
else if (!await this.terminateWithSafety(handle, task, attempt, stage.source, scope.signal)) {
|
|
1424
|
+
throw new Error("Worker termination was not proved.");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
for (const kind of CLEANUP_KINDS) {
|
|
1428
|
+
const step = attempt.cleanup.find((item) => item.kind === kind);
|
|
1429
|
+
if (step.status === "completed")
|
|
1430
|
+
continue;
|
|
1431
|
+
step.status = "running";
|
|
1432
|
+
await this.saveProductive(handle);
|
|
1433
|
+
if (kind === "worker_tab" || kind === "workspace") {
|
|
1434
|
+
const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.cleanupHost({
|
|
1435
|
+
requestId: state.request.id, kind, task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1436
|
+
}, context));
|
|
1437
|
+
if (result.outcome === "blocked")
|
|
1438
|
+
throw new Error(result.failure);
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
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));
|
|
1442
|
+
if (result.outcome !== "ready")
|
|
1443
|
+
throw new Error(result.failure);
|
|
1444
|
+
}
|
|
1445
|
+
step.status = "completed";
|
|
1446
|
+
delete step.failure;
|
|
1447
|
+
await this.saveProductive(handle);
|
|
1448
|
+
}
|
|
1449
|
+
candidate.worker = "released";
|
|
1450
|
+
// Same-attempt revisions share this exact worker checkout; release historical
|
|
1451
|
+
// rejected tips only after the physical checkout has been proved removed.
|
|
1452
|
+
for (const old of state.integration.candidates.filter((item) => item.decision === "rejected"
|
|
1453
|
+
&& item.taskId === stage.taskId && item.attempt === stage.attempt))
|
|
1454
|
+
old.worker = "released";
|
|
1455
|
+
await this.saveProductive(handle);
|
|
1456
|
+
}
|
|
1457
|
+
generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
|
|
1458
|
+
await this.saveProductive(handle);
|
|
1459
|
+
for (const kind of ["worktree", "branch"]) {
|
|
1460
|
+
const step = generation.cleanup.find((item) => item.kind === kind);
|
|
1461
|
+
if (step.status === "completed")
|
|
1462
|
+
continue;
|
|
1463
|
+
step.status = "running";
|
|
1464
|
+
await this.saveProductive(handle);
|
|
1465
|
+
const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, generation.worktree, generation.expectedMain, receipts, promoted, kind, context.signal, generation.correction));
|
|
1466
|
+
if (result.outcome !== "ready")
|
|
1467
|
+
throw new Error(result.failure);
|
|
1468
|
+
step.status = "completed";
|
|
1469
|
+
delete step.failure;
|
|
1470
|
+
await this.saveProductive(handle);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
catch (error) {
|
|
1474
|
+
generation.failure = undefined; // Promotion remains proven; cleanup evidence stays inspectable.
|
|
1475
|
+
const pending = generation.cleanup?.find((step) => step.status === "running")
|
|
1476
|
+
?? generation.stages.flatMap((stage) => changesetTaskState(state, stage.taskId).attempts[stage.attempt - 1].cleanup)
|
|
1477
|
+
.find((step) => step.status === "running");
|
|
1478
|
+
if (pending)
|
|
1479
|
+
pending.failure = bounded(`Cleanup requires attention: ${errorText(error)}`);
|
|
1480
|
+
await this.saveProductive(handle);
|
|
1481
|
+
}
|
|
1482
|
+
if (state.tasks.every((task) => task.status === "completed" || task.status === "rejected")
|
|
1483
|
+
&& state.integration.candidates.every((candidate) => candidate.decision || generation.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt && sameIdentity(stage.source, candidate.tip)))) {
|
|
1484
|
+
state.final = { status: "passed", identity: promoted, checks: generation.checks,
|
|
1485
|
+
...(generation.review ? { review: generation.review } : {}) };
|
|
1486
|
+
state.status = "completed";
|
|
1487
|
+
state.accepted = true;
|
|
1488
|
+
state.acceptedAt = this.coordinatorRuntime.now();
|
|
1489
|
+
await this.saveProductive(handle);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
async requireExactIntegration(handle, generation, receipts, scope) {
|
|
1493
|
+
const root = handle.state.root;
|
|
1494
|
+
const worktree = generation.worktree;
|
|
1495
|
+
const tip = generation.correction
|
|
1496
|
+
? await this.callProductive(handle, scope, async (context) => {
|
|
1497
|
+
await this.integrationGit.inspectCorrection(root, worktree, generation.expectedMain, receipts, generation.correction.from, generation.correction.to, context.signal);
|
|
1498
|
+
return generation.correction.to;
|
|
1499
|
+
})
|
|
1500
|
+
: await this.callProductive(handle, scope, (context) => this.integrationGit.inspectCombined(root, worktree, generation.expectedMain, receipts, context.signal));
|
|
1501
|
+
if (!sameIdentity(tip, generation.combinedTip) || !isCleanCommitted(tip))
|
|
1502
|
+
throw new Error("Combined tip changed or became dirty.");
|
|
1503
|
+
for (const stage of generation.stages) {
|
|
1504
|
+
const task = changesetTaskState(handle.state, stage.taskId);
|
|
1505
|
+
const worker = task.attempts[stage.attempt - 1]?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
|
|
1506
|
+
if (!worker)
|
|
1507
|
+
throw new Error("Selected worker has no owned worktree.");
|
|
1508
|
+
await this.callProductive(handle, scope, (context) => this.integrationGit.inspectWorker(root, worker, stage.source, context.signal));
|
|
1509
|
+
}
|
|
1510
|
+
const main = await this.callProductive(handle, scope, (context) => this.gitRuntime.inspectMain({ root }, context));
|
|
1511
|
+
if (!sameIdentity(main, generation.expectedMain))
|
|
1512
|
+
throw new Error("Main changed or became dirty before promotion.");
|
|
1513
|
+
}
|
|
1514
|
+
async abort(id, root, outerSignal) {
|
|
1515
|
+
root = realpathSync.native(root);
|
|
1516
|
+
return await this.store.withLock(root, async (lifecycle) => {
|
|
1517
|
+
const handle = await this.store.load(root, id);
|
|
1518
|
+
const state = handle.state;
|
|
1519
|
+
if (terminal(state))
|
|
1520
|
+
return this.response(state);
|
|
1521
|
+
if (state.integration.candidates.some((candidate) => candidate.worker !== "released")
|
|
1522
|
+
|| state.integration.generations.some((generation) => generation.worktree
|
|
1523
|
+
&& !generation.cleanup?.every((step) => step.status === "completed"))) {
|
|
1524
|
+
throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort.");
|
|
1525
|
+
}
|
|
1526
|
+
const activeControls = this.activeControls(root, id);
|
|
1527
|
+
for (const { control } of activeControls)
|
|
1528
|
+
control.invalidate();
|
|
1529
|
+
if (!activeControls.length
|
|
1530
|
+
&& !lifecycle.productiveRunLeaseActive
|
|
1531
|
+
&& this.recoverInterrupted(state)) {
|
|
1532
|
+
await handle.save();
|
|
1533
|
+
}
|
|
1534
|
+
const safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS;
|
|
1535
|
+
for (const task of state.tasks) {
|
|
1536
|
+
if (task.kind !== "changeset")
|
|
1537
|
+
continue;
|
|
1538
|
+
for (const attempt of task.attempts) {
|
|
1539
|
+
if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
|
|
1540
|
+
continue;
|
|
1541
|
+
await this.terminateWithSafety(handle, task, attempt, this.terminationCandidate(attempt), outerSignal, safetyDeadline, true);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
state.status = "aborted";
|
|
1545
|
+
delete state.recovery;
|
|
1546
|
+
state.accepted = false;
|
|
1547
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
1548
|
+
await handle.save();
|
|
1549
|
+
return this.response(state);
|
|
1550
|
+
}, { purpose: "abort" });
|
|
1551
|
+
}
|
|
1552
|
+
async status(id, root, outerSignal) {
|
|
1553
|
+
root = realpathSync.native(root);
|
|
1554
|
+
return await this.store.withLock(root, async () => {
|
|
1555
|
+
const handle = await this.store.load(root, id);
|
|
1556
|
+
const deadline = this.coordinatorRuntime.now() + STATUS_INSPECTION_BUDGET_MS;
|
|
1557
|
+
const scope = new DeadlineScope(deadline, () => this.coordinatorRuntime.now(), outerSignal);
|
|
1558
|
+
try {
|
|
1559
|
+
let main;
|
|
1560
|
+
try {
|
|
1561
|
+
const actual = await scope.call(async (context) => await this.gitRuntime.inspectMain({ root: handle.state.root }, context));
|
|
1562
|
+
main = {
|
|
1563
|
+
status: sameIdentity(actual, handle.state.main) ? "current" : "drifted",
|
|
1564
|
+
expected: handle.state.main,
|
|
1565
|
+
actual,
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
main = {
|
|
1570
|
+
status: "unavailable",
|
|
1571
|
+
expected: handle.state.main,
|
|
1572
|
+
failure: `Read-only Main inspection failed: ${errorText(error)}`,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
return this.response(handle.state, main);
|
|
1576
|
+
}
|
|
1577
|
+
finally {
|
|
1578
|
+
scope.close();
|
|
1579
|
+
}
|
|
1580
|
+
}, { purpose: "status" });
|
|
1581
|
+
}
|
|
1582
|
+
async run(handle, scope, forceTextTaskId, stagedSnapshot) {
|
|
1583
|
+
const state = handle.state;
|
|
1584
|
+
let saveOnExit = true;
|
|
1585
|
+
state.status = "running";
|
|
1586
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
1587
|
+
try {
|
|
1588
|
+
while (state.tasks.some((task) => task.status !== "completed")) {
|
|
1589
|
+
const attention = forceTextTaskId ? undefined : state.tasks.find((task) => task.status === "needs_attention");
|
|
1590
|
+
if (attention) {
|
|
1591
|
+
state.status = "needs_attention";
|
|
1592
|
+
return this.response(state);
|
|
1593
|
+
}
|
|
1594
|
+
const ready = readyPendingTasks(state).filter((task) => (!forceTextTaskId || task.taskId === forceTextTaskId)
|
|
1595
|
+
&& (!stagedSnapshot || (hasChangesetDependency(state, task.taskId)
|
|
1596
|
+
&& stagedDependencySnapshot(state, task.taskId, stagedSnapshot))));
|
|
1597
|
+
if (!ready.length) {
|
|
1598
|
+
if (!stagedSnapshot && !state.integration.candidates.length)
|
|
1599
|
+
throw new Error("No dependency wave is ready.");
|
|
1600
|
+
state.status = "needs_attention";
|
|
1601
|
+
return this.response(state);
|
|
1602
|
+
}
|
|
1603
|
+
let actualMain;
|
|
1604
|
+
try {
|
|
1605
|
+
actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
1606
|
+
}
|
|
1607
|
+
catch (error) {
|
|
1608
|
+
this.rethrowStopped(error);
|
|
1609
|
+
const failure = isDeadline(error, scope)
|
|
1610
|
+
? "The operation safety deadline expired before dependency-wave dispatch."
|
|
1611
|
+
: `Main inspection failed before dependency-wave dispatch: ${errorText(error)}`;
|
|
1612
|
+
for (const task of ready)
|
|
1613
|
+
this.attention(task, failure);
|
|
1614
|
+
state.status = "needs_attention";
|
|
1615
|
+
return this.response(state);
|
|
1616
|
+
}
|
|
1617
|
+
if (!sameIdentity(actualMain, state.main)) {
|
|
1618
|
+
for (const task of ready)
|
|
1619
|
+
this.attention(task, "Main drifted before dependency-wave dispatch.");
|
|
1620
|
+
state.status = "needs_attention";
|
|
1621
|
+
return this.response(state);
|
|
1622
|
+
}
|
|
1623
|
+
if (stagedSnapshot) {
|
|
1624
|
+
const generation = state.integration.generations.at(-1);
|
|
1625
|
+
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope);
|
|
1626
|
+
}
|
|
1627
|
+
const wave = {
|
|
1628
|
+
number: state.waves.length + 1,
|
|
1629
|
+
base: stagedSnapshot ?? state.main,
|
|
1630
|
+
taskIds: ready.map((task) => task.taskId),
|
|
1631
|
+
status: "dispatching",
|
|
1632
|
+
};
|
|
1633
|
+
state.waves.push(wave);
|
|
1634
|
+
for (const task of ready) {
|
|
1635
|
+
if (task.kind === "text") {
|
|
1636
|
+
task.status = "running";
|
|
1637
|
+
task.attempts.push({ number: task.attempts.length + 1, status: "running" });
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
task.status = "allocating";
|
|
1641
|
+
task.attempts.push({
|
|
1642
|
+
number: task.attempts.length + 1,
|
|
1643
|
+
waveNumber: wave.number,
|
|
1644
|
+
waveBase: wave.base,
|
|
1645
|
+
correlationToken: runtimeToken(this.coordinatorRuntime.randomToken()),
|
|
1646
|
+
allocationGeneration: 1,
|
|
1647
|
+
allocations: [],
|
|
1648
|
+
prompts: [],
|
|
1649
|
+
cleanup: CLEANUP_KINDS.map((kind) => ({ kind, status: "pending" })),
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
await this.saveProductive(handle);
|
|
1653
|
+
const dispatched = await Promise.allSettled(ready.map(async (task) => {
|
|
1654
|
+
try {
|
|
1655
|
+
await this.dispatchTask(handle, task, scope);
|
|
1656
|
+
}
|
|
1657
|
+
catch (error) {
|
|
1658
|
+
this.rethrowStopped(error);
|
|
1659
|
+
const failure = isDeadline(error, scope)
|
|
1660
|
+
? "The operation safety deadline expired during task dispatch."
|
|
1661
|
+
: `Task dispatch was interrupted: ${errorText(error)}`;
|
|
1662
|
+
this.attention(task, bounded(failure));
|
|
1663
|
+
}
|
|
1664
|
+
}));
|
|
1665
|
+
const stopped = dispatched.find((result) => result.status === "rejected" && result.reason instanceof DurableRunStopped);
|
|
1666
|
+
if (stopped)
|
|
1667
|
+
throw stopped.reason;
|
|
1668
|
+
const rejected = dispatched.find((result) => result.status === "rejected");
|
|
1669
|
+
if (rejected)
|
|
1670
|
+
throw rejected.reason;
|
|
1671
|
+
if (ready.some((task) => task.kind === "text"
|
|
1672
|
+
? task.status !== "completed"
|
|
1673
|
+
: task.status !== "ready_to_integrate")) {
|
|
1674
|
+
wave.status = "needs_attention";
|
|
1675
|
+
state.status = "needs_attention";
|
|
1676
|
+
return this.response(state);
|
|
1677
|
+
}
|
|
1678
|
+
if (ready.every((task) => task.kind === "text")) {
|
|
1679
|
+
wave.status = "completed";
|
|
1680
|
+
await this.saveProductive(handle);
|
|
1681
|
+
forceTextTaskId = undefined;
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
for (const task of ready)
|
|
1685
|
+
if (task.kind === "changeset")
|
|
1686
|
+
this.retainCandidate(state, task);
|
|
1687
|
+
wave.status = "needs_attention";
|
|
1688
|
+
state.status = "needs_attention";
|
|
1689
|
+
await this.saveProductive(handle);
|
|
1690
|
+
return this.response(state);
|
|
1691
|
+
}
|
|
1692
|
+
return await this.runFinal(handle, scope);
|
|
1693
|
+
}
|
|
1694
|
+
catch (error) {
|
|
1695
|
+
if (error instanceof DurableRunStopped) {
|
|
1696
|
+
saveOnExit = false;
|
|
1697
|
+
return this.response(handle.state);
|
|
1698
|
+
}
|
|
1699
|
+
const activeTasks = state.tasks.filter((task) => !["pending", "completed", "needs_attention"].includes(task.status));
|
|
1700
|
+
if (activeTasks.length) {
|
|
1701
|
+
const failure = isDeadline(error, scope) ? "The operation safety deadline expired." : `Execution was interrupted: ${errorText(error)}`;
|
|
1702
|
+
for (const active of activeTasks)
|
|
1703
|
+
this.attention(active, failure);
|
|
1704
|
+
}
|
|
1705
|
+
else if (state.final.status === "running") {
|
|
1706
|
+
state.final.status = "interrupted";
|
|
1707
|
+
state.final.failure = bounded(isDeadline(error, scope) ? "The operation safety deadline expired." : `Final gate was interrupted: ${errorText(error)}`);
|
|
1708
|
+
}
|
|
1709
|
+
state.status = "needs_attention";
|
|
1710
|
+
state.accepted = false;
|
|
1711
|
+
return this.response(state);
|
|
1712
|
+
}
|
|
1713
|
+
finally {
|
|
1714
|
+
this.closeRequestControls(state.root, state.request.id);
|
|
1715
|
+
if (saveOnExit) {
|
|
1716
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
1717
|
+
await this.saveProductive(handle);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
async dispatchTask(handle, task, scope) {
|
|
1722
|
+
if (task.kind === "text")
|
|
1723
|
+
return await this.dispatchTextTask(handle, task, scope);
|
|
1724
|
+
const state = handle.state;
|
|
1725
|
+
const request = changesetTaskRequest(state, task.taskId);
|
|
1726
|
+
const attempt = latestAttempt(task);
|
|
1727
|
+
try {
|
|
1728
|
+
for (const kind of ALLOCATION_KINDS) {
|
|
1729
|
+
if (allocationByKind(attempt, kind))
|
|
1730
|
+
continue;
|
|
1731
|
+
let intent;
|
|
1732
|
+
if (kind === "worktree") {
|
|
1733
|
+
intent = {
|
|
1734
|
+
kind,
|
|
1735
|
+
generation: attempt.allocationGeneration,
|
|
1736
|
+
token: attempt.correlationToken,
|
|
1737
|
+
status: "allocating",
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
const plan = runtimeHostAllocationPlan(await this.callProductive(handle, scope, async (context) => await this.hostRuntime.planHostAllocation({
|
|
1742
|
+
requestId: state.request.id,
|
|
1743
|
+
goal: state.request.goal,
|
|
1744
|
+
kind,
|
|
1745
|
+
task: request,
|
|
1746
|
+
attempt,
|
|
1747
|
+
}, context)), kind);
|
|
1748
|
+
intent = {
|
|
1749
|
+
...plan,
|
|
1750
|
+
generation: attempt.allocationGeneration,
|
|
1751
|
+
token: attempt.correlationToken,
|
|
1752
|
+
status: "allocating",
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
attempt.allocations.push(intent);
|
|
1756
|
+
await this.saveProductive(handle);
|
|
1757
|
+
let result;
|
|
1758
|
+
try {
|
|
1759
|
+
if (intent.kind === "worktree") {
|
|
1760
|
+
result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.allocateWorktree({
|
|
1761
|
+
root: state.root,
|
|
1762
|
+
...(attempt.waveBase.head !== state.main.head ? { baseRoot: state.integration.generations.at(-1)?.worktree?.path } : {}),
|
|
1763
|
+
intent,
|
|
1764
|
+
task: request,
|
|
1765
|
+
attempt,
|
|
1766
|
+
onPrepared: async (worktree) => {
|
|
1767
|
+
const prepared = worktree;
|
|
1768
|
+
for (const field of ["path", "cwd", "branch", "repoRoot"]) {
|
|
1769
|
+
requireExactAllocationText(prepared[field], `Prepared worktree ${field}`);
|
|
1770
|
+
}
|
|
1771
|
+
if (typeof prepared.baseCommit !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(prepared.baseCommit)
|
|
1772
|
+
|| prepared.baseCommit !== attempt.waveBase.head || prepared.path !== prepared.cwd) {
|
|
1773
|
+
throw new Error("Prepared worktree does not match the exact recorded wave plan.");
|
|
1774
|
+
}
|
|
1775
|
+
const exact = prepared;
|
|
1776
|
+
intent.worktree = {
|
|
1777
|
+
path: exact.path,
|
|
1778
|
+
cwd: exact.cwd,
|
|
1779
|
+
branch: exact.branch,
|
|
1780
|
+
repoRoot: exact.repoRoot,
|
|
1781
|
+
baseCommit: exact.baseCommit,
|
|
1782
|
+
};
|
|
1783
|
+
await this.saveProductive(handle);
|
|
1784
|
+
},
|
|
1785
|
+
}, context));
|
|
1786
|
+
}
|
|
1787
|
+
else {
|
|
1788
|
+
const allocate = async (context) => await this.hostRuntime.allocateHost({
|
|
1789
|
+
requestId: state.request.id,
|
|
1790
|
+
intent,
|
|
1791
|
+
task: request,
|
|
1792
|
+
attempt,
|
|
1793
|
+
...(intent.kind === "agent" ? { acquireLaunch: async () => {
|
|
1794
|
+
buildChangesetTaskPrompt({
|
|
1795
|
+
goal: state.request.goal,
|
|
1796
|
+
contexts: resolveTextTaskContexts(state, request),
|
|
1797
|
+
task: request,
|
|
1798
|
+
kind: "initial",
|
|
1799
|
+
worktreeCwd: intent.worktreeCwd,
|
|
1800
|
+
});
|
|
1801
|
+
const launch = await this.coordinatorRuntime.acquireLaunch(request.role, request.modelClass, context);
|
|
1802
|
+
if (launch.launch.role !== request.role || launch.launch.modelClass !== request.modelClass) {
|
|
1803
|
+
await withTransientLaunch(launch, async () => {
|
|
1804
|
+
throw new Error("Task launch acquisition returned the wrong Role or model class.");
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
return launch;
|
|
1808
|
+
} } : {}),
|
|
1809
|
+
}, context);
|
|
1810
|
+
result = intent.kind === "agent"
|
|
1811
|
+
? await this.withLifecycleLock(handle, async () => {
|
|
1812
|
+
const allocated = await scope.call(allocate);
|
|
1813
|
+
if (allocated.kind !== intent.kind)
|
|
1814
|
+
throw new Error("Agent allocation returned the wrong result kind.");
|
|
1815
|
+
if (allocated.outcome === "owned") {
|
|
1816
|
+
applyOwnedAllocationResult(intent, allocated);
|
|
1817
|
+
await handle.save();
|
|
1818
|
+
}
|
|
1819
|
+
return allocated;
|
|
1820
|
+
})
|
|
1821
|
+
: await this.callProductive(handle, scope, allocate);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
catch (error) {
|
|
1825
|
+
this.rethrowStopped(error);
|
|
1826
|
+
intent.status = "unknown";
|
|
1827
|
+
intent.failure = bounded(`Allocation result is unknown: ${errorText(error)}`);
|
|
1828
|
+
this.attention(task, intent.failure);
|
|
1829
|
+
await this.saveProductive(handle);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (result.kind !== intent.kind) {
|
|
1833
|
+
throw new Error(`${intent.kind} allocation returned the wrong result kind.`);
|
|
1834
|
+
}
|
|
1835
|
+
if (result.outcome !== "owned") {
|
|
1836
|
+
const failure = boundedRuntimeText(result.failure, `${intent.kind} allocation failure`);
|
|
1837
|
+
const possibleResources = result.outcome === "unknown" ? boundedPossibleResources(result.possibleResources) : undefined;
|
|
1838
|
+
intent.status = result.outcome;
|
|
1839
|
+
intent.failure = failure;
|
|
1840
|
+
if (result.outcome === "unknown")
|
|
1841
|
+
intent.possibleResources = possibleResources;
|
|
1842
|
+
this.attention(task, intent.failure);
|
|
1843
|
+
await this.saveProductive(handle);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
if (intent.kind !== "agent") {
|
|
1847
|
+
applyOwnedAllocationResult(intent, result);
|
|
1848
|
+
await this.saveProductive(handle);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
task.status = "working";
|
|
1852
|
+
await this.saveProductive(handle);
|
|
1853
|
+
await this.driveWorkerSafely(handle, task, scope, "initial");
|
|
1854
|
+
}
|
|
1855
|
+
catch (error) {
|
|
1856
|
+
this.rethrowStopped(error);
|
|
1857
|
+
this.attention(task, isDeadline(error, scope) ? "The operation safety deadline expired during allocation." : `Task allocation was interrupted: ${errorText(error)}`);
|
|
1858
|
+
await this.saveProductive(handle);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
async dispatchTextTask(handle, task, scope) {
|
|
1862
|
+
const state = handle.state;
|
|
1863
|
+
const request = taskRequest(state, task.taskId);
|
|
1864
|
+
if (request.kind !== "text")
|
|
1865
|
+
throw new Error(`Task ${task.taskId} is not a text task.`);
|
|
1866
|
+
const attempt = task.attempts.at(-1);
|
|
1867
|
+
if (!attempt || attempt.status !== "running")
|
|
1868
|
+
throw new Error(`Text task ${task.taskId} has no running attempt.`);
|
|
1869
|
+
const prompt = buildTextTaskPrompt(state.request.goal, request, resolveTextTaskContexts(state, request));
|
|
1870
|
+
const result = await this.callProductive(handle, scope, async (context) => {
|
|
1871
|
+
const wave = state.waves.at(-1);
|
|
1872
|
+
if (!wave?.taskIds.includes(task.taskId))
|
|
1873
|
+
throw new Error("Text task lacks a recorded launch wave.");
|
|
1874
|
+
const baseRoot = wave.base.head === state.main.head ? state.root : state.integration.generations.at(-1)?.worktree?.path;
|
|
1875
|
+
if (!baseRoot)
|
|
1876
|
+
throw new Error("Staged dependency checkout is unavailable.");
|
|
1877
|
+
if (!sameIdentity(await this.gitRuntime.inspectMain({ root: baseRoot }, context), wave.base)) {
|
|
1878
|
+
throw new Error("Staged dependency snapshot drifted before text launch.");
|
|
1879
|
+
}
|
|
1880
|
+
const isolated = await createChildWorktree(baseRoot, `${state.request.id}-${task.taskId}-text-${attempt.number}`, undefined, context.signal);
|
|
1881
|
+
if (!isolated)
|
|
1882
|
+
throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
|
|
1883
|
+
let result;
|
|
1884
|
+
try {
|
|
1885
|
+
const launchHandle = await this.coordinatorRuntime.acquireLaunch(request.role, request.modelClass, context);
|
|
1886
|
+
if (launchHandle.launch.role !== request.role || launchHandle.launch.modelClass !== request.modelClass) {
|
|
1887
|
+
await withTransientLaunch(launchHandle, async () => {
|
|
1888
|
+
throw new Error("Text task launch acquisition returned the wrong Role or model class.");
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
result = await withTransientLaunch(launchHandle, async (verifiedLaunch) => {
|
|
1892
|
+
const launch = { args: [...verifiedLaunch.args], env: { ...verifiedLaunch.env } };
|
|
1893
|
+
return await this.textExecutor.run({
|
|
1894
|
+
signal: context.signal,
|
|
1895
|
+
prepare: async () => ({ launch, task: prompt, cwd: isolated.cwd }),
|
|
1896
|
+
});
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
finally {
|
|
1900
|
+
const cleanup = await finalizeChildWorktree(isolated);
|
|
1901
|
+
if (cleanup.outcome !== "pruned") {
|
|
1902
|
+
throw new Error(`Isolated text task changed its checkout; work was retained at ${cleanup.path} on ${cleanup.branch}.`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return result;
|
|
1906
|
+
});
|
|
1907
|
+
if (result.outcome !== "success" || result.exitCode !== 0) {
|
|
1908
|
+
throw new Error("Text task executor did not complete successfully.");
|
|
1909
|
+
}
|
|
1910
|
+
if (result.outputTruncated !== false)
|
|
1911
|
+
throw new Error("Text task executor output was truncated.");
|
|
1912
|
+
const output = result.output.trim();
|
|
1913
|
+
if (!output)
|
|
1914
|
+
throw new Error("Text task executor returned empty output.");
|
|
1915
|
+
if (Buffer.byteLength(output, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
|
|
1916
|
+
throw new Error(`Text task executor output exceeds ${MAX_PERSISTED_RUNTIME_TEXT_BYTES} UTF-8 bytes.`);
|
|
1917
|
+
}
|
|
1918
|
+
const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
1919
|
+
if (!sameIdentity(actualMain, state.main))
|
|
1920
|
+
throw new Error("Main drifted during text task execution.");
|
|
1921
|
+
const wave = state.waves.at(-1);
|
|
1922
|
+
if (wave.base.head !== state.main.head) {
|
|
1923
|
+
const generation = state.integration.generations.at(-1);
|
|
1924
|
+
if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
|
|
1925
|
+
throw new Error("Staged dependency snapshot was superseded during text task execution.");
|
|
1926
|
+
}
|
|
1927
|
+
await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope);
|
|
1928
|
+
}
|
|
1929
|
+
attempt.status = "completed";
|
|
1930
|
+
attempt.failure = undefined;
|
|
1931
|
+
attempt.output = { text: output };
|
|
1932
|
+
task.status = "completed";
|
|
1933
|
+
task.failure = undefined;
|
|
1934
|
+
await this.saveProductive(handle);
|
|
1935
|
+
}
|
|
1936
|
+
correctionAllowed(state, request, attempt, postSeal = false) {
|
|
1937
|
+
const effectiveMaximum = Math.min(state.policy.maxCorrections, this.currentPolicy().maxCorrections);
|
|
1938
|
+
return state.correctionCount < effectiveMaximum && (correctionEligible(request, attempt) || Boolean(postSeal
|
|
1939
|
+
&& attempt.readiness && attempt.prompts.at(-1)?.status === "settled" && !attempt.termination
|
|
1940
|
+
&& attempt.prompts.length < MAX_WORKER_PROMPTS
|
|
1941
|
+
&& !attempt.prompts.some((prompt) => prompt.kind === "correction" || prompt.status === "ambiguous")
|
|
1942
|
+
&& attempt.candidate && sameIdentity(attempt.readiness.candidate, attempt.candidate)
|
|
1943
|
+
&& checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)));
|
|
1944
|
+
}
|
|
1945
|
+
async driveWorkerSafely(handle, task, scope, initialKind, initialInstruction, postSeal = false) {
|
|
1946
|
+
const control = this.ensureFollowupControl(handle, task);
|
|
1947
|
+
try {
|
|
1948
|
+
await this.driveWorker(handle, task, scope, initialKind, control, initialInstruction, postSeal);
|
|
1949
|
+
}
|
|
1950
|
+
catch (error) {
|
|
1951
|
+
this.rethrowStopped(error);
|
|
1952
|
+
this.attention(task, isDeadline(error, scope)
|
|
1953
|
+
? "The child operation safety deadline expired during worker execution."
|
|
1954
|
+
: `Worker execution was interrupted: ${errorText(error)}`);
|
|
1955
|
+
await this.saveProductive(handle);
|
|
1956
|
+
}
|
|
1957
|
+
finally {
|
|
1958
|
+
this.closeFollowupControl(handle.state.root, handle.state.request.id, task.taskId, control);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
async driveWorker(handle, task, scope, initialKind, control, initialInstruction, postSeal = false) {
|
|
1962
|
+
const state = handle.state;
|
|
1963
|
+
const request = changesetTaskRequest(state, task.taskId);
|
|
1964
|
+
const attempt = latestAttempt(task);
|
|
1965
|
+
const workerId = allocationByKind(attempt, "agent")?.agentName;
|
|
1966
|
+
if (!workerId)
|
|
1967
|
+
throw new Error("Worker launch has no durably recorded agent ID.");
|
|
1968
|
+
let kind = initialKind;
|
|
1969
|
+
let failure = task.failure;
|
|
1970
|
+
let instruction = initialInstruction;
|
|
1971
|
+
for (;;) {
|
|
1972
|
+
if (kind === "followup" && instruction === undefined) {
|
|
1973
|
+
throw new Error("Follow-up worker execution requires an exact queued instruction.");
|
|
1974
|
+
}
|
|
1975
|
+
if (kind === "correction" && !this.correctionAllowed(state, request, attempt, postSeal)) {
|
|
1976
|
+
this.attention(task, "The same-agent correction is unavailable or already used.");
|
|
1977
|
+
await this.saveProductive(handle);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (kind !== "initial") {
|
|
1981
|
+
attempt.readiness = undefined;
|
|
1982
|
+
attempt.preliminaryChecks = undefined;
|
|
1983
|
+
attempt.preliminaryReview = undefined;
|
|
1984
|
+
}
|
|
1985
|
+
let preCandidate;
|
|
1986
|
+
try {
|
|
1987
|
+
preCandidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectTaskCandidate({
|
|
1988
|
+
root: state.root,
|
|
1989
|
+
task: request,
|
|
1990
|
+
attempt,
|
|
1991
|
+
}, context)), "Pre-prompt candidate identity");
|
|
1992
|
+
}
|
|
1993
|
+
catch (error) {
|
|
1994
|
+
this.rethrowStopped(error);
|
|
1995
|
+
this.attention(task, `Pre-prompt candidate identity is unavailable: ${errorText(error)}`);
|
|
1996
|
+
await this.saveProductive(handle);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const worktree = allocationByKind(attempt, "worktree");
|
|
2000
|
+
if (!worktree?.worktree
|
|
2001
|
+
|| preCandidate.branch !== `refs/heads/${worktree.worktree.branch}`
|
|
2002
|
+
|| !isCleanCommitted(preCandidate)
|
|
2003
|
+
|| (kind === "initial" && preCandidate.head !== attempt.waveBase.head)) {
|
|
2004
|
+
this.attention(task, "Pre-prompt task candidate inspection returned an invalid owned worktree identity.");
|
|
2005
|
+
await this.saveProductive(handle);
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const retainedCandidate = kind === "initial"
|
|
2009
|
+
? undefined
|
|
2010
|
+
: attempt.candidate ?? attempt.prompts.at(-1)?.candidate ?? attempt.prompts.at(-1)?.preCandidate;
|
|
2011
|
+
if (retainedCandidate && !sameIdentity(preCandidate, retainedCandidate)) {
|
|
2012
|
+
this.attention(task, "The same-agent correction worktree identity drifted before prompting.");
|
|
2013
|
+
await this.saveProductive(handle);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (attempt.prompts.length >= MAX_WORKER_PROMPTS) {
|
|
2017
|
+
this.attention(task, `A task may submit at most ${MAX_WORKER_PROMPTS} worker prompts.`);
|
|
2018
|
+
await this.saveProductive(handle);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
const prompt = {
|
|
2022
|
+
kind,
|
|
2023
|
+
status: "submitting",
|
|
2024
|
+
preCandidate,
|
|
2025
|
+
...(kind === "followup" ? { instruction } : {}),
|
|
2026
|
+
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2027
|
+
};
|
|
2028
|
+
attempt.prompts.push(prompt);
|
|
2029
|
+
if (kind === "correction")
|
|
2030
|
+
state.correctionCount += 1;
|
|
2031
|
+
task.failure = undefined;
|
|
2032
|
+
await this.saveProductive(handle);
|
|
2033
|
+
let worker;
|
|
2034
|
+
try {
|
|
2035
|
+
worker = await this.callProductive(handle, scope, async (context) => await this.hostRuntime.runWorker({
|
|
2036
|
+
goal: state.request.goal,
|
|
2037
|
+
contexts: resolveTextTaskContexts(state, request),
|
|
2038
|
+
task: request,
|
|
2039
|
+
attempt,
|
|
2040
|
+
workerId,
|
|
2041
|
+
kind,
|
|
2042
|
+
preCandidate,
|
|
2043
|
+
...(failure ? { failure } : {}),
|
|
2044
|
+
...(instruction ? { instruction } : {}),
|
|
2045
|
+
}, context));
|
|
2046
|
+
}
|
|
2047
|
+
catch (error) {
|
|
2048
|
+
this.rethrowStopped(error);
|
|
2049
|
+
prompt.status = "ambiguous";
|
|
2050
|
+
prompt.failure = bounded(`Prompt result is ambiguous and will not be replayed: ${errorText(error)}`);
|
|
2051
|
+
this.attention(task, prompt.failure);
|
|
2052
|
+
await this.saveProductive(handle);
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
if (worker.outcome === "unknown" || worker.outcome === "interrupted") {
|
|
2056
|
+
prompt.status = "ambiguous";
|
|
2057
|
+
prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, `${worker.outcome} worker diagnostic`);
|
|
2058
|
+
this.attention(task, `${worker.outcome} worker result will not be replayed: ${prompt.failure}`);
|
|
2059
|
+
await this.saveProductive(handle);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (worker.outcome === "not_prompted") {
|
|
2063
|
+
prompt.status = "not_sent";
|
|
2064
|
+
prompt.failure = boundedRuntimeDiagnostic(worker.diagnostic, "not-prompted worker diagnostic");
|
|
2065
|
+
this.attention(task, `Worker prompt was not submitted: ${prompt.failure}`);
|
|
2066
|
+
await this.saveProductive(handle);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
prompt.status = "settled";
|
|
2070
|
+
if (worker.outcome === "blocked") {
|
|
2071
|
+
failure = boundedRuntimeDiagnostic(worker.diagnostic, "blocked worker diagnostic");
|
|
2072
|
+
prompt.failure = failure;
|
|
2073
|
+
await this.saveProductive(handle);
|
|
2074
|
+
}
|
|
2075
|
+
else {
|
|
2076
|
+
if (worker.outcome !== "candidate")
|
|
2077
|
+
throw new Error("Unexpected worker result.");
|
|
2078
|
+
worker.candidate = runtimeIdentity(worker.candidate, "Settled worker candidate identity");
|
|
2079
|
+
if (!isCleanCommitted(worker.candidate) || worker.candidate.head === preCandidate.head) {
|
|
2080
|
+
this.attention(task, "Settled worker did not produce a new clean committed candidate.");
|
|
2081
|
+
await this.saveProductive(handle);
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
prompt.candidate = worker.candidate;
|
|
2085
|
+
attempt.candidate = worker.candidate;
|
|
2086
|
+
attempt.candidateBase ??= attempt.waveBase;
|
|
2087
|
+
const checks = await this.runCheckBatch(handle, request.checks, worker.candidate, "preliminary", scope, task.taskId);
|
|
2088
|
+
attempt.preliminaryChecks = checks;
|
|
2089
|
+
await this.saveProductive(handle);
|
|
2090
|
+
if (!checks.passed) {
|
|
2091
|
+
failure = sameIdentity(checks.identityAfter, worker.candidate)
|
|
2092
|
+
? "A declared task check failed on the recorded candidate."
|
|
2093
|
+
: "A declared task check changed the recorded candidate.";
|
|
2094
|
+
}
|
|
2095
|
+
else {
|
|
2096
|
+
failure = undefined;
|
|
2097
|
+
}
|
|
2098
|
+
if (!failure) {
|
|
2099
|
+
const queuedFollowup = control.takeQueuedFollowupOrSeal();
|
|
2100
|
+
if (queuedFollowup) {
|
|
2101
|
+
kind = "followup";
|
|
2102
|
+
instruction = queuedFollowup.instruction;
|
|
2103
|
+
task.status = "working";
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
const workerId = allocationByKind(attempt, "agent")?.agentName;
|
|
2107
|
+
if (!workerId || attempt.termination || !attempt.candidate || !attempt.candidateBase
|
|
2108
|
+
|| !checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)) {
|
|
2109
|
+
throw new Error("Readiness requires an exact live owned worker and passing candidate lineage.");
|
|
2110
|
+
}
|
|
2111
|
+
if (request.judgment) {
|
|
2112
|
+
attempt.preliminaryReview = await this.runReview(handle, request.judgment.criterion, attempt.candidateBase, attempt.candidate, request.judgment.role, request.judgment.modelClass, "preliminary", scope, task.taskId);
|
|
2113
|
+
await this.saveProductive(handle);
|
|
2114
|
+
if (!attempt.preliminaryReview.passed) {
|
|
2115
|
+
this.attention(task, "Preliminary Judgment did not return exact PASS on the worker candidate.");
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
attempt.readiness = {
|
|
2120
|
+
candidate: attempt.candidate,
|
|
2121
|
+
base: attempt.candidateBase,
|
|
2122
|
+
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2123
|
+
};
|
|
2124
|
+
task.status = "ready_to_integrate";
|
|
2125
|
+
task.failure = undefined;
|
|
2126
|
+
await this.saveProductive(handle);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
if (this.correctionAllowed(state, request, attempt)) {
|
|
2131
|
+
kind = "correction";
|
|
2132
|
+
instruction = undefined;
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
const queuedFollowup = control.takeQueuedFollowup();
|
|
2136
|
+
if (queuedFollowup) {
|
|
2137
|
+
kind = "followup";
|
|
2138
|
+
instruction = queuedFollowup.instruction;
|
|
2139
|
+
task.status = "working";
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
this.attention(task, failure ?? "Task candidate needs attention after its correction window closed.");
|
|
2143
|
+
await this.saveProductive(handle);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
terminationCandidate(attempt) {
|
|
2148
|
+
return attempt.candidate
|
|
2149
|
+
?? attempt.prompts.at(-1)?.candidate
|
|
2150
|
+
?? attempt.prompts.at(-1)?.preCandidate
|
|
2151
|
+
?? attempt.waveBase;
|
|
2152
|
+
}
|
|
2153
|
+
async terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS, lifecycleLocked = false) {
|
|
2154
|
+
if (!lifecycleLocked && this.productiveLifecycles.has(handle.state.root)) {
|
|
2155
|
+
return await this.withLifecycleLock(handle, async () => await this.terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline, true));
|
|
2156
|
+
}
|
|
2157
|
+
const save = async () => lifecycleLocked ? await handle.save() : await this.saveProductive(handle);
|
|
2158
|
+
const workerId = allocationByKind(attempt, "agent")?.agentName;
|
|
2159
|
+
if (!workerId)
|
|
2160
|
+
throw new Error("Safety termination requires an exact durably owned agent ID.");
|
|
2161
|
+
attempt.termination = { status: "terminating", workerId, candidate };
|
|
2162
|
+
await save();
|
|
2163
|
+
const safety = new DeadlineScope(safetyDeadline, () => this.coordinatorRuntime.now(), outerSignal);
|
|
2164
|
+
try {
|
|
2165
|
+
const result = await safety.call(async (context) => await this.hostRuntime.terminateWorker({
|
|
2166
|
+
task: changesetTaskRequest(handle.state, task.taskId), attempt, workerId, candidate,
|
|
2167
|
+
}, context));
|
|
2168
|
+
if (result.outcome !== "terminated") {
|
|
2169
|
+
const failure = boundedRuntimeText(result.failure, "Worker termination failure");
|
|
2170
|
+
attempt.termination = { status: "unknown", workerId, candidate, failure };
|
|
2171
|
+
this.attention(task, `Worker termination is unproved: ${failure}`);
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
attempt.termination = { status: "terminated", workerId, candidate, at: this.coordinatorRuntime.now() };
|
|
2175
|
+
return true;
|
|
2176
|
+
}
|
|
2177
|
+
catch (error) {
|
|
2178
|
+
this.rethrowStopped(error);
|
|
2179
|
+
attempt.termination = { status: "unknown", workerId, candidate, failure: errorText(error) };
|
|
2180
|
+
this.attention(task, `Worker termination is unproved: ${errorText(error)}`);
|
|
2181
|
+
return false;
|
|
2182
|
+
}
|
|
2183
|
+
finally {
|
|
2184
|
+
safety.close();
|
|
2185
|
+
await save();
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
async runCheckBatch(handle, checks, candidate, phase, scope, taskId, root = handle.state.root) {
|
|
2189
|
+
const attempt = taskId ? latestAttempt(changesetTaskState(handle.state, taskId)) : undefined;
|
|
2190
|
+
const result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.runChecks({
|
|
2191
|
+
root,
|
|
2192
|
+
scope: phase === "final" ? "final" : "task",
|
|
2193
|
+
...(taskId ? { taskId, attempt } : {}),
|
|
2194
|
+
checks,
|
|
2195
|
+
candidate,
|
|
2196
|
+
}, context));
|
|
2197
|
+
const identityAfter = runtimeIdentity(result.identityAfter, "Post-check identity");
|
|
2198
|
+
if (!exactCommandResults(result.results, checks)) {
|
|
2199
|
+
throw new Error("Check runtime did not return one result with the exact command and argv for every declared check.");
|
|
2200
|
+
}
|
|
2201
|
+
if (result.results.some((item) => !Number.isSafeInteger(item.code)
|
|
2202
|
+
|| (item.killed !== undefined && typeof item.killed !== "boolean")
|
|
2203
|
+
|| typeof item.stdout !== "string" || typeof item.stderr !== "string")) {
|
|
2204
|
+
throw new Error("Check runtime returned malformed result evidence.");
|
|
2205
|
+
}
|
|
2206
|
+
const commandsPassed = result.results.every((item) => item.code === 0 && !item.killed);
|
|
2207
|
+
const identityMatches = sameIdentity(identityAfter, candidate);
|
|
2208
|
+
const passed = identityMatches && commandsPassed;
|
|
2209
|
+
let diagnosticIndex = passed ? -1 : result.results.findIndex((item) => item.code !== 0 || item.killed);
|
|
2210
|
+
if (diagnosticIndex < 0 && commandsPassed && !identityMatches)
|
|
2211
|
+
diagnosticIndex = result.results.length - 1;
|
|
2212
|
+
const evidence = {
|
|
2213
|
+
phase,
|
|
2214
|
+
candidate,
|
|
2215
|
+
identityAfter,
|
|
2216
|
+
results: result.results.map((item, index) => ({
|
|
2217
|
+
command: item.command,
|
|
2218
|
+
args: [...item.args],
|
|
2219
|
+
code: item.code,
|
|
2220
|
+
killed: item.killed ?? false,
|
|
2221
|
+
stdout: index === diagnosticIndex ? bounded(item.stdout) : "",
|
|
2222
|
+
stderr: index === diagnosticIndex ? bounded(item.stderr) : "",
|
|
2223
|
+
})),
|
|
2224
|
+
passed,
|
|
2225
|
+
at: this.coordinatorRuntime.now(),
|
|
2226
|
+
};
|
|
2227
|
+
return evidence;
|
|
2228
|
+
}
|
|
2229
|
+
async runReview(handle, criterion, base, tip, role, modelClass, phase, scope, taskId, root = handle.state.root) {
|
|
2230
|
+
const attempt = taskId ? latestAttempt(changesetTaskState(handle.state, taskId)) : undefined;
|
|
2231
|
+
const result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.review({
|
|
2232
|
+
root,
|
|
2233
|
+
scope: phase === "final" ? "final" : "task",
|
|
2234
|
+
phase,
|
|
2235
|
+
...(taskId ? { taskId, attempt } : {}),
|
|
2236
|
+
criterion,
|
|
2237
|
+
base,
|
|
2238
|
+
tip,
|
|
2239
|
+
acquireLaunch: async () => {
|
|
2240
|
+
const launch = await this.coordinatorRuntime.acquireLaunch(role, modelClass, context);
|
|
2241
|
+
if (launch.launch.role !== role || launch.launch.modelClass !== modelClass) {
|
|
2242
|
+
await withTransientLaunch(launch, async () => {
|
|
2243
|
+
throw new Error("Judgment launch acquisition returned the wrong Role or model class.");
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
return launch;
|
|
2247
|
+
},
|
|
2248
|
+
}, context));
|
|
2249
|
+
const identityAfter = runtimeIdentity(result.identityAfter, "Post-review identity");
|
|
2250
|
+
const verdict = boundedRuntimeText(result.verdict, "Judgment verdict");
|
|
2251
|
+
const evidence = {
|
|
2252
|
+
phase,
|
|
2253
|
+
criterion,
|
|
2254
|
+
base,
|
|
2255
|
+
tip,
|
|
2256
|
+
identityAfter,
|
|
2257
|
+
verdict,
|
|
2258
|
+
passed: verdict === "PASS" && sameIdentity(identityAfter, tip),
|
|
2259
|
+
at: this.coordinatorRuntime.now(),
|
|
2260
|
+
};
|
|
2261
|
+
return evidence;
|
|
2262
|
+
}
|
|
2263
|
+
retainCandidate(state, task) {
|
|
2264
|
+
const attempt = latestAttempt(task);
|
|
2265
|
+
const request = changesetTaskRequest(state, task.taskId);
|
|
2266
|
+
if (!attempt.readiness || !attempt.candidate || !attempt.candidateBase || !attempt.preliminaryChecks
|
|
2267
|
+
|| !sameIdentity(attempt.readiness.candidate, attempt.candidate)
|
|
2268
|
+
|| !sameIdentity(attempt.readiness.base, attempt.candidateBase)
|
|
2269
|
+
|| !checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)
|
|
2270
|
+
|| (request.judgment && !reviewEvidencePasses(attempt.preliminaryReview, "preliminary", request.judgment.criterion, attempt.candidateBase, attempt.candidate))
|
|
2271
|
+
|| !allocationByKind(attempt, "agent")?.agentName || attempt.termination) {
|
|
2272
|
+
throw new Error(`Task ${task.taskId} has no exact ready live candidate.`);
|
|
2273
|
+
}
|
|
2274
|
+
if (state.integration.candidates.some((candidate) => candidate.taskId === task.taskId && !candidate.decision)) {
|
|
2275
|
+
throw new Error(`Candidate ${task.taskId} is already retained; revision needs a new integration generation.`);
|
|
2276
|
+
}
|
|
2277
|
+
state.integration.candidates.push({
|
|
2278
|
+
taskId: task.taskId, attempt: attempt.number, base: attempt.candidateBase,
|
|
2279
|
+
tip: attempt.candidate, checks: attempt.preliminaryChecks,
|
|
2280
|
+
...(request.judgment ? { review: attempt.preliminaryReview } : {}), worker: "retained",
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
async verifyRetainedTask(handle, task, scope) {
|
|
2284
|
+
const attempt = latestAttempt(task);
|
|
2285
|
+
if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
|
|
2286
|
+
throw new Error("An ambiguous delivered prompt is retained for inspection or explicit abort and is never replayed.");
|
|
2287
|
+
}
|
|
2288
|
+
const candidate = runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectRetainedTask({
|
|
2289
|
+
root: handle.state.root, task: changesetTaskRequest(handle.state, task.taskId), attempt,
|
|
2290
|
+
}, context)), "Retained task candidate identity");
|
|
2291
|
+
if (!isCleanCommitted(candidate))
|
|
2292
|
+
throw new Error("Retained task candidate is not clean and committed.");
|
|
2293
|
+
if (attempt.readiness) {
|
|
2294
|
+
if (!sameIdentity(candidate, attempt.readiness.candidate))
|
|
2295
|
+
throw new Error("Ready retained task candidate drifted from its exact lineage.");
|
|
2296
|
+
task.status = "ready_to_integrate";
|
|
2297
|
+
task.failure = undefined;
|
|
2298
|
+
await this.saveProductive(handle);
|
|
2299
|
+
this.retainCandidate(handle.state, task);
|
|
2300
|
+
handle.state.status = "needs_attention";
|
|
2301
|
+
await this.saveProductive(handle);
|
|
2302
|
+
return this.response(handle.state);
|
|
2303
|
+
}
|
|
2304
|
+
const request = changesetTaskRequest(handle.state, task.taskId);
|
|
2305
|
+
if (!attempt.candidate || !attempt.candidateBase || !sameIdentity(candidate, attempt.candidate)
|
|
2306
|
+
|| !checkBatchPasses(attempt.preliminaryChecks, request.checks, candidate)) {
|
|
2307
|
+
throw new Error("Retained task work lacks exact passing preliminary candidate evidence.");
|
|
2308
|
+
}
|
|
2309
|
+
attempt.readiness = {
|
|
2310
|
+
candidate: attempt.candidate,
|
|
2311
|
+
base: attempt.candidateBase,
|
|
2312
|
+
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2313
|
+
};
|
|
2314
|
+
task.status = "ready_to_integrate";
|
|
2315
|
+
task.failure = undefined;
|
|
2316
|
+
await this.saveProductive(handle);
|
|
2317
|
+
this.retainCandidate(handle.state, task);
|
|
2318
|
+
handle.state.status = "needs_attention";
|
|
2319
|
+
await this.saveProductive(handle);
|
|
2320
|
+
return this.response(handle.state);
|
|
2321
|
+
}
|
|
2322
|
+
async retry(handle, task, scope) {
|
|
2323
|
+
const attempt = task.attempts.at(-1);
|
|
2324
|
+
if (!attempt) {
|
|
2325
|
+
for (const pending of handle.state.tasks) {
|
|
2326
|
+
if (pending.status === "needs_attention" && pending.attempts.length === 0) {
|
|
2327
|
+
pending.status = "pending";
|
|
2328
|
+
pending.failure = undefined;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
await this.saveProductive(handle);
|
|
2332
|
+
return await this.run(handle, scope, undefined);
|
|
2333
|
+
}
|
|
2334
|
+
if (attempt.prompts.length) {
|
|
2335
|
+
if (attempt.prompts.some((prompt) => prompt.status === "ambiguous")) {
|
|
2336
|
+
throw new Error("An ambiguous delivered prompt is never replayed.");
|
|
2337
|
+
}
|
|
2338
|
+
if (!this.correctionAllowed(handle.state, changesetTaskRequest(handle.state, task.taskId), attempt)) {
|
|
2339
|
+
throw new Error("The same-agent correction is unavailable or already used.");
|
|
2340
|
+
}
|
|
2341
|
+
task.status = "working";
|
|
2342
|
+
await this.driveWorkerSafely(handle, task, scope, "correction");
|
|
2343
|
+
}
|
|
2344
|
+
else {
|
|
2345
|
+
if (attempt.termination || allocationByKind(attempt, "agent")) {
|
|
2346
|
+
throw new Error("A promptless attempt with a terminated or potentially active saved agent cannot be retried productively.");
|
|
2347
|
+
}
|
|
2348
|
+
for (const intent of attempt.allocations.filter((item) => item.status !== "owned")) {
|
|
2349
|
+
let result;
|
|
2350
|
+
try {
|
|
2351
|
+
const request = changesetTaskRequest(handle.state, task.taskId);
|
|
2352
|
+
result = intent.kind === "worktree"
|
|
2353
|
+
? await this.callProductive(handle, scope, async (context) => await this.gitRuntime.reconcileWorktreeAllocation({
|
|
2354
|
+
root: handle.state.root, intent, task: request, attempt,
|
|
2355
|
+
}, context))
|
|
2356
|
+
: await this.callProductive(handle, scope, async (context) => await this.hostRuntime.reconcileHostAllocation({
|
|
2357
|
+
requestId: handle.state.request.id, intent, task: request, attempt,
|
|
2358
|
+
}, context));
|
|
2359
|
+
}
|
|
2360
|
+
catch (error) {
|
|
2361
|
+
this.rethrowStopped(error);
|
|
2362
|
+
intent.status = "unknown";
|
|
2363
|
+
intent.failure = bounded(`Allocation reconciliation is ambiguous: ${errorText(error)}`);
|
|
2364
|
+
throw new Error(intent.failure);
|
|
2365
|
+
}
|
|
2366
|
+
if (result.outcome !== "absent") {
|
|
2367
|
+
const failure = boundedRuntimeText(result.failure, "Allocation reconciliation failure");
|
|
2368
|
+
const possibleResources = boundedPossibleResources(result.possibleResources);
|
|
2369
|
+
intent.status = "unknown";
|
|
2370
|
+
intent.failure = failure;
|
|
2371
|
+
intent.possibleResources = possibleResources;
|
|
2372
|
+
await this.saveProductive(handle);
|
|
2373
|
+
throw new Error("A possible prior allocation blocks retry; it was not adopted or closed.");
|
|
2374
|
+
}
|
|
2375
|
+
intent.status = "absent";
|
|
2376
|
+
intent.failure = undefined;
|
|
2377
|
+
intent.possibleResources = undefined;
|
|
2378
|
+
}
|
|
2379
|
+
if (attempt.allocationGeneration >= 2)
|
|
2380
|
+
throw new Error("The one reconciled unprompted fresh launch was already used.");
|
|
2381
|
+
attempt.allocationGeneration += 1;
|
|
2382
|
+
task.status = "allocating";
|
|
2383
|
+
task.failure = undefined;
|
|
2384
|
+
await this.saveProductive(handle);
|
|
2385
|
+
await this.dispatchTask(handle, task, scope);
|
|
2386
|
+
}
|
|
2387
|
+
const current = changesetTaskState(handle.state, task.taskId);
|
|
2388
|
+
if (current.status === "ready_to_integrate") {
|
|
2389
|
+
this.retainCandidate(handle.state, current);
|
|
2390
|
+
handle.state.status = "needs_attention";
|
|
2391
|
+
await this.saveProductive(handle);
|
|
2392
|
+
return this.response(handle.state);
|
|
2393
|
+
}
|
|
2394
|
+
if (current.status !== "completed") {
|
|
2395
|
+
handle.state.status = "needs_attention";
|
|
2396
|
+
await this.saveProductive(handle);
|
|
2397
|
+
return this.response(handle.state);
|
|
2398
|
+
}
|
|
2399
|
+
return await this.run(handle, scope, undefined);
|
|
2400
|
+
}
|
|
2401
|
+
async finalize(handle, scope) {
|
|
2402
|
+
const state = handle.state;
|
|
2403
|
+
if (state.tasks.some((task) => task.status !== "completed"))
|
|
2404
|
+
throw new Error("Finalization requires every task to be completed.");
|
|
2405
|
+
if (state.final.status !== "pending" && state.final.status !== "interrupted") {
|
|
2406
|
+
throw new Error(`Final gate is ${state.final.status} and cannot be finalized.`);
|
|
2407
|
+
}
|
|
2408
|
+
if (state.final.identity) {
|
|
2409
|
+
const actual = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
2410
|
+
if (!sameIdentity(actual, state.final.identity)) {
|
|
2411
|
+
await this.markSuperseded(handle, "Main drifted from the recorded final-gate identity.");
|
|
2412
|
+
return this.response(state);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
return await this.runFinal(handle, scope);
|
|
2416
|
+
}
|
|
2417
|
+
async runFinal(handle, scope) {
|
|
2418
|
+
const state = handle.state;
|
|
2419
|
+
state.status = "running";
|
|
2420
|
+
state.final.status = "running";
|
|
2421
|
+
state.final.failure = undefined;
|
|
2422
|
+
state.accepted = false;
|
|
2423
|
+
await this.saveProductive(handle);
|
|
2424
|
+
try {
|
|
2425
|
+
const identity = state.final.identity ?? runtimeIdentity(await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context)), "Final Main identity");
|
|
2426
|
+
if (!sameIdentity(identity, state.main) || !isCleanCommitted(identity)) {
|
|
2427
|
+
await this.markSuperseded(handle, "Main drifted before the final gate.");
|
|
2428
|
+
return this.response(state);
|
|
2429
|
+
}
|
|
2430
|
+
state.final.identity = identity;
|
|
2431
|
+
await this.saveProductive(handle);
|
|
2432
|
+
const finalChecks = state.request.finalChecks ?? [];
|
|
2433
|
+
const checks = await this.runCheckBatch(handle, finalChecks, identity, "final", scope);
|
|
2434
|
+
state.final.checks = checks;
|
|
2435
|
+
await this.saveProductive(handle);
|
|
2436
|
+
if (!sameIdentity(checks.identityAfter, identity)) {
|
|
2437
|
+
await this.markSuperseded(handle, "Final checks changed Main.");
|
|
2438
|
+
return this.response(state);
|
|
2439
|
+
}
|
|
2440
|
+
if (!checkBatchPasses(checks, finalChecks, identity)) {
|
|
2441
|
+
await this.markFinalFailed(handle, "A definitive final check failed.");
|
|
2442
|
+
return this.response(state);
|
|
2443
|
+
}
|
|
2444
|
+
const afterChecks = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
2445
|
+
if (!sameIdentity(afterChecks, identity)) {
|
|
2446
|
+
await this.markSuperseded(handle, "Main drifted after final checks.");
|
|
2447
|
+
return this.response(state);
|
|
2448
|
+
}
|
|
2449
|
+
if (state.request.finalJudgment) {
|
|
2450
|
+
const review = await this.runReview(handle, state.request.finalJudgment.criterion, state.requestStartMain, identity, state.request.finalJudgment.role, state.request.finalJudgment.modelClass, "final", scope);
|
|
2451
|
+
state.final.review = review;
|
|
2452
|
+
await this.saveProductive(handle);
|
|
2453
|
+
if (!sameIdentity(review.identityAfter, identity)) {
|
|
2454
|
+
await this.markSuperseded(handle, "Final Judgment changed Main.");
|
|
2455
|
+
return this.response(state);
|
|
2456
|
+
}
|
|
2457
|
+
if (!review.passed) {
|
|
2458
|
+
await this.markFinalFailed(handle, "The definitive final judgment was not exact PASS.");
|
|
2459
|
+
return this.response(state);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
const afterJudgment = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
2463
|
+
if (!sameIdentity(afterJudgment, identity)) {
|
|
2464
|
+
await this.markSuperseded(handle, "Main drifted after final judgment.");
|
|
2465
|
+
return this.response(state);
|
|
2466
|
+
}
|
|
2467
|
+
const beforeCompletion = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMain({ root: state.root }, context));
|
|
2468
|
+
if (!sameIdentity(beforeCompletion, identity)) {
|
|
2469
|
+
await this.markSuperseded(handle, "Main drifted before final completion.");
|
|
2470
|
+
return this.response(state);
|
|
2471
|
+
}
|
|
2472
|
+
state.main = identity;
|
|
2473
|
+
state.final.status = "passed";
|
|
2474
|
+
state.status = "completed";
|
|
2475
|
+
state.accepted = true;
|
|
2476
|
+
state.acceptedAt = this.coordinatorRuntime.now();
|
|
2477
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
2478
|
+
await this.saveProductive(handle);
|
|
2479
|
+
return this.response(state);
|
|
2480
|
+
}
|
|
2481
|
+
catch (error) {
|
|
2482
|
+
this.rethrowStopped(error);
|
|
2483
|
+
state.final.status = "interrupted";
|
|
2484
|
+
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)}`);
|
|
2485
|
+
state.status = "needs_attention";
|
|
2486
|
+
state.accepted = false;
|
|
2487
|
+
await this.saveProductive(handle);
|
|
2488
|
+
return this.response(state);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
async markFinalFailed(handle, failure) {
|
|
2492
|
+
handle.state.final.status = "final_failed";
|
|
2493
|
+
handle.state.final.failure = bounded(failure);
|
|
2494
|
+
handle.state.status = "final_failed";
|
|
2495
|
+
handle.state.accepted = false;
|
|
2496
|
+
await this.saveProductive(handle);
|
|
2497
|
+
}
|
|
2498
|
+
async markSuperseded(handle, failure) {
|
|
2499
|
+
handle.state.final.status = "superseded";
|
|
2500
|
+
handle.state.final.failure = bounded(failure);
|
|
2501
|
+
handle.state.status = "superseded";
|
|
2502
|
+
handle.state.accepted = false;
|
|
2503
|
+
await this.saveProductive(handle);
|
|
2504
|
+
}
|
|
2505
|
+
recoverInterrupted(state) {
|
|
2506
|
+
if (state.status === "pending") {
|
|
2507
|
+
const ready = readyPendingTasks(state).filter((task) => task.attempts.length === 0);
|
|
2508
|
+
if (!ready.length)
|
|
2509
|
+
return false;
|
|
2510
|
+
for (const task of ready) {
|
|
2511
|
+
this.attention(task, `Dependency scheduling is pending before ${task.taskId} started; resume retry to continue.`);
|
|
2512
|
+
}
|
|
2513
|
+
state.status = "needs_attention";
|
|
2514
|
+
state.accepted = false;
|
|
2515
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
2516
|
+
return true;
|
|
2517
|
+
}
|
|
2518
|
+
if (state.status !== "running")
|
|
2519
|
+
return false;
|
|
2520
|
+
for (const task of state.tasks) {
|
|
2521
|
+
if (["pending", "completed", "needs_attention"].includes(task.status))
|
|
2522
|
+
continue;
|
|
2523
|
+
if (task.kind !== "changeset") {
|
|
2524
|
+
this.attention(task, "Execution was interrupted at an ambiguous boundary and will not replay automatically.");
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
const attempt = task.attempts.at(-1);
|
|
2528
|
+
if (attempt) {
|
|
2529
|
+
for (const intent of attempt.allocations) {
|
|
2530
|
+
if (intent.status === "allocating") {
|
|
2531
|
+
intent.status = "unknown";
|
|
2532
|
+
intent.failure = "Allocation was interrupted after intent persistence; no result was adopted.";
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
for (const prompt of attempt.prompts) {
|
|
2536
|
+
if (prompt.status === "submitting") {
|
|
2537
|
+
prompt.status = "ambiguous";
|
|
2538
|
+
prompt.failure = "Prompt delivery was interrupted and will not be replayed.";
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
if (attempt.termination?.status === "terminating") {
|
|
2542
|
+
attempt.termination.status = "unknown";
|
|
2543
|
+
attempt.termination.failure = "Worker termination was interrupted and remains unproved.";
|
|
2544
|
+
}
|
|
2545
|
+
for (const step of attempt.cleanup)
|
|
2546
|
+
if (step.status === "running")
|
|
2547
|
+
step.status = "pending";
|
|
2548
|
+
}
|
|
2549
|
+
this.attention(task, state.recovery?.taskId === task.taskId
|
|
2550
|
+
? "Deliberate recovery was interrupted; exact saved evidence and resources were retained."
|
|
2551
|
+
: "Execution was interrupted at an ambiguous boundary and will not replay automatically.");
|
|
2552
|
+
}
|
|
2553
|
+
if (!state.tasks.some((task) => task.status === "needs_attention")) {
|
|
2554
|
+
for (const task of readyPendingTasks(state)) {
|
|
2555
|
+
this.attention(task, `Dependency scheduling was interrupted before ${task.taskId} started; resume retry to continue.`);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
if (state.final.status === "running") {
|
|
2559
|
+
state.final.status = "interrupted";
|
|
2560
|
+
state.final.failure = "Final verification was interrupted without a definitive result.";
|
|
2561
|
+
}
|
|
2562
|
+
delete state.recovery;
|
|
2563
|
+
state.status = "needs_attention";
|
|
2564
|
+
state.accepted = false;
|
|
2565
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
2566
|
+
return true;
|
|
2567
|
+
}
|
|
2568
|
+
ensureFollowupControl(handle, task) {
|
|
2569
|
+
const key = this.controlKey(handle.state.root, handle.state.request.id, task.taskId);
|
|
2570
|
+
const existing = this.followupControls.get(key);
|
|
2571
|
+
if (existing) {
|
|
2572
|
+
if (existing.task !== task)
|
|
2573
|
+
throw new Error(`Task ${task.taskId} follow-up control has stale state ownership.`);
|
|
2574
|
+
return existing.control;
|
|
2575
|
+
}
|
|
2576
|
+
const control = new FollowupControl((instruction) => {
|
|
2577
|
+
const attempt = latestAttempt(task);
|
|
2578
|
+
const agent = allocationByKind(attempt, "agent");
|
|
2579
|
+
if (!agent)
|
|
2580
|
+
throw new Error("Worker launch has no durably recorded agent allocation.");
|
|
2581
|
+
buildChangesetTaskPrompt({
|
|
2582
|
+
goal: handle.state.request.goal,
|
|
2583
|
+
contexts: [],
|
|
2584
|
+
task: changesetTaskRequest(handle.state, task.taskId),
|
|
2585
|
+
kind: "followup",
|
|
2586
|
+
instruction,
|
|
2587
|
+
worktreeCwd: agent.worktreeCwd,
|
|
2588
|
+
});
|
|
2589
|
+
});
|
|
2590
|
+
this.followupControls.set(key, { task, control });
|
|
2591
|
+
return control;
|
|
2592
|
+
}
|
|
2593
|
+
controlKey(root, requestId, taskId) {
|
|
2594
|
+
return `${root}\0${requestId}\0${taskId}`;
|
|
2595
|
+
}
|
|
2596
|
+
activeControl(root, requestId, taskId) {
|
|
2597
|
+
return this.followupControls.get(this.controlKey(root, requestId, taskId));
|
|
2598
|
+
}
|
|
2599
|
+
activeControls(root, requestId) {
|
|
2600
|
+
const prefix = `${root}\0${requestId}\0`;
|
|
2601
|
+
return [...this.followupControls.entries()]
|
|
2602
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
2603
|
+
.map(([, active]) => active);
|
|
2604
|
+
}
|
|
2605
|
+
hasActiveControl(root, requestId) {
|
|
2606
|
+
return this.activeControls(root, requestId).length > 0;
|
|
2607
|
+
}
|
|
2608
|
+
hasAnyActiveControl(root) {
|
|
2609
|
+
const prefix = `${root}\0`;
|
|
2610
|
+
return [...this.followupControls.keys()].some((key) => key.startsWith(prefix));
|
|
2611
|
+
}
|
|
2612
|
+
closeFollowupControl(root, requestId, taskId, control) {
|
|
2613
|
+
const key = this.controlKey(root, requestId, taskId);
|
|
2614
|
+
control.close();
|
|
2615
|
+
if (this.followupControls.get(key)?.control === control)
|
|
2616
|
+
this.followupControls.delete(key);
|
|
2617
|
+
}
|
|
2618
|
+
closeRequestControls(root, requestId) {
|
|
2619
|
+
for (const { task, control } of this.activeControls(root, requestId)) {
|
|
2620
|
+
this.closeFollowupControl(root, requestId, task.taskId, control);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
attention(task, failure) {
|
|
2624
|
+
const boundedFailure = bounded(failure);
|
|
2625
|
+
if (task.kind === "text") {
|
|
2626
|
+
const attempt = task.attempts.at(-1);
|
|
2627
|
+
if (attempt?.status === "running") {
|
|
2628
|
+
attempt.status = "failed";
|
|
2629
|
+
attempt.failure = boundedFailure;
|
|
2630
|
+
}
|
|
2631
|
+
else {
|
|
2632
|
+
if (task.attempts.length >= 2)
|
|
2633
|
+
throw new Error(`Text task ${task.taskId} cannot record another failed attempt.`);
|
|
2634
|
+
task.attempts.push({ number: task.attempts.length + 1, status: "failed", failure: boundedFailure });
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
task.status = "needs_attention";
|
|
2638
|
+
task.failure = boundedFailure;
|
|
2639
|
+
}
|
|
2640
|
+
response(state, main) {
|
|
2641
|
+
const completed = state.tasks.filter((task) => task.status === "completed").length;
|
|
2642
|
+
const rejected = state.tasks.filter((task) => task.status === "rejected").length;
|
|
2643
|
+
const resumable = !terminal(state);
|
|
2644
|
+
let continuation;
|
|
2645
|
+
if (resumable) {
|
|
2646
|
+
const attention = state.tasks.find((task) => task.status === "needs_attention");
|
|
2647
|
+
if (completed === state.tasks.length
|
|
2648
|
+
&& (state.final.status === "pending" || state.final.status === "interrupted")) {
|
|
2649
|
+
continuation = { id: state.request.id, action: "finalize" };
|
|
2650
|
+
}
|
|
2651
|
+
else if (attention?.kind === "text" && textRetryEligible(state, attention)) {
|
|
2652
|
+
continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
|
|
2653
|
+
}
|
|
2654
|
+
else if (attention?.kind === "changeset" && attention.attempts.length === 0) {
|
|
2655
|
+
continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
|
|
2656
|
+
}
|
|
2657
|
+
else if (attention?.kind === "changeset" && !attention.attempts.at(-1)?.termination) {
|
|
2658
|
+
const attempt = attention.attempts.at(-1);
|
|
2659
|
+
if (attempt && this.correctionAllowed(state, changesetTaskRequest(state, attention.taskId), attempt)) {
|
|
2660
|
+
continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
|
|
2661
|
+
}
|
|
2662
|
+
else if ((attempt?.readiness && !state.integration.candidates.length)
|
|
2663
|
+
|| (attempt?.candidate && attempt.candidateBase
|
|
2664
|
+
&& checkBatchPasses(attempt.preliminaryChecks, changesetTaskRequest(state, attention.taskId).checks, attempt.candidate))) {
|
|
2665
|
+
continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
const attention = continuation && "taskId" in continuation
|
|
2670
|
+
? taskState(state, continuation.taskId)
|
|
2671
|
+
: state.tasks.find((task) => task.status === "needs_attention");
|
|
2672
|
+
return {
|
|
2673
|
+
text: bounded([
|
|
2674
|
+
`Pi Subagent ${state.request.id}: ${state.status}.`,
|
|
2675
|
+
`Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
|
|
2676
|
+
...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
|
|
2677
|
+
...(main?.status === "drifted" ? [
|
|
2678
|
+
`Main: drifted from ${main.expected.branch}@${main.expected.head} to ${main.actual.branch}@${main.actual.head}.`,
|
|
2679
|
+
] : []),
|
|
2680
|
+
...(main?.status === "unavailable" ? [`Main: ${main.failure}`] : []),
|
|
2681
|
+
...(attention?.failure ? [`Needs attention (${attention.taskId}): ${attention.failure}`] : []),
|
|
2682
|
+
...(state.integration.candidates.length && state.integration.generations.at(-1)?.status !== "promoted" ? [
|
|
2683
|
+
`Candidates: ${state.integration.candidates.map((item) => `${item.taskId}#${item.attempt}@${item.tip.head}${item.decision ? " (rejected)" : ""}`).join(", ")}. Use subagent_stage with exact status identities; Main remains unchanged.`,
|
|
2684
|
+
] : []),
|
|
2685
|
+
...(state.integration.generations.at(-1) ? [
|
|
2686
|
+
`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"}.`,
|
|
2687
|
+
...(state.integration.generations.at(-1).status === "staging" && readyPendingTasks(state).some((task) => state.integration.generations.at(-1).combinedTip && hasChangesetDependency(state, task.taskId)
|
|
2688
|
+
&& stagedDependencySnapshot(state, task.taskId, state.integration.generations.at(-1).combinedTip))
|
|
2689
|
+
? ["Main may call subagent_integrate advance with this generation and its exact staged combinedTip to launch ready dependents."] : []),
|
|
2690
|
+
...(state.integration.generations.at(-1).failure ? [`Integration: ${state.integration.generations.at(-1).failure}`] : []),
|
|
2691
|
+
...(state.integration.generations.at(-1).promotion?.failure ? [`Promotion: ${state.integration.generations.at(-1).promotion.failure}`] : []),
|
|
2692
|
+
...(state.integration.generations.at(-1).checks?.passed === false ? ["Combined checks failed; Main was not changed."] : []),
|
|
2693
|
+
...(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."] : []),
|
|
2694
|
+
] : []),
|
|
2695
|
+
...(state.integration.generations.some((item) => item.status === "superseded" && item.worktree
|
|
2696
|
+
&& !item.cleanup?.every((step) => step.status === "completed")) ? [
|
|
2697
|
+
"Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and last staged tip; dirty or conflicted work stays retained.",
|
|
2698
|
+
] : []),
|
|
2699
|
+
...(state.integration.candidates.some((item) => item.decision === "rejected" && item.worker !== "released") ? [
|
|
2700
|
+
"Rejected candidate resources remain retained. After proving worker termination and releasing any superseded stage, use subagent_integrate release with the exact task, attempt and candidate tip.",
|
|
2701
|
+
] : []),
|
|
2702
|
+
...(state.final.failure ? [`Final: ${state.final.failure}`] : []),
|
|
2703
|
+
...(continuation ? [`Continuation: ${JSON.stringify(continuation)}`] : []),
|
|
2704
|
+
].join("\n")),
|
|
2705
|
+
state,
|
|
2706
|
+
...(main ? { main } : {}),
|
|
2707
|
+
...(continuation ? { continuation } : {}),
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
}
|