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.
@@ -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
+ }