parallel-codex-tui 0.1.4 → 0.1.6
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/.parallel-codex/config.example.toml +46 -0
- package/README.md +96 -19
- package/dist/cli-startup-preflight.js +18 -0
- package/dist/cli-startup-recovery.js +13 -1
- package/dist/cli.js +19 -7
- package/dist/core/clipboard.js +97 -0
- package/dist/core/collaboration-timeline.js +9 -2
- package/dist/core/config.js +161 -103
- package/dist/core/router.js +1 -1
- package/dist/core/session-index.js +234 -61
- package/dist/core/session-manager.js +25 -1
- package/dist/core/task-session-details.js +175 -0
- package/dist/core/task-state-machine.js +10 -9
- package/dist/doctor.js +58 -39
- package/dist/domain/schemas.js +15 -1
- package/dist/orchestrator/collaboration-channel.js +35 -3
- package/dist/orchestrator/final-acceptance.js +86 -0
- package/dist/orchestrator/orchestrator.js +405 -69
- package/dist/orchestrator/prompts.js +42 -3
- package/dist/orchestrator/workspace-sandbox.js +16 -0
- package/dist/tui/App.js +514 -56
- package/dist/tui/AppShell.js +9 -3
- package/dist/tui/FeatureBoardView.js +7 -2
- package/dist/tui/InputBar.js +87 -15
- package/dist/tui/StatusBar.js +1 -1
- package/dist/tui/StatusDetailView.js +164 -0
- package/dist/tui/TaskSessionDetailView.js +222 -0
- package/dist/tui/TaskSessionsView.js +5 -2
- package/dist/tui/WorkerOutputView.js +6 -1
- package/dist/tui/WorkerOverviewView.js +23 -4
- package/dist/tui/keyboard.js +6 -0
- package/dist/tui/status-line.js +42 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +4 -3
- package/dist/workers/live-probe.js +4 -3
- package/dist/workers/mock-adapter.js +37 -5
- package/dist/workers/native-attach.js +32 -17
- package/dist/workers/process-adapter.js +2 -0
- package/dist/workers/provider.js +26 -0
- package/dist/workers/registry.js +12 -22
- package/package.json +7 -1
|
@@ -1,79 +1,77 @@
|
|
|
1
|
-
import { readdir } from "node:fs/promises";
|
|
1
|
+
import { copyFile, readdir, rename, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { NativeSessionSchema, RetiredNativeSessionSchema, RouteDecisionSchema, TaskIdSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
5
5
|
import { ensureDir, pathExists, readJson, readTextIfExists } from "./file-store.js";
|
|
6
|
+
const SESSION_INDEX_SCHEMA_VERSION = 2;
|
|
7
|
+
const SESSION_INDEX_FILENAME = "session-index.sqlite";
|
|
6
8
|
export class SessionIndex {
|
|
7
9
|
db;
|
|
8
10
|
projectRoot;
|
|
9
11
|
dataDir;
|
|
10
|
-
|
|
12
|
+
databasePath;
|
|
13
|
+
recovery;
|
|
14
|
+
constructor(db, projectRoot, dataDir, databasePath, recovery) {
|
|
11
15
|
this.db = db;
|
|
12
16
|
this.projectRoot = projectRoot;
|
|
13
17
|
this.dataDir = dataDir;
|
|
18
|
+
this.databasePath = databasePath;
|
|
19
|
+
this.recovery = recovery;
|
|
14
20
|
}
|
|
15
21
|
static async open(projectRoot, dataDir) {
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const runtimeDir = join(projectRoot, dataDir);
|
|
23
|
+
const databasePath = join(runtimeDir, SESSION_INDEX_FILENAME);
|
|
24
|
+
const recoveryBackupPath = `${databasePath}.backup`;
|
|
25
|
+
await ensureDir(runtimeDir);
|
|
26
|
+
const databaseExisted = await pathExists(databasePath);
|
|
27
|
+
const opened = await openSessionDatabase(databasePath, recoveryBackupPath);
|
|
28
|
+
const index = new SessionIndex(opened.db, projectRoot, dataDir, databasePath, opened.recovery);
|
|
29
|
+
try {
|
|
30
|
+
const previousVersion = index.schemaVersion();
|
|
31
|
+
if (databaseExisted && previousVersion < SESSION_INDEX_SCHEMA_VERSION) {
|
|
32
|
+
await index.writeBackup(`${databasePath}.pre-migration-v${previousVersion}.backup`);
|
|
33
|
+
}
|
|
34
|
+
index.initialize();
|
|
35
|
+
await index.writeBackup(recoveryBackupPath);
|
|
36
|
+
return index;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
try {
|
|
40
|
+
index.close();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Preserve the migration or backup failure that prevented startup.
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
20
47
|
}
|
|
21
48
|
initialize() {
|
|
22
|
-
this.db.exec(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
state TEXT NOT NULL,
|
|
49
|
-
phase TEXT NOT NULL,
|
|
50
|
-
summary TEXT NOT NULL,
|
|
51
|
-
status_path TEXT NOT NULL,
|
|
52
|
-
output_log_path TEXT NOT NULL,
|
|
53
|
-
dir TEXT NOT NULL,
|
|
54
|
-
native_session_id TEXT,
|
|
55
|
-
PRIMARY KEY (task_id, worker_id)
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
CREATE TABLE IF NOT EXISTS native_sessions (
|
|
59
|
-
task_id TEXT NOT NULL,
|
|
60
|
-
worker_id TEXT NOT NULL,
|
|
61
|
-
engine TEXT NOT NULL,
|
|
62
|
-
role TEXT NOT NULL,
|
|
63
|
-
session_id TEXT NOT NULL,
|
|
64
|
-
cwd TEXT NOT NULL,
|
|
65
|
-
created_at TEXT NOT NULL,
|
|
66
|
-
last_used_at TEXT NOT NULL,
|
|
67
|
-
source TEXT NOT NULL,
|
|
68
|
-
PRIMARY KEY (task_id, worker_id)
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
CREATE TABLE IF NOT EXISTS workspace_state (
|
|
72
|
-
key TEXT PRIMARY KEY,
|
|
73
|
-
value TEXT NOT NULL
|
|
74
|
-
);
|
|
75
|
-
`);
|
|
76
|
-
this.ensureColumn("tasks", "archived_at", "TEXT");
|
|
49
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
50
|
+
try {
|
|
51
|
+
const currentVersion = this.schemaVersion();
|
|
52
|
+
if (currentVersion > SESSION_INDEX_SCHEMA_VERSION) {
|
|
53
|
+
throw new Error(`Session index schema v${currentVersion} is newer than supported v${SESSION_INDEX_SCHEMA_VERSION}`);
|
|
54
|
+
}
|
|
55
|
+
for (let targetVersion = currentVersion + 1; targetVersion <= SESSION_INDEX_SCHEMA_VERSION; targetVersion += 1) {
|
|
56
|
+
this.applyMigration(targetVersion);
|
|
57
|
+
this.db.exec(`PRAGMA user_version = ${targetVersion}`);
|
|
58
|
+
}
|
|
59
|
+
this.db.exec("COMMIT");
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
try {
|
|
63
|
+
this.db.exec("ROLLBACK");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Preserve the migration failure if SQLite already rolled the transaction back.
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
assertHealthyDatabase(this.db);
|
|
71
|
+
}
|
|
72
|
+
schemaVersion() {
|
|
73
|
+
const row = this.db.prepare("PRAGMA user_version").get();
|
|
74
|
+
return Number(row?.user_version) || 0;
|
|
77
75
|
}
|
|
78
76
|
async upsertTask(task) {
|
|
79
77
|
this.db
|
|
@@ -260,6 +258,7 @@ export class SessionIndex {
|
|
|
260
258
|
}
|
|
261
259
|
throw error;
|
|
262
260
|
}
|
|
261
|
+
await this.writeBackup(`${this.databasePath}.backup`);
|
|
263
262
|
}
|
|
264
263
|
close() {
|
|
265
264
|
this.db.close();
|
|
@@ -277,6 +276,78 @@ export class SessionIndex {
|
|
|
277
276
|
}
|
|
278
277
|
}
|
|
279
278
|
}
|
|
279
|
+
applyMigration(targetVersion) {
|
|
280
|
+
if (targetVersion === 1) {
|
|
281
|
+
this.db.exec(`
|
|
282
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
283
|
+
id TEXT PRIMARY KEY,
|
|
284
|
+
title TEXT NOT NULL,
|
|
285
|
+
created_at TEXT NOT NULL,
|
|
286
|
+
cwd TEXT NOT NULL,
|
|
287
|
+
mode TEXT NOT NULL,
|
|
288
|
+
status TEXT NOT NULL
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
292
|
+
task_id TEXT NOT NULL,
|
|
293
|
+
turn_id TEXT NOT NULL,
|
|
294
|
+
created_at TEXT NOT NULL,
|
|
295
|
+
request_path TEXT NOT NULL,
|
|
296
|
+
PRIMARY KEY (task_id, turn_id)
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
CREATE TABLE IF NOT EXISTS workers (
|
|
300
|
+
task_id TEXT NOT NULL,
|
|
301
|
+
worker_id TEXT NOT NULL,
|
|
302
|
+
role TEXT NOT NULL,
|
|
303
|
+
engine TEXT NOT NULL,
|
|
304
|
+
state TEXT NOT NULL,
|
|
305
|
+
phase TEXT NOT NULL,
|
|
306
|
+
summary TEXT NOT NULL,
|
|
307
|
+
status_path TEXT NOT NULL,
|
|
308
|
+
output_log_path TEXT NOT NULL,
|
|
309
|
+
dir TEXT NOT NULL,
|
|
310
|
+
native_session_id TEXT,
|
|
311
|
+
PRIMARY KEY (task_id, worker_id)
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
CREATE TABLE IF NOT EXISTS native_sessions (
|
|
315
|
+
task_id TEXT NOT NULL,
|
|
316
|
+
worker_id TEXT NOT NULL,
|
|
317
|
+
engine TEXT NOT NULL,
|
|
318
|
+
role TEXT NOT NULL,
|
|
319
|
+
session_id TEXT NOT NULL,
|
|
320
|
+
cwd TEXT NOT NULL,
|
|
321
|
+
created_at TEXT NOT NULL,
|
|
322
|
+
last_used_at TEXT NOT NULL,
|
|
323
|
+
source TEXT NOT NULL,
|
|
324
|
+
PRIMARY KEY (task_id, worker_id)
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
CREATE TABLE IF NOT EXISTS workspace_state (
|
|
328
|
+
key TEXT PRIMARY KEY,
|
|
329
|
+
value TEXT NOT NULL
|
|
330
|
+
);
|
|
331
|
+
`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (targetVersion === 2) {
|
|
335
|
+
this.ensureColumn("tasks", "archived_at", "TEXT");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
throw new Error(`Missing session index migration for schema v${targetVersion}`);
|
|
339
|
+
}
|
|
340
|
+
async writeBackup(targetPath) {
|
|
341
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
342
|
+
try {
|
|
343
|
+
this.db.prepare("VACUUM INTO ?").run(tempPath);
|
|
344
|
+
await replaceFile(tempPath, targetPath);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
280
351
|
tableHasColumn(table, column) {
|
|
281
352
|
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
282
353
|
return columns.some((entry) => entry.name === column);
|
|
@@ -362,6 +433,108 @@ export class SessionIndex {
|
|
|
362
433
|
return retired?.session_id === active.session_id ? null : active;
|
|
363
434
|
}
|
|
364
435
|
}
|
|
436
|
+
async function openSessionDatabase(databasePath, backupPath) {
|
|
437
|
+
let db = null;
|
|
438
|
+
try {
|
|
439
|
+
db = openCheckedDatabase(databasePath);
|
|
440
|
+
return { db, recovery: null };
|
|
441
|
+
}
|
|
442
|
+
catch (primaryError) {
|
|
443
|
+
try {
|
|
444
|
+
db?.close();
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Continue into file-backed recovery.
|
|
448
|
+
}
|
|
449
|
+
const backupIsHealthy = await isHealthyDatabaseFile(backupPath);
|
|
450
|
+
const quarantinedPath = await quarantineDatabase(databasePath);
|
|
451
|
+
await Promise.all([
|
|
452
|
+
rm(`${databasePath}-wal`, { force: true }),
|
|
453
|
+
rm(`${databasePath}-shm`, { force: true })
|
|
454
|
+
]);
|
|
455
|
+
if (backupIsHealthy) {
|
|
456
|
+
await copyFile(backupPath, databasePath);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
return {
|
|
460
|
+
db: openCheckedDatabase(databasePath),
|
|
461
|
+
recovery: {
|
|
462
|
+
source: backupIsHealthy ? "backup" : "empty",
|
|
463
|
+
quarantinedPath
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
catch (recoveryError) {
|
|
468
|
+
throw new AggregateError([primaryError, recoveryError], `Unable to open or recover session index: ${databasePath}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function openCheckedDatabase(path) {
|
|
473
|
+
const db = new DatabaseSync(path);
|
|
474
|
+
try {
|
|
475
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
476
|
+
assertHealthyDatabase(db);
|
|
477
|
+
return db;
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
try {
|
|
481
|
+
db.close();
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// Preserve the database validation failure.
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function assertHealthyDatabase(db) {
|
|
490
|
+
const rows = db.prepare("PRAGMA quick_check").all();
|
|
491
|
+
if (rows.length !== 1
|
|
492
|
+
|| Object.values(rows[0] ?? {}).length !== 1
|
|
493
|
+
|| Object.values(rows[0] ?? {})[0] !== "ok") {
|
|
494
|
+
throw new Error(`Session index integrity check failed: ${JSON.stringify(rows)}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function isHealthyDatabaseFile(path) {
|
|
498
|
+
if (!(await pathExists(path))) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
let db = null;
|
|
502
|
+
try {
|
|
503
|
+
db = new DatabaseSync(path);
|
|
504
|
+
assertHealthyDatabase(db);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
try {
|
|
512
|
+
db?.close();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// A failed validation can leave no open handle to close.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function quarantineDatabase(path) {
|
|
520
|
+
const quarantinedPath = `${path}.corrupt-${Date.now()}-${process.pid}`;
|
|
521
|
+
if (await pathExists(path)) {
|
|
522
|
+
await rename(path, quarantinedPath);
|
|
523
|
+
}
|
|
524
|
+
return quarantinedPath;
|
|
525
|
+
}
|
|
526
|
+
async function replaceFile(source, target) {
|
|
527
|
+
try {
|
|
528
|
+
await rename(source, target);
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
if (error.code !== "EEXIST" && error.code !== "EPERM") {
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
await rm(target, { force: true });
|
|
535
|
+
await rename(source, target);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
365
538
|
async function readJsonIfValid(path, schema) {
|
|
366
539
|
if (!(await pathExists(path))) {
|
|
367
540
|
return null;
|
|
@@ -27,7 +27,7 @@ export class InterruptedTaskRecoveryBlockedError extends Error {
|
|
|
27
27
|
this.name = "InterruptedTaskRecoveryBlockedError";
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
const TERMINAL_TASK_STATES = new Set(["done", "failed", "cancelled"]);
|
|
30
|
+
const TERMINAL_TASK_STATES = new Set(["done", "paused", "failed", "cancelled"]);
|
|
31
31
|
const ACTIVE_WORKER_STATES = new Set(["idle", "starting", "running", "waiting"]);
|
|
32
32
|
const ACTIVE_FEATURE_STATES = new Set([
|
|
33
33
|
"queued",
|
|
@@ -41,6 +41,19 @@ const ACTIVE_FEATURE_STATES = new Set([
|
|
|
41
41
|
]);
|
|
42
42
|
const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
|
|
43
43
|
const PENDING_TASK_CREATION_CLAIM = /^\.(task-.+)\.creating\.json$/;
|
|
44
|
+
const CompletionContractSchema = z.object({
|
|
45
|
+
version: z.literal(1),
|
|
46
|
+
final_judge_required: z.literal(true)
|
|
47
|
+
});
|
|
48
|
+
const FinalAcceptanceEvidenceSchema = z.object({
|
|
49
|
+
decision: z.literal("approved")
|
|
50
|
+
}).passthrough();
|
|
51
|
+
const FinalAcceptanceValidationSchema = z.object({
|
|
52
|
+
version: z.literal(1),
|
|
53
|
+
state: z.literal("valid"),
|
|
54
|
+
decision: z.literal("approved"),
|
|
55
|
+
issues: z.array(z.string()).length(0)
|
|
56
|
+
});
|
|
44
57
|
const TaskCreationOwnerSchema = z.object({
|
|
45
58
|
version: z.literal(1),
|
|
46
59
|
task_id: TaskIdSchema,
|
|
@@ -852,6 +865,17 @@ export class SessionManager {
|
|
|
852
865
|
if (!(await readTextIfExists(join(latestTurn.dir, "supervisor-summary.md"))).trim()) {
|
|
853
866
|
return false;
|
|
854
867
|
}
|
|
868
|
+
const completionContractPath = join(latestTurn.dir, "completion-contract.json");
|
|
869
|
+
if (await pathExists(completionContractPath)) {
|
|
870
|
+
try {
|
|
871
|
+
await readJson(completionContractPath, CompletionContractSchema);
|
|
872
|
+
await readJson(join(latestTurn.dir, "final-acceptance.json"), FinalAcceptanceEvidenceSchema);
|
|
873
|
+
await readJson(join(latestTurn.dir, "final-acceptance-validation.json"), FinalAcceptanceValidationSchema);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
855
879
|
const featuresRoot = join(task.dir, "features");
|
|
856
880
|
if (!(await pathExists(featuresRoot))) {
|
|
857
881
|
return true;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { NativeSessionSchema, RetiredNativeSessionSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
4
|
+
import { pathExists, readJson, readTextIfExists } from "./file-store.js";
|
|
5
|
+
export async function loadTaskSessionDetails(input) {
|
|
6
|
+
const [turns, workers] = await Promise.all([
|
|
7
|
+
readTaskTurns(input.taskDir),
|
|
8
|
+
readTaskWorkers(input.taskDir, input.modelNames ?? {})
|
|
9
|
+
]);
|
|
10
|
+
const turnById = new Map(turns.map((turn) => [turn.turnId, turn]));
|
|
11
|
+
for (const worker of workers) {
|
|
12
|
+
let turn = turnById.get(worker.turnId);
|
|
13
|
+
if (!turn) {
|
|
14
|
+
turn = {
|
|
15
|
+
turnId: worker.turnId,
|
|
16
|
+
createdAt: worker.lastActivityAt,
|
|
17
|
+
request: "",
|
|
18
|
+
workers: []
|
|
19
|
+
};
|
|
20
|
+
turnById.set(worker.turnId, turn);
|
|
21
|
+
turns.push(turn);
|
|
22
|
+
}
|
|
23
|
+
turn.workers.push(worker);
|
|
24
|
+
}
|
|
25
|
+
turns.sort((left, right) => left.turnId.localeCompare(right.turnId));
|
|
26
|
+
for (const turn of turns) {
|
|
27
|
+
turn.workers.sort(compareTaskSessionWorkers);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
task: input.task,
|
|
31
|
+
projectName: basename(input.task.cwd) || input.task.cwd,
|
|
32
|
+
projectPath: input.task.cwd,
|
|
33
|
+
turns,
|
|
34
|
+
workers
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function readTaskTurns(taskDir) {
|
|
38
|
+
const turnsDir = join(taskDir, "turns");
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(turnsDir, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const turns = await Promise.all(entries
|
|
47
|
+
.filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
|
|
48
|
+
.map(async (entry) => {
|
|
49
|
+
const dir = join(turnsDir, entry.name);
|
|
50
|
+
try {
|
|
51
|
+
const meta = await readJson(join(dir, "turn.json"), TurnMetaSchema);
|
|
52
|
+
if (meta.turn_id !== entry.name) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
turnId: meta.turn_id,
|
|
57
|
+
createdAt: meta.created_at,
|
|
58
|
+
request: compactTaskRequest(await readTextIfExists(join(dir, "user.md"))),
|
|
59
|
+
workers: []
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
return turns
|
|
67
|
+
.filter((turn) => turn !== null)
|
|
68
|
+
.sort((left, right) => left.turnId.localeCompare(right.turnId));
|
|
69
|
+
}
|
|
70
|
+
async function readTaskWorkers(taskDir, modelNames) {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(taskDir, { withFileTypes: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const workers = await Promise.all(entries
|
|
79
|
+
.filter((entry) => entry.isDirectory())
|
|
80
|
+
.map(async (entry) => {
|
|
81
|
+
const dir = join(taskDir, entry.name);
|
|
82
|
+
const statusPath = join(dir, "status.json");
|
|
83
|
+
if (!(await pathExists(statusPath))) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const status = await readJson(statusPath, WorkerStatusSchema);
|
|
88
|
+
const nativeSession = await readActiveNativeSession(dir, status.worker_id);
|
|
89
|
+
return {
|
|
90
|
+
id: status.worker_id,
|
|
91
|
+
turnId: taskSessionWorkerTurnId(status.worker_id, status.feature_id),
|
|
92
|
+
...(status.feature_id ? { featureId: status.feature_id } : {}),
|
|
93
|
+
...(status.feature_title ? { featureTitle: status.feature_title } : {}),
|
|
94
|
+
role: status.role,
|
|
95
|
+
engine: status.engine,
|
|
96
|
+
model: status.model_name?.trim() || modelNames[status.engine]?.trim() || "",
|
|
97
|
+
...(status.model_provider?.trim() ? { modelProvider: status.model_provider.trim() } : {}),
|
|
98
|
+
state: status.state,
|
|
99
|
+
phase: status.phase,
|
|
100
|
+
summary: status.summary,
|
|
101
|
+
lastActivityAt: laterTimestamp(status.last_event_at, nativeSession?.lastUsedAt),
|
|
102
|
+
dir,
|
|
103
|
+
statusPath,
|
|
104
|
+
outputLogPath: join(dir, "output.log"),
|
|
105
|
+
nativeSession
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
return workers
|
|
113
|
+
.filter((worker) => worker !== null)
|
|
114
|
+
.sort((left, right) => (left.turnId.localeCompare(right.turnId)
|
|
115
|
+
|| compareTaskSessionWorkers(left, right)));
|
|
116
|
+
}
|
|
117
|
+
async function readActiveNativeSession(workerDir, workerId) {
|
|
118
|
+
const activePath = join(workerDir, "native-session.json");
|
|
119
|
+
if (!(await pathExists(activePath))) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const active = await readJson(activePath, NativeSessionSchema);
|
|
124
|
+
if (active.worker_id !== workerId) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const retiredPath = join(workerDir, "native-session.retired.json");
|
|
128
|
+
if (await pathExists(retiredPath)) {
|
|
129
|
+
try {
|
|
130
|
+
const retired = await readJson(retiredPath, RetiredNativeSessionSchema);
|
|
131
|
+
if (retired.session_id === active.session_id) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// A malformed retirement record cannot hide a valid active session.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
sessionId: active.session_id,
|
|
141
|
+
cwd: active.cwd,
|
|
142
|
+
writableDirs: active.writable_dirs ?? [],
|
|
143
|
+
createdAt: active.created_at,
|
|
144
|
+
lastUsedAt: active.last_used_at,
|
|
145
|
+
source: active.source
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function taskSessionWorkerTurnId(workerId, featureId) {
|
|
153
|
+
const featureTurn = featureId?.match(/^(\d{4})(?:-|$)/)?.[1];
|
|
154
|
+
const waveTurn = workerId.match(/-wave-(\d{4})-/)?.[1];
|
|
155
|
+
const finalTurn = workerId.match(/-final-(\d{4})$/)?.[1];
|
|
156
|
+
const taskTurn = workerId.match(/-(\d{4})$/)?.[1];
|
|
157
|
+
return featureTurn ?? waveTurn ?? finalTurn ?? taskTurn ?? "0001";
|
|
158
|
+
}
|
|
159
|
+
function compareTaskSessionWorkers(left, right) {
|
|
160
|
+
return taskSessionWorkerStage(left) - taskSessionWorkerStage(right)
|
|
161
|
+
|| left.id.localeCompare(right.id);
|
|
162
|
+
}
|
|
163
|
+
function taskSessionWorkerStage(worker) {
|
|
164
|
+
if (worker.role === "judge" && /-final-\d{4}$/.test(worker.id)) {
|
|
165
|
+
return 4;
|
|
166
|
+
}
|
|
167
|
+
return ["main", "judge", "actor", "critic"].indexOf(worker.role);
|
|
168
|
+
}
|
|
169
|
+
function compactTaskRequest(request) {
|
|
170
|
+
const value = request.replace(/\s+/g, " ").trim();
|
|
171
|
+
return value.length > 160 ? `${value.slice(0, 157)}...` : value;
|
|
172
|
+
}
|
|
173
|
+
function laterTimestamp(left, right) {
|
|
174
|
+
return right && right.localeCompare(left) > 0 ? right : left;
|
|
175
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
const TASK_STATE_TRANSITIONS = {
|
|
2
|
-
created: new Set(["routed", "failed", "cancelled"]),
|
|
3
|
-
routed: new Set(["judging", "failed", "cancelled"]),
|
|
4
|
-
judging: new Set(["ready_for_pair", "failed", "cancelled"]),
|
|
5
|
-
ready_for_pair: new Set(["actor_running", "done", "failed", "cancelled"]),
|
|
6
|
-
actor_running: new Set(["critic_running", "verifying", "failed", "cancelled"]),
|
|
7
|
-
critic_running: new Set(["revision_needed", "integrating", "failed", "cancelled"]),
|
|
8
|
-
revision_needed: new Set(["actor_running", "failed", "cancelled"]),
|
|
9
|
-
integrating: new Set(["actor_running", "verifying", "done", "failed", "cancelled"]),
|
|
10
|
-
verifying: new Set(["revision_needed", "integrating", "failed", "cancelled"]),
|
|
2
|
+
created: new Set(["routed", "paused", "failed", "cancelled"]),
|
|
3
|
+
routed: new Set(["judging", "paused", "failed", "cancelled"]),
|
|
4
|
+
judging: new Set(["ready_for_pair", "paused", "failed", "cancelled"]),
|
|
5
|
+
ready_for_pair: new Set(["actor_running", "paused", "done", "failed", "cancelled"]),
|
|
6
|
+
actor_running: new Set(["critic_running", "verifying", "paused", "failed", "cancelled"]),
|
|
7
|
+
critic_running: new Set(["revision_needed", "integrating", "paused", "failed", "cancelled"]),
|
|
8
|
+
revision_needed: new Set(["actor_running", "paused", "failed", "cancelled"]),
|
|
9
|
+
integrating: new Set(["actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
|
|
10
|
+
verifying: new Set(["revision_needed", "integrating", "paused", "done", "failed", "cancelled"]),
|
|
11
|
+
paused: new Set(["routed", "judging", "ready_for_pair", "failed", "cancelled"]),
|
|
11
12
|
done: new Set(["routed", "cancelled"]),
|
|
12
13
|
failed: new Set(["routed", "judging", "ready_for_pair"]),
|
|
13
14
|
cancelled: new Set(["routed", "judging", "ready_for_pair"])
|