parallel-codex-tui 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -7
- package/dist/cli-args.js +45 -2
- package/dist/cli-help.js +3 -0
- package/dist/cli.js +44 -1
- package/dist/orchestrator/orchestrator.js +26 -4
- package/dist/supervisor/client.js +442 -0
- package/dist/supervisor/operations.js +116 -0
- package/dist/supervisor/protocol.js +177 -0
- package/dist/supervisor/runner.js +238 -0
- package/dist/supervisor/store.js +216 -0
- package/dist/tui/App.js +107 -31
- package/dist/tui/AppShell.js +7 -3
- package/dist/tui/InputBar.js +9 -3
- package/dist/version.js +1 -1
- package/dist/workers/mock-adapter.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { closeSync, openSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { asSupervisorRunResult, supervisorEventPayload } from "./protocol.js";
|
|
6
|
+
import { acknowledgeSupervisorRun, appendSupervisorCommand, claimSupervisorController, createSupervisorRun, createSupervisorRunId, listSupervisorRuns, readSupervisorEvents, readSupervisorRunState, supervisorRunIsAcknowledged, supervisorRunIsTerminal, supervisorRunProcessIsActive, writeSupervisorRunState } from "./store.js";
|
|
7
|
+
const RUN_POLL_MS = 100;
|
|
8
|
+
const QUEUED_START_GRACE_MS = 3000;
|
|
9
|
+
export class SupervisorDetachedError extends Error {
|
|
10
|
+
constructor(message = "Background run detached") {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "SupervisorDetachedError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function isSupervisorDetachedError(error) {
|
|
16
|
+
return error instanceof Error && error.name === "SupervisorDetachedError";
|
|
17
|
+
}
|
|
18
|
+
export class SupervisorOrchestrator {
|
|
19
|
+
options;
|
|
20
|
+
persistsRunResults = true;
|
|
21
|
+
launch;
|
|
22
|
+
now;
|
|
23
|
+
recoverableRun = null;
|
|
24
|
+
currentRun = null;
|
|
25
|
+
settledRun = null;
|
|
26
|
+
detaching = false;
|
|
27
|
+
backgroundRunListeners = new Set();
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.launch = options.launch ?? launchSupervisorProcess;
|
|
31
|
+
this.now = options.now ?? (() => new Date());
|
|
32
|
+
}
|
|
33
|
+
static async open(options) {
|
|
34
|
+
const client = new SupervisorOrchestrator(options);
|
|
35
|
+
client.recoverableRun = await client.findRecoverableRun();
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
async handleRequest(input) {
|
|
39
|
+
const routed = await this.options.delegate.routeInitialRequest(input);
|
|
40
|
+
return this.startRun({
|
|
41
|
+
kind: "handle-request",
|
|
42
|
+
request: input.request,
|
|
43
|
+
cwd: input.cwd,
|
|
44
|
+
route: routed.route,
|
|
45
|
+
role_selection: routed.roleSelection
|
|
46
|
+
}, input);
|
|
47
|
+
}
|
|
48
|
+
async handleTaskTurn(input) {
|
|
49
|
+
return this.startRun({
|
|
50
|
+
kind: "handle-task-turn",
|
|
51
|
+
request: input.request,
|
|
52
|
+
cwd: input.cwd,
|
|
53
|
+
task_id: input.taskId,
|
|
54
|
+
...(input.route ? { route: input.route } : {}),
|
|
55
|
+
...(input.roleSelection ? { role_selection: input.roleSelection } : {})
|
|
56
|
+
}, input);
|
|
57
|
+
}
|
|
58
|
+
async answerTaskQuestion(input) {
|
|
59
|
+
return this.startRun({
|
|
60
|
+
kind: "answer-task-question",
|
|
61
|
+
request: input.request,
|
|
62
|
+
cwd: input.cwd,
|
|
63
|
+
task_id: input.taskId,
|
|
64
|
+
...(input.route ? { route: input.route } : {}),
|
|
65
|
+
...(input.roleSelection ? { role_selection: input.roleSelection } : {})
|
|
66
|
+
}, input);
|
|
67
|
+
}
|
|
68
|
+
async retryTask(input) {
|
|
69
|
+
return this.startRun({
|
|
70
|
+
kind: "retry-task",
|
|
71
|
+
cwd: input.cwd,
|
|
72
|
+
task_id: input.taskId
|
|
73
|
+
}, input);
|
|
74
|
+
}
|
|
75
|
+
async resumeFeature(input) {
|
|
76
|
+
return this.startRun({
|
|
77
|
+
kind: "resume-feature",
|
|
78
|
+
cwd: input.cwd,
|
|
79
|
+
task_id: input.taskId,
|
|
80
|
+
feature_id: input.featureId
|
|
81
|
+
}, input);
|
|
82
|
+
}
|
|
83
|
+
async restorePendingRun(input) {
|
|
84
|
+
this.detaching = input.signal?.aborted ? this.detaching : false;
|
|
85
|
+
const pending = this.recoverableRun ?? await this.findRecoverableRun();
|
|
86
|
+
this.recoverableRun = null;
|
|
87
|
+
if (!pending) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const controller = await claimSupervisorController(pending.files);
|
|
91
|
+
return this.watchRun({
|
|
92
|
+
files: pending.files,
|
|
93
|
+
controller,
|
|
94
|
+
detached: this.detaching,
|
|
95
|
+
cancelSent: false
|
|
96
|
+
}, input);
|
|
97
|
+
}
|
|
98
|
+
detachBackgroundRuns() {
|
|
99
|
+
this.detaching = true;
|
|
100
|
+
const current = this.currentRun;
|
|
101
|
+
if (!current) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
current.detached = true;
|
|
105
|
+
void current.controller?.release();
|
|
106
|
+
current.controller = null;
|
|
107
|
+
this.notifyBackgroundRunState();
|
|
108
|
+
}
|
|
109
|
+
backgroundRunAttached() {
|
|
110
|
+
return this.currentRun !== null;
|
|
111
|
+
}
|
|
112
|
+
backgroundRunControllable() {
|
|
113
|
+
return this.currentRun?.controller !== null && this.currentRun?.controller !== undefined;
|
|
114
|
+
}
|
|
115
|
+
subscribeBackgroundRunState(listener) {
|
|
116
|
+
this.backgroundRunListeners.add(listener);
|
|
117
|
+
return () => this.backgroundRunListeners.delete(listener);
|
|
118
|
+
}
|
|
119
|
+
async acknowledgeBackgroundRun() {
|
|
120
|
+
if (!this.settledRun) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const files = this.settledRun;
|
|
124
|
+
this.settledRun = null;
|
|
125
|
+
await acknowledgeSupervisorRun(files);
|
|
126
|
+
}
|
|
127
|
+
async cancelFeature(taskId, featureId) {
|
|
128
|
+
if (!this.currentRun) {
|
|
129
|
+
return this.options.delegate.cancelFeature(taskId, featureId);
|
|
130
|
+
}
|
|
131
|
+
if (!this.currentRun.controller) {
|
|
132
|
+
return { requested: false, featureId };
|
|
133
|
+
}
|
|
134
|
+
await appendSupervisorCommand(this.currentRun.files, {
|
|
135
|
+
version: 1,
|
|
136
|
+
id: randomUUID(),
|
|
137
|
+
at: this.now().toISOString(),
|
|
138
|
+
type: "cancel-feature",
|
|
139
|
+
task_id: taskId,
|
|
140
|
+
feature_id: featureId
|
|
141
|
+
});
|
|
142
|
+
return { requested: true, featureId };
|
|
143
|
+
}
|
|
144
|
+
async pauseFeature(taskId, featureId) {
|
|
145
|
+
if (!this.currentRun) {
|
|
146
|
+
return this.options.delegate.pauseFeature(taskId, featureId);
|
|
147
|
+
}
|
|
148
|
+
if (!this.currentRun.controller) {
|
|
149
|
+
return { requested: false, featureId };
|
|
150
|
+
}
|
|
151
|
+
await appendSupervisorCommand(this.currentRun.files, {
|
|
152
|
+
version: 1,
|
|
153
|
+
id: randomUUID(),
|
|
154
|
+
at: this.now().toISOString(),
|
|
155
|
+
type: "pause-feature",
|
|
156
|
+
task_id: taskId,
|
|
157
|
+
feature_id: featureId
|
|
158
|
+
});
|
|
159
|
+
return { requested: true, featureId };
|
|
160
|
+
}
|
|
161
|
+
routeTaskFollowUp(input) {
|
|
162
|
+
return this.options.delegate.routeTaskFollowUp(input);
|
|
163
|
+
}
|
|
164
|
+
canRetryTask(taskId) {
|
|
165
|
+
return this.options.delegate.canRetryTask(taskId);
|
|
166
|
+
}
|
|
167
|
+
listTaskWorkers(taskId) {
|
|
168
|
+
return this.options.delegate.listTaskWorkers(taskId);
|
|
169
|
+
}
|
|
170
|
+
reassignFeature(input) {
|
|
171
|
+
return this.options.delegate.reassignFeature(input);
|
|
172
|
+
}
|
|
173
|
+
roleConfigurationSnapshot(taskId) {
|
|
174
|
+
return this.options.delegate.roleConfigurationSnapshot(taskId);
|
|
175
|
+
}
|
|
176
|
+
updateRoleConfiguration(input) {
|
|
177
|
+
return this.options.delegate.updateRoleConfiguration(input);
|
|
178
|
+
}
|
|
179
|
+
clearRoleConfiguration(scope, taskId) {
|
|
180
|
+
return this.options.delegate.clearRoleConfiguration(scope, taskId);
|
|
181
|
+
}
|
|
182
|
+
validateRoleConfiguration(roles) {
|
|
183
|
+
return this.options.delegate.validateRoleConfiguration(roles);
|
|
184
|
+
}
|
|
185
|
+
async startRun(input, callbacks) {
|
|
186
|
+
this.detaching = callbacks.signal?.aborted ? this.detaching : false;
|
|
187
|
+
if (this.currentRun) {
|
|
188
|
+
throw new Error("A Supervisor run is already attached in this TUI.");
|
|
189
|
+
}
|
|
190
|
+
const active = (await this.reconcileRuns()).filter(({ state }) => !supervisorRunIsTerminal(state));
|
|
191
|
+
if (active.length > 0) {
|
|
192
|
+
throw new Error("A background run is already active in this workspace. Reopen the TUI to attach to it.");
|
|
193
|
+
}
|
|
194
|
+
const createdAt = this.now().toISOString();
|
|
195
|
+
const request = {
|
|
196
|
+
...input,
|
|
197
|
+
version: 1,
|
|
198
|
+
run_id: createSupervisorRunId(this.now()),
|
|
199
|
+
app_root: this.options.appRoot,
|
|
200
|
+
workspace_root: this.options.workspaceRoot,
|
|
201
|
+
data_dir: this.options.dataDir,
|
|
202
|
+
created_at: createdAt
|
|
203
|
+
};
|
|
204
|
+
const files = await createSupervisorRun(this.options.workspaceRoot, this.options.dataDir, request);
|
|
205
|
+
const controller = await claimSupervisorController(files);
|
|
206
|
+
if (!controller) {
|
|
207
|
+
throw new Error("The new Supervisor run was claimed by another TUI before it could start.");
|
|
208
|
+
}
|
|
209
|
+
const watched = {
|
|
210
|
+
files,
|
|
211
|
+
controller,
|
|
212
|
+
detached: this.detaching,
|
|
213
|
+
cancelSent: false
|
|
214
|
+
};
|
|
215
|
+
this.currentRun = watched;
|
|
216
|
+
this.notifyBackgroundRunState();
|
|
217
|
+
try {
|
|
218
|
+
await this.launch(files, request);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (this.currentRun === watched) {
|
|
222
|
+
this.currentRun = null;
|
|
223
|
+
this.notifyBackgroundRunState();
|
|
224
|
+
}
|
|
225
|
+
await controller.release();
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
|
+
const failedAt = this.now().toISOString();
|
|
228
|
+
const state = await readSupervisorRunState(files);
|
|
229
|
+
await writeSupervisorRunState(files, {
|
|
230
|
+
...state,
|
|
231
|
+
status: "failed",
|
|
232
|
+
updated_at: failedAt,
|
|
233
|
+
finished_at: failedAt,
|
|
234
|
+
error: message
|
|
235
|
+
});
|
|
236
|
+
await this.options.sessions.appendChatMessage({ from: "system", text: message });
|
|
237
|
+
this.settledRun = files;
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
return this.watchRun(watched, callbacks);
|
|
241
|
+
}
|
|
242
|
+
async watchRun(watched, callbacks) {
|
|
243
|
+
this.currentRun = watched;
|
|
244
|
+
this.notifyBackgroundRunState();
|
|
245
|
+
let nextEventSequence = 0;
|
|
246
|
+
try {
|
|
247
|
+
while (true) {
|
|
248
|
+
if (watched.detached) {
|
|
249
|
+
throw new SupervisorDetachedError();
|
|
250
|
+
}
|
|
251
|
+
if (!watched.controller) {
|
|
252
|
+
watched.controller = await claimSupervisorController(watched.files);
|
|
253
|
+
if (watched.controller) {
|
|
254
|
+
this.notifyBackgroundRunState();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const events = await readSupervisorEvents(watched.files);
|
|
258
|
+
for (const event of events) {
|
|
259
|
+
if (event.sequence < nextEventSequence) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
nextEventSequence = event.sequence + 1;
|
|
263
|
+
dispatchSupervisorEvent(event, callbacks);
|
|
264
|
+
}
|
|
265
|
+
const state = await readSupervisorRunState(watched.files);
|
|
266
|
+
if (supervisorRunIsTerminal(state)) {
|
|
267
|
+
this.settledRun = watched.files;
|
|
268
|
+
if (state.status === "completed" && state.result) {
|
|
269
|
+
return asSupervisorRunResult(state.result);
|
|
270
|
+
}
|
|
271
|
+
const error = new Error(state.error || `Supervisor run ${state.status}`);
|
|
272
|
+
if (state.status === "cancelled") {
|
|
273
|
+
error.name = "AbortError";
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
if (watched.controller && !(await supervisorRunProcessIsActive(state))) {
|
|
278
|
+
const ageMs = this.now().getTime() - Date.parse(state.created_at);
|
|
279
|
+
if (state.status !== "queued" || ageMs >= QUEUED_START_GRACE_MS) {
|
|
280
|
+
const failedAt = this.now().toISOString();
|
|
281
|
+
const summary = `Supervisor exited unexpectedly while ${state.kind} was running.`;
|
|
282
|
+
await writeSupervisorRunState(watched.files, {
|
|
283
|
+
...state,
|
|
284
|
+
status: "failed",
|
|
285
|
+
updated_at: failedAt,
|
|
286
|
+
finished_at: failedAt,
|
|
287
|
+
error: summary
|
|
288
|
+
});
|
|
289
|
+
await this.options.sessions.appendChatMessage({
|
|
290
|
+
from: "system",
|
|
291
|
+
text: summary,
|
|
292
|
+
taskId: state.task_id ?? undefined
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (callbacks.signal?.aborted && !watched.cancelSent) {
|
|
298
|
+
if (!watched.controller) {
|
|
299
|
+
throw new SupervisorDetachedError("Observer detached; the background run is still active");
|
|
300
|
+
}
|
|
301
|
+
watched.cancelSent = true;
|
|
302
|
+
await appendSupervisorCommand(watched.files, {
|
|
303
|
+
version: 1,
|
|
304
|
+
id: randomUUID(),
|
|
305
|
+
at: this.now().toISOString(),
|
|
306
|
+
type: "cancel-run"
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
await delay(RUN_POLL_MS);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
if (this.currentRun === watched) {
|
|
314
|
+
this.currentRun = null;
|
|
315
|
+
}
|
|
316
|
+
await watched.controller?.release();
|
|
317
|
+
watched.controller = null;
|
|
318
|
+
this.notifyBackgroundRunState();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
notifyBackgroundRunState() {
|
|
322
|
+
for (const listener of this.backgroundRunListeners) {
|
|
323
|
+
listener();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async findRecoverableRun() {
|
|
327
|
+
const runs = await this.reconcileRuns();
|
|
328
|
+
const active = runs.filter(({ state }) => !supervisorRunIsTerminal(state));
|
|
329
|
+
if (active.length > 0) {
|
|
330
|
+
return active.at(-1) ?? null;
|
|
331
|
+
}
|
|
332
|
+
const unacknowledged = [];
|
|
333
|
+
for (const run of runs) {
|
|
334
|
+
if (supervisorRunIsTerminal(run.state) && !(await supervisorRunIsAcknowledged(run.files))) {
|
|
335
|
+
unacknowledged.push(run);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return unacknowledged.at(-1) ?? null;
|
|
339
|
+
}
|
|
340
|
+
async reconcileRuns() {
|
|
341
|
+
const runs = await listSupervisorRuns(this.options.workspaceRoot, this.options.dataDir);
|
|
342
|
+
for (const run of runs) {
|
|
343
|
+
if (supervisorRunIsTerminal(run.state) || await supervisorRunProcessIsActive(run.state)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const ageMs = this.now().getTime() - Date.parse(run.state.created_at);
|
|
347
|
+
if (run.state.status === "queued" && ageMs < QUEUED_START_GRACE_MS) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const failedAt = this.now().toISOString();
|
|
351
|
+
const summary = `Supervisor exited unexpectedly while ${run.state.kind} was running.`;
|
|
352
|
+
run.state = {
|
|
353
|
+
...run.state,
|
|
354
|
+
status: "failed",
|
|
355
|
+
updated_at: failedAt,
|
|
356
|
+
finished_at: failedAt,
|
|
357
|
+
error: summary
|
|
358
|
+
};
|
|
359
|
+
await writeSupervisorRunState(run.files, run.state);
|
|
360
|
+
await this.options.sessions.appendChatMessage({
|
|
361
|
+
from: "system",
|
|
362
|
+
text: summary,
|
|
363
|
+
taskId: run.state.task_id ?? undefined
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return runs;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function dispatchSupervisorEvent(event, callbacks) {
|
|
370
|
+
const payload = supervisorEventPayload(event);
|
|
371
|
+
switch (event.type) {
|
|
372
|
+
case "route-start":
|
|
373
|
+
callbacks.onRouteStart?.(payload);
|
|
374
|
+
break;
|
|
375
|
+
case "route-progress":
|
|
376
|
+
callbacks.onRouteProgress?.(payload);
|
|
377
|
+
break;
|
|
378
|
+
case "route":
|
|
379
|
+
callbacks.onRoute?.(payload);
|
|
380
|
+
break;
|
|
381
|
+
case "status":
|
|
382
|
+
callbacks.onStatus?.(payload);
|
|
383
|
+
break;
|
|
384
|
+
case "worker":
|
|
385
|
+
callbacks.onWorker?.(payload);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function launchSupervisorProcess(files, request) {
|
|
390
|
+
const entrypoint = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
391
|
+
if (!entrypoint) {
|
|
392
|
+
throw new Error("Cannot locate the parallel-codex-tui CLI entrypoint for Supervisor launch.");
|
|
393
|
+
}
|
|
394
|
+
const errorLog = openSync(join(files.dir, "supervisor.log"), "a");
|
|
395
|
+
const child = spawn(process.execPath, [...process.execArgv, entrypoint], {
|
|
396
|
+
cwd: process.cwd(),
|
|
397
|
+
detached: true,
|
|
398
|
+
env: {
|
|
399
|
+
...process.env,
|
|
400
|
+
PCT_SUPERVISOR_RUN_DIR: files.dir
|
|
401
|
+
},
|
|
402
|
+
stdio: ["ignore", "ignore", errorLog]
|
|
403
|
+
});
|
|
404
|
+
try {
|
|
405
|
+
await new Promise((resolve, reject) => {
|
|
406
|
+
child.once("spawn", resolve);
|
|
407
|
+
child.once("error", reject);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
closeSync(errorLog);
|
|
412
|
+
}
|
|
413
|
+
let exitCode;
|
|
414
|
+
const onExit = (code) => {
|
|
415
|
+
exitCode = code;
|
|
416
|
+
};
|
|
417
|
+
child.once("exit", onExit);
|
|
418
|
+
const deadline = Date.now() + 5000;
|
|
419
|
+
while (Date.now() < deadline) {
|
|
420
|
+
const state = await readSupervisorRunState(files);
|
|
421
|
+
if (state.status !== "queued") {
|
|
422
|
+
child.off("exit", onExit);
|
|
423
|
+
child.unref();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (exitCode !== undefined) {
|
|
427
|
+
throw new Error(`Supervisor process exited before startup (code ${exitCode ?? "signal"}).`);
|
|
428
|
+
}
|
|
429
|
+
await delay(25);
|
|
430
|
+
}
|
|
431
|
+
child.off("exit", onExit);
|
|
432
|
+
try {
|
|
433
|
+
child.kill("SIGTERM");
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// The startup failure below remains the useful error when the child already exited.
|
|
437
|
+
}
|
|
438
|
+
throw new Error("Supervisor process did not publish startup state within 5s.");
|
|
439
|
+
}
|
|
440
|
+
function delay(ms) {
|
|
441
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
442
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendSupervisorCommand, listSupervisorRuns, readSupervisorController, supervisorControllerIsActive, supervisorRunIsAcknowledged, supervisorRunIsTerminal, supervisorRunProcessIsActive } from "./store.js";
|
|
3
|
+
const QUEUED_START_GRACE_MS = 5000;
|
|
4
|
+
export async function inspectSupervisorRuns(workspaceRoot, dataDir, now = new Date()) {
|
|
5
|
+
const records = await listSupervisorRuns(workspaceRoot, dataDir);
|
|
6
|
+
const runs = await Promise.all(records.map((record) => inspectSupervisorRun(record, now)));
|
|
7
|
+
return {
|
|
8
|
+
version: 1,
|
|
9
|
+
workspace_root: workspaceRoot,
|
|
10
|
+
generated_at: now.toISOString(),
|
|
11
|
+
runs: runs.reverse()
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function requestSupervisorRunCancellation(workspaceRoot, dataDir, runId, now = new Date()) {
|
|
15
|
+
const records = await listSupervisorRuns(workspaceRoot, dataDir);
|
|
16
|
+
const inspected = await Promise.all(records.map(async (record) => ({
|
|
17
|
+
record,
|
|
18
|
+
view: await inspectSupervisorRun(record, now)
|
|
19
|
+
})));
|
|
20
|
+
const newestFirst = inspected.reverse();
|
|
21
|
+
const selected = runId
|
|
22
|
+
? newestFirst.find(({ view }) => view.run_id === runId)
|
|
23
|
+
: newestFirst.find(({ view }) => !isTerminalStatus(view.status) && view.control !== "stale");
|
|
24
|
+
if (!selected) {
|
|
25
|
+
if (runId) {
|
|
26
|
+
throw new Error(`Supervisor run not found: ${runId}`);
|
|
27
|
+
}
|
|
28
|
+
const stale = newestFirst.find(({ view }) => !isTerminalStatus(view.status));
|
|
29
|
+
if (stale) {
|
|
30
|
+
throw new Error(`Supervisor run is not active: ${stale.view.run_id} (${stale.view.status})`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`No active Supervisor run in workspace ${workspaceRoot}`);
|
|
33
|
+
}
|
|
34
|
+
if (isTerminalStatus(selected.view.status)) {
|
|
35
|
+
throw new Error(`Supervisor run is already ${selected.view.status}: ${selected.view.run_id}`);
|
|
36
|
+
}
|
|
37
|
+
if (selected.view.control === "stale") {
|
|
38
|
+
throw new Error(`Supervisor run is not active: ${selected.view.run_id} (${selected.view.status})`);
|
|
39
|
+
}
|
|
40
|
+
const requestedAt = now.toISOString();
|
|
41
|
+
const commandId = randomUUID();
|
|
42
|
+
await appendSupervisorCommand(selected.record.files, {
|
|
43
|
+
version: 1,
|
|
44
|
+
id: commandId,
|
|
45
|
+
at: requestedAt,
|
|
46
|
+
type: "cancel-run"
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
version: 1,
|
|
50
|
+
command_id: commandId,
|
|
51
|
+
requested_at: requestedAt,
|
|
52
|
+
run: selected.view
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function formatSupervisorRuns(report) {
|
|
56
|
+
if (report.runs.length === 0) {
|
|
57
|
+
return `No Supervisor runs in ${report.workspace_root}.`;
|
|
58
|
+
}
|
|
59
|
+
const lines = [`Supervisor runs · ${report.workspace_root}`];
|
|
60
|
+
for (const run of report.runs) {
|
|
61
|
+
const acknowledgement = run.acknowledged ? "seen" : "unread";
|
|
62
|
+
lines.push(`${run.status} · ${run.control} · ${acknowledgement} · ${run.run_id}`);
|
|
63
|
+
lines.push([
|
|
64
|
+
run.task_id ? `task ${run.task_id}` : `kind ${run.kind}`,
|
|
65
|
+
`updated ${run.updated_at}`,
|
|
66
|
+
...(run.pid ? [`pid ${run.pid}`] : [])
|
|
67
|
+
].join(" · "));
|
|
68
|
+
}
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
export function formatSupervisorCancellation(result) {
|
|
72
|
+
const target = result.run.task_id ? `task ${result.run.task_id}` : result.run.kind;
|
|
73
|
+
return `Cancellation requested · ${result.run.run_id} · ${target}`;
|
|
74
|
+
}
|
|
75
|
+
async function inspectSupervisorRun(record, now) {
|
|
76
|
+
const controller = await readSupervisorController(record.files);
|
|
77
|
+
const [processActive, controllerActive, acknowledged] = await Promise.all([
|
|
78
|
+
supervisorRunProcessIsActive(record.state),
|
|
79
|
+
controller ? supervisorControllerIsActive(controller) : Promise.resolve(false),
|
|
80
|
+
supervisorRunIsAcknowledged(record.files)
|
|
81
|
+
]);
|
|
82
|
+
return {
|
|
83
|
+
run_id: record.state.run_id,
|
|
84
|
+
kind: record.state.kind,
|
|
85
|
+
status: record.state.status,
|
|
86
|
+
control: controlState(record, processActive, controllerActive, now),
|
|
87
|
+
task_id: record.state.task_id ?? null,
|
|
88
|
+
created_at: record.state.created_at,
|
|
89
|
+
updated_at: record.state.updated_at,
|
|
90
|
+
finished_at: record.state.finished_at ?? null,
|
|
91
|
+
pid: record.state.pid ?? null,
|
|
92
|
+
process_active: processActive,
|
|
93
|
+
controller_pid: controller?.pid ?? null,
|
|
94
|
+
controller_active: controllerActive,
|
|
95
|
+
acknowledged
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function controlState(record, processActive, controllerActive, now) {
|
|
99
|
+
if (supervisorRunIsTerminal(record.state)) {
|
|
100
|
+
return "settled";
|
|
101
|
+
}
|
|
102
|
+
if (controllerActive) {
|
|
103
|
+
return "controlled";
|
|
104
|
+
}
|
|
105
|
+
if (processActive) {
|
|
106
|
+
return "detached";
|
|
107
|
+
}
|
|
108
|
+
if (record.state.status === "queued"
|
|
109
|
+
&& now.getTime() - Date.parse(record.state.updated_at) <= QUEUED_START_GRACE_MS) {
|
|
110
|
+
return "starting";
|
|
111
|
+
}
|
|
112
|
+
return "stale";
|
|
113
|
+
}
|
|
114
|
+
function isTerminalStatus(status) {
|
|
115
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
116
|
+
}
|