parallel-codex-tui 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.parallel-codex/config.example.toml +136 -3
  2. package/README.md +299 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-preflight.js +18 -0
  7. package/dist/cli-startup-recovery.js +82 -0
  8. package/dist/cli-workspace-picker.js +330 -0
  9. package/dist/cli-workspace-transition.js +33 -0
  10. package/dist/cli-workspace.js +7 -71
  11. package/dist/cli.js +234 -24
  12. package/dist/core/collaboration-timeline.js +268 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +297 -109
  15. package/dist/core/file-store.js +119 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +7 -0
  18. package/dist/core/process-identity.js +48 -0
  19. package/dist/core/process-mutation-turn.js +128 -0
  20. package/dist/core/process-ownership.js +276 -0
  21. package/dist/core/process-tree.js +90 -0
  22. package/dist/core/router-audit.js +155 -0
  23. package/dist/core/router-redaction.js +31 -0
  24. package/dist/core/router.js +462 -35
  25. package/dist/core/session-index.js +412 -88
  26. package/dist/core/session-manager.js +1110 -40
  27. package/dist/core/task-session-details.js +175 -0
  28. package/dist/core/task-state-machine.js +18 -0
  29. package/dist/core/workspace-commit-recovery.js +118 -0
  30. package/dist/core/workspace.js +19 -11
  31. package/dist/doctor.js +373 -34
  32. package/dist/domain/schemas.js +142 -7
  33. package/dist/orchestrator/collaboration-channel.js +289 -6
  34. package/dist/orchestrator/feature-plan.js +70 -0
  35. package/dist/orchestrator/final-acceptance.js +86 -0
  36. package/dist/orchestrator/judge-artifacts.js +236 -0
  37. package/dist/orchestrator/orchestrator.js +2086 -203
  38. package/dist/orchestrator/prompts.js +168 -5
  39. package/dist/orchestrator/supervisor-summary.js +56 -2
  40. package/dist/orchestrator/workspace-sandbox.js +927 -0
  41. package/dist/tui/App.js +3187 -161
  42. package/dist/tui/AppShell.js +196 -25
  43. package/dist/tui/CollaborationTimelineView.js +327 -0
  44. package/dist/tui/FeatureBoardView.js +232 -0
  45. package/dist/tui/InputBar.js +581 -57
  46. package/dist/tui/RouterDiagnosticsView.js +469 -0
  47. package/dist/tui/StatusBar.js +610 -57
  48. package/dist/tui/StatusDetailView.js +164 -0
  49. package/dist/tui/TaskSessionDetailView.js +222 -0
  50. package/dist/tui/TaskSessionsView.js +210 -0
  51. package/dist/tui/TerminalOutput.js +53 -9
  52. package/dist/tui/WorkerOutputView.js +1404 -161
  53. package/dist/tui/WorkerOverviewView.js +269 -0
  54. package/dist/tui/chat-history.js +25 -0
  55. package/dist/tui/chat-input.js +67 -19
  56. package/dist/tui/chat-paste.js +76 -0
  57. package/dist/tui/display-width.js +41 -3
  58. package/dist/tui/incremental-text-file.js +101 -0
  59. package/dist/tui/keyboard.js +49 -0
  60. package/dist/tui/markdown-text.js +14 -0
  61. package/dist/tui/raw-input-decoder.js +3 -0
  62. package/dist/tui/scrolling.js +2 -1
  63. package/dist/tui/status-line.js +360 -11
  64. package/dist/tui/task-memory.js +13 -1
  65. package/dist/tui/task-result.js +105 -0
  66. package/dist/tui/terminal-screen.js +13 -1
  67. package/dist/tui/theme-contrast.js +144 -0
  68. package/dist/tui/theme-preview.js +109 -0
  69. package/dist/tui/theme.js +158 -0
  70. package/dist/version.js +1 -1
  71. package/dist/workers/capabilities.js +213 -0
  72. package/dist/workers/live-probe.js +177 -0
  73. package/dist/workers/mock-adapter.js +73 -8
  74. package/dist/workers/native-attach.js +106 -16
  75. package/dist/workers/process-adapter.js +572 -77
  76. package/dist/workers/provider.js +26 -0
  77. package/dist/workers/registry.js +12 -20
  78. package/package.json +11 -2
@@ -1,26 +1,86 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { cp, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { basename, dirname, join } from "node:path";
3
- import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
4
- import { formatTaskTimestamp, taskDir } from "./paths.js";
4
+ import { z } from "zod";
5
+ import { appendJsonLine, appendText, ensureDir, pathExists, readJson, readRecentJsonLines, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
6
+ import { runWithLeaseFinalization } from "./lease-finalization.js";
7
+ import { formatTaskTimestamp, taskDir, taskSessionIdIsValid } from "./paths.js";
5
8
  import { sessionsRoot } from "./paths.js";
6
- import { NativeSessionSchema, RouteDecisionSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
9
+ import { loadCollaborationTimeline } from "./collaboration-timeline.js";
10
+ import { taskStateTransitionAllowed } from "./task-state-machine.js";
11
+ import { processIsAlive, readProcessStartToken } from "./process-identity.js";
12
+ import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
13
+ import { ChatRecordSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
14
+ export class InterruptedTaskRecoveryBlockedError extends Error {
15
+ taskId;
16
+ blocks;
17
+ constructor(taskId, blocks, subject = `task ${taskId}`) {
18
+ const details = blocks
19
+ .map((block) => `${block.workerId} (${block.reason}; ${block.processPath})`)
20
+ .join(", ");
21
+ const stateLabel = subject === `task ${taskId}` ? "Task" : subject;
22
+ super(`Startup recovery blocked for ${subject}: ${details}. `
23
+ + `${stateLabel} state and checkpoints were left unchanged to prevent concurrent workers. `
24
+ + "Verify or stop each recorded process, then restart parallel-codex-tui.");
25
+ this.taskId = taskId;
26
+ this.blocks = blocks;
27
+ this.name = "InterruptedTaskRecoveryBlockedError";
28
+ }
29
+ }
30
+ const TERMINAL_TASK_STATES = new Set(["done", "paused", "failed", "cancelled"]);
31
+ const ACTIVE_WORKER_STATES = new Set(["idle", "starting", "running", "waiting"]);
32
+ const ACTIVE_FEATURE_STATES = new Set([
33
+ "queued",
34
+ "actor_running",
35
+ "actor_done",
36
+ "critic_running",
37
+ "critic_done",
38
+ "revision_needed",
39
+ "integrating",
40
+ "verifying"
41
+ ]);
42
+ const PENDING_TURN_DIRECTORY = /^\.turn-(\d{4})-.+\.pending$/;
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
+ });
57
+ const TaskCreationOwnerSchema = z.object({
58
+ version: z.literal(1),
59
+ task_id: TaskIdSchema,
60
+ pid: z.number().int().positive(),
61
+ started_at: z.string().datetime(),
62
+ process_start_token: z.string().min(1).optional()
63
+ });
7
64
  export class SessionManager {
8
65
  projectRoot;
9
66
  dataDir;
10
67
  now;
11
68
  randomId;
12
69
  index;
70
+ claimTaskRunLease;
13
71
  constructor(options) {
14
72
  this.projectRoot = options.projectRoot;
15
73
  this.dataDir = options.dataDir;
16
74
  this.now = options.now ?? (() => new Date());
17
75
  this.randomId = options.randomId ?? (() => Math.random().toString(16).slice(2, 6));
18
76
  this.index = options.index;
77
+ this.claimTaskRunLease = options.claimTaskRunLease ?? claimTaskRunLease;
19
78
  }
20
- async createTask(input) {
79
+ async createTask(input, options = {}) {
21
80
  const createdAt = this.now();
22
- const id = `task-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
23
- const dir = taskDir(this.projectRoot, this.dataDir, id);
81
+ const baseId = `task-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
82
+ const creation = await this.claimUniqueTaskDirectory(baseId, createdAt);
83
+ const { taskId: id, stagingDir, finalDir } = creation;
24
84
  const meta = {
25
85
  id,
26
86
  title: titleFromRequest(input.request),
@@ -29,24 +89,131 @@ export class SessionManager {
29
89
  mode: input.route.mode,
30
90
  status: "created"
31
91
  };
32
- await ensureDir(dir);
33
- await writeJson(join(dir, "meta.json"), TaskMetaSchema.parse(meta));
34
- await writeText(join(dir, "user-request.md"), `${input.request.trim()}\n`);
35
- await this.index?.upsertTask(meta);
36
- await writeJson(join(dir, "route.json"), RouteDecisionSchema.parse(input.route));
37
- await this.writeTurn({ id, dir }, "0001", input.request, input.route, createdAt);
38
- await this.appendEvent({ id, dir }, "task.created", "Task session created");
39
- return {
92
+ const stagingTask = {
40
93
  id,
41
- dir,
42
- metaPath: join(dir, "meta.json"),
43
- routePath: join(dir, "route.json"),
44
- eventsPath: join(dir, "events.jsonl")
94
+ dir: stagingDir,
95
+ metaPath: join(stagingDir, "meta.json"),
96
+ routePath: join(stagingDir, "route.json"),
97
+ eventsPath: join(stagingDir, "events.jsonl")
45
98
  };
99
+ const task = this.taskFromId(id);
100
+ let published = false;
101
+ try {
102
+ await writeJson(stagingTask.metaPath, TaskMetaSchema.parse(meta));
103
+ await writeText(join(stagingDir, "user-request.md"), `${input.request.trim()}\n`);
104
+ await writeJson(stagingTask.routePath, RouteDecisionSchema.parse(input.route));
105
+ const turn = await this.writeTurn(stagingTask, "0001", input.request, input.route, createdAt, false);
106
+ await this.appendEvent(stagingTask, "task.created", "Task session created");
107
+ await rename(stagingDir, finalDir);
108
+ published = true;
109
+ await this.updateTaskStatus(task, "routed");
110
+ await this.index?.upsertTurn(task.id, await readJson(join(task.dir, "turns", turn.turnId, "turn.json"), TurnMetaSchema));
111
+ await this.index?.setActiveTaskId(id);
112
+ if (!options.retainCreationClaim) {
113
+ await removeIfExists(creation.claimPath);
114
+ }
115
+ return task;
116
+ }
117
+ catch (error) {
118
+ try {
119
+ if (!published) {
120
+ await rm(stagingDir, { recursive: true, force: true });
121
+ }
122
+ await removeIfExists(creation.claimPath);
123
+ }
124
+ catch {
125
+ // Startup reconciliation can finish cleanup when immediate cleanup is unavailable.
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+ async releaseTaskCreationClaim(task) {
131
+ await removeIfExists(this.taskCreationClaimPath(task.id));
132
+ }
133
+ async claimUniqueTaskDirectory(baseId, createdAt) {
134
+ const root = sessionsRoot(this.projectRoot, this.dataDir);
135
+ await ensureDir(root);
136
+ const processStartToken = await readProcessStartToken(process.pid);
137
+ for (let attempt = 1;; attempt += 1) {
138
+ const id = attempt === 1 ? baseId : `${baseId}-${String(attempt).padStart(4, "0")}`;
139
+ const claimPath = this.taskCreationClaimPath(id);
140
+ const stagingDir = join(root, `.${id}.creating`);
141
+ const finalDir = taskDir(this.projectRoot, this.dataDir, id);
142
+ const owner = TaskCreationOwnerSchema.parse({
143
+ version: 1,
144
+ task_id: id,
145
+ pid: process.pid,
146
+ started_at: createdAt.toISOString(),
147
+ ...(processStartToken ? { process_start_token: processStartToken } : {})
148
+ });
149
+ if (!(await writeJsonExclusive(claimPath, owner))) {
150
+ continue;
151
+ }
152
+ try {
153
+ if (await pathExists(finalDir)) {
154
+ await removeIfExists(claimPath);
155
+ continue;
156
+ }
157
+ await mkdir(stagingDir);
158
+ return { taskId: id, stagingDir, finalDir, claimPath };
159
+ }
160
+ catch (error) {
161
+ await removeIfExists(claimPath);
162
+ if (error.code === "EEXIST") {
163
+ continue;
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+ }
169
+ taskCreationClaimPath(taskId) {
170
+ return join(sessionsRoot(this.projectRoot, this.dataDir), `.${taskId}.creating.json`);
46
171
  }
47
172
  mainSessionDir() {
48
173
  return join(this.projectRoot, this.dataDir, "sessions", "main");
49
174
  }
175
+ async reconcileInterruptedMainSession() {
176
+ const main = this.taskFromId("main");
177
+ if (!(await pathExists(main.dir))) {
178
+ return null;
179
+ }
180
+ let recoveryLease;
181
+ try {
182
+ recoveryLease = await this.claimTaskRunLease(main.dir);
183
+ }
184
+ catch (error) {
185
+ if (error instanceof TaskRunLeaseConflictError) {
186
+ return null;
187
+ }
188
+ throw error;
189
+ }
190
+ return runWithLeaseFinalization("Main session startup recovery", recoveryLease, async () => {
191
+ const workers = await this.reconcileTaskWorkers(main, "Main session");
192
+ if (workers.recovered === 0 && workers.terminated === 0) {
193
+ return null;
194
+ }
195
+ await this.appendEvent(main, "main.recovered_after_restart", `Recovered interrupted Main session; ${workers.recovered} active workers marked cancelled and ${workers.terminated} processes terminated`);
196
+ return {
197
+ workersRecovered: workers.recovered,
198
+ processesTerminated: workers.terminated
199
+ };
200
+ });
201
+ }
202
+ async appendChatMessage(input) {
203
+ const record = ChatRecordSchema.parse({
204
+ time: this.now().toISOString(),
205
+ from: input.from,
206
+ text: input.text,
207
+ task_id: input.taskId
208
+ });
209
+ await appendJsonLine(join(this.mainSessionDir(), "chat.jsonl"), record);
210
+ }
211
+ async readChatHistory(limit = 200) {
212
+ const boundedLimit = Number.isFinite(limit)
213
+ ? Math.min(1000, Math.max(0, Math.trunc(limit)))
214
+ : 200;
215
+ return readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, boundedLimit);
216
+ }
50
217
  taskFromId(taskId) {
51
218
  const dir = taskDir(this.projectRoot, this.dataDir, taskId);
52
219
  return {
@@ -57,9 +224,259 @@ export class SessionManager {
57
224
  eventsPath: join(dir, "events.jsonl")
58
225
  };
59
226
  }
227
+ async readCollaborationTimeline(taskId) {
228
+ const task = this.taskFromId(taskId);
229
+ if (!(await this.hasTask(taskId))) {
230
+ throw new Error(`Task session not found: ${taskId}`);
231
+ }
232
+ return loadCollaborationTimeline(taskId, task.dir);
233
+ }
60
234
  async hasTask(taskId) {
235
+ if (!taskSessionIdIsValid(taskId) || taskId === "main") {
236
+ return false;
237
+ }
61
238
  const task = this.taskFromId(taskId);
62
- return Boolean(await readTaskMetaIfValid(task.metaPath));
239
+ const meta = await readTaskMetaIfValid(task.metaPath);
240
+ return meta?.id === taskId;
241
+ }
242
+ async renameTask(taskId, title) {
243
+ const normalizedTitle = normalizeTaskTitle(title);
244
+ return this.withTaskManagementLease(taskId, "rename", async (task, meta) => {
245
+ const next = TaskMetaSchema.parse({ ...meta, title: normalizedTitle });
246
+ await writeJson(task.metaPath, next);
247
+ await this.index?.upsertTask(next);
248
+ await this.appendEvent(task, "task.renamed", `Task renamed to ${normalizedTitle}`);
249
+ return next;
250
+ });
251
+ }
252
+ async setTaskArchived(taskId, archived) {
253
+ return this.withTaskManagementLease(taskId, archived ? "archive" : "unarchive", async (task, meta) => {
254
+ this.assertTerminalTask(meta, archived ? "archive" : "unarchive");
255
+ if (archived && await this.index?.activeTaskId() === taskId) {
256
+ throw new Error(`Cannot archive active task ${taskId}. Start a new task first.`);
257
+ }
258
+ const { archived_at: _archivedAt, ...withoutArchive } = meta;
259
+ const next = TaskMetaSchema.parse(archived
260
+ ? { ...withoutArchive, archived_at: this.now().toISOString() }
261
+ : withoutArchive);
262
+ await writeJson(task.metaPath, next);
263
+ await this.index?.upsertTask(next);
264
+ await this.appendEvent(task, archived ? "task.archived" : "task.unarchived", archived ? "Task session archived" : "Task session restored from archive");
265
+ return next;
266
+ });
267
+ }
268
+ async deleteTask(taskId) {
269
+ if (await this.index?.activeTaskId() === taskId) {
270
+ throw new Error(`Cannot delete active task ${taskId}. Start a new task first.`);
271
+ }
272
+ const deletedDir = await this.withTaskManagementLease(taskId, "delete", async (task, meta) => {
273
+ this.assertTerminalTask(meta, "delete");
274
+ if (await this.index?.activeTaskId() === taskId) {
275
+ throw new Error(`Cannot delete active task ${taskId}. Start a new task first.`);
276
+ }
277
+ const target = join(sessionsRoot(this.projectRoot, this.dataDir), `.${taskId}.deleted-${randomUUID()}`);
278
+ await rename(task.dir, target);
279
+ try {
280
+ await this.index?.deleteTask(taskId);
281
+ }
282
+ catch (error) {
283
+ try {
284
+ await rename(target, task.dir);
285
+ }
286
+ catch (rollbackError) {
287
+ throw new Error(`Task index deletion failed and session rollback also failed: ${errorMessage(error)}; ${errorMessage(rollbackError)}`, { cause: new AggregateError([error, rollbackError]) });
288
+ }
289
+ throw error;
290
+ }
291
+ return target;
292
+ });
293
+ await rm(deletedDir, { force: true, recursive: true });
294
+ }
295
+ async exportTask(taskId) {
296
+ return this.withTaskManagementLease(taskId, "export", async (task, meta) => {
297
+ this.assertTerminalTask(meta, "export");
298
+ const createdAt = this.now().toISOString();
299
+ const exportsRoot = join(this.projectRoot, this.dataDir, "exports");
300
+ await ensureDir(exportsRoot);
301
+ const staging = await mkdtemp(join(exportsRoot, `.${taskId}-`));
302
+ const suffix = basename(staging).slice(-6);
303
+ const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
304
+ const destination = join(exportsRoot, `${taskId}-${stamp}-${suffix}`);
305
+ try {
306
+ await this.appendEvent(task, "task.exported", "Task session exported");
307
+ await writeJson(join(staging, "manifest.json"), {
308
+ format: "parallel-codex-task-export-v1",
309
+ exported_at: createdAt,
310
+ source_workspace: this.projectRoot,
311
+ session_path: "session",
312
+ task: await this.readMeta(task)
313
+ });
314
+ await cp(task.dir, join(staging, "session"), {
315
+ recursive: true,
316
+ preserveTimestamps: true,
317
+ verbatimSymlinks: true,
318
+ filter: (source) => !isTransientTaskLeasePath(task.dir, source)
319
+ });
320
+ await rename(staging, destination);
321
+ }
322
+ catch (error) {
323
+ await rm(staging, { force: true, recursive: true });
324
+ throw error;
325
+ }
326
+ return { taskId, path: destination, createdAt };
327
+ });
328
+ }
329
+ async reconcilePendingTaskCreations() {
330
+ const report = {
331
+ published: 0,
332
+ abandoned: 0,
333
+ active: 0,
334
+ publishedTaskIds: []
335
+ };
336
+ for (const pending of await this.pendingTaskCreationDirectories()) {
337
+ const owner = await readTaskCreationOwnerIfValid(pending.claimPath);
338
+ if (owner?.task_id === pending.taskId && await taskCreationOwnerIsActive(owner)) {
339
+ report.active += 1;
340
+ continue;
341
+ }
342
+ const finalTask = this.taskFromId(pending.taskId);
343
+ const finalMeta = await readTaskMetaIfValid(finalTask.metaPath);
344
+ if (finalMeta) {
345
+ await this.projectPublishedTaskCreation(pending.taskId, finalMeta);
346
+ if (await pathExists(pending.stagingDir)) {
347
+ await this.quarantinePendingTaskCreation(pending);
348
+ report.abandoned += 1;
349
+ }
350
+ else {
351
+ report.published += 1;
352
+ report.publishedTaskIds.push(pending.taskId);
353
+ }
354
+ await removeIfExists(pending.claimPath);
355
+ continue;
356
+ }
357
+ const snapshot = await this.readCompletePendingTaskCreation(pending);
358
+ if (snapshot) {
359
+ if (!(await pathExists(pending.finalDir))) {
360
+ try {
361
+ await rename(pending.stagingDir, pending.finalDir);
362
+ }
363
+ catch (error) {
364
+ const code = error.code;
365
+ if (code !== "ENOENT" && code !== "EEXIST") {
366
+ throw error;
367
+ }
368
+ }
369
+ }
370
+ const publishedMeta = await readTaskMetaIfValid(finalTask.metaPath);
371
+ if (publishedMeta) {
372
+ await removeIfExists(pending.claimPath);
373
+ await this.projectPublishedTaskCreation(pending.taskId, publishedMeta);
374
+ report.published += 1;
375
+ report.publishedTaskIds.push(pending.taskId);
376
+ continue;
377
+ }
378
+ }
379
+ const racedMeta = await readTaskMetaIfValid(finalTask.metaPath);
380
+ if (racedMeta) {
381
+ await removeIfExists(pending.claimPath);
382
+ await this.projectPublishedTaskCreation(pending.taskId, racedMeta);
383
+ report.published += 1;
384
+ report.publishedTaskIds.push(pending.taskId);
385
+ continue;
386
+ }
387
+ let archived = false;
388
+ if (await pathExists(pending.stagingDir)) {
389
+ archived = await this.quarantinePendingTaskCreation(pending);
390
+ }
391
+ if (!archived) {
392
+ const concurrentlyPublishedMeta = await readTaskMetaIfValid(finalTask.metaPath);
393
+ if (concurrentlyPublishedMeta) {
394
+ await removeIfExists(pending.claimPath);
395
+ await this.projectPublishedTaskCreation(pending.taskId, concurrentlyPublishedMeta);
396
+ report.published += 1;
397
+ report.publishedTaskIds.push(pending.taskId);
398
+ continue;
399
+ }
400
+ }
401
+ await removeIfExists(pending.claimPath);
402
+ report.abandoned += 1;
403
+ }
404
+ return report;
405
+ }
406
+ async reconcileInterruptedTasks() {
407
+ const root = sessionsRoot(this.projectRoot, this.dataDir);
408
+ if (!(await pathExists(root))) {
409
+ return [];
410
+ }
411
+ const entries = await readdir(root, { withFileTypes: true });
412
+ const recovered = [];
413
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
414
+ if (!entry.isDirectory() || !TaskIdSchema.safeParse(entry.name).success) {
415
+ continue;
416
+ }
417
+ const task = this.taskFromId(entry.name);
418
+ if (await this.taskCreationClaimIsActive(task.id)) {
419
+ continue;
420
+ }
421
+ const meta = await readTaskMetaIfValid(task.metaPath);
422
+ if (!meta || meta.id !== task.id) {
423
+ continue;
424
+ }
425
+ const needsTaskRecovery = await this.taskNeedsRecovery(task, meta);
426
+ const needsTransitionRepair = await this.taskStatusTransitionNeedsRepair(task, meta);
427
+ const needsTurnReconciliation = (await this.pendingTurnDirectories(task)).length > 0;
428
+ if (!needsTaskRecovery && !needsTransitionRepair && !needsTurnReconciliation) {
429
+ continue;
430
+ }
431
+ let recoveryLease;
432
+ try {
433
+ recoveryLease = await this.claimTaskRunLease(task.dir);
434
+ }
435
+ catch (error) {
436
+ if (error instanceof TaskRunLeaseConflictError) {
437
+ continue;
438
+ }
439
+ throw error;
440
+ }
441
+ const recovery = await runWithLeaseFinalization(`Task ${task.id} startup recovery`, recoveryLease, async () => {
442
+ const claimedMeta = await readTaskMetaIfValid(task.metaPath);
443
+ if (!claimedMeta) {
444
+ return null;
445
+ }
446
+ const turns = await this.reconcilePendingTurns(task);
447
+ const claimedNeedsTaskRecovery = await this.taskNeedsRecovery(task, claimedMeta);
448
+ const claimedNeedsTransitionRepair = await this.taskStatusTransitionNeedsRepair(task, claimedMeta);
449
+ if (!claimedNeedsTaskRecovery && !claimedNeedsTransitionRepair) {
450
+ return null;
451
+ }
452
+ if (claimedNeedsTransitionRepair) {
453
+ await this.syncTaskStatusTransition(task, claimedMeta);
454
+ }
455
+ if (!claimedNeedsTaskRecovery) {
456
+ return null;
457
+ }
458
+ const workers = await this.reconcileTaskWorkers(task);
459
+ const featuresRecovered = await this.reconcileTaskFeatures(task);
460
+ await this.updateTaskStatus(task, "cancelled");
461
+ await this.appendEvent(task, claimedMeta.status === "done" ? "task.recovered_incomplete_done" : "task.recovered_after_restart", claimedMeta.status === "done"
462
+ ? `Recovered incomplete done task; ${workers.recovered} active workers and ${featuresRecovered} active features marked cancelled, checkpoints preserved`
463
+ : `Recovered interrupted task from ${claimedMeta.status}; ${workers.recovered} active workers and ${featuresRecovered} active features marked cancelled, checkpoints preserved`);
464
+ return {
465
+ taskId: task.id,
466
+ previousState: claimedMeta.status,
467
+ workersRecovered: workers.recovered,
468
+ featuresRecovered,
469
+ processesTerminated: workers.terminated,
470
+ ...(turns.published > 0 ? { turnsPublished: turns.published } : {}),
471
+ ...(turns.repaired > 0 ? { turnsRepaired: turns.repaired } : {}),
472
+ ...(turns.abandoned > 0 ? { turnsAbandoned: turns.abandoned } : {})
473
+ };
474
+ });
475
+ if (recovery) {
476
+ recovered.push(recovery);
477
+ }
478
+ }
479
+ return recovered;
63
480
  }
64
481
  async latestTask() {
65
482
  const root = sessionsRoot(this.projectRoot, this.dataDir);
@@ -69,7 +486,7 @@ export class SessionManager {
69
486
  const entries = await readdir(root, { withFileTypes: true });
70
487
  const tasks = [];
71
488
  for (const entry of entries) {
72
- if (!entry.isDirectory() || !entry.name.startsWith("task-")) {
489
+ if (!entry.isDirectory() || !TaskIdSchema.safeParse(entry.name).success) {
73
490
  continue;
74
491
  }
75
492
  const metaPath = join(root, entry.name, "meta.json");
@@ -78,12 +495,13 @@ export class SessionManager {
78
495
  if (!meta) {
79
496
  continue;
80
497
  }
81
- if (meta.mode === "complex") {
498
+ if (meta.id === entry.name && meta.mode === "complex" && !meta.archived_at) {
82
499
  tasks.push(meta);
83
500
  }
84
501
  }
85
502
  }
86
- const latest = tasks.sort((left, right) => left.created_at.localeCompare(right.created_at)).at(-1);
503
+ const latest = tasks.sort((left, right) => (left.created_at.localeCompare(right.created_at)
504
+ || left.id.localeCompare(right.id))).at(-1);
87
505
  if (!latest) {
88
506
  return null;
89
507
  }
@@ -102,6 +520,9 @@ export class SessionManager {
102
520
  const turnId = await this.nextTurnId(task);
103
521
  const turn = await this.writeTurn(task, turnId, input.request, input.route, this.now());
104
522
  await this.appendEvent(task, "turn.created", `Turn ${turnId} created`);
523
+ if (await readTaskMetaIfValid(task.metaPath)) {
524
+ await this.updateTaskStatus(task, "routed");
525
+ }
105
526
  return turn;
106
527
  }
107
528
  async latestTurn(task) {
@@ -120,14 +541,59 @@ export class SessionManager {
120
541
  }
121
542
  return this.turnFiles(task, turnId);
122
543
  }
544
+ async readLatestRoute(task) {
545
+ const latestTaskRoute = await readRouteDecisionIfValid(join(task.dir, "latest-route.json"));
546
+ if (latestTaskRoute) {
547
+ return latestTaskRoute;
548
+ }
549
+ const latestTurn = await this.latestTurn(task);
550
+ if (latestTurn) {
551
+ const latestRoute = await readRouteDecisionIfValid(latestTurn.routePath);
552
+ if (latestRoute) {
553
+ return latestRoute;
554
+ }
555
+ }
556
+ return readRouteDecisionIfValid(task.routePath);
557
+ }
558
+ async recordLatestRoute(task, route) {
559
+ await writeJson(join(task.dir, "latest-route.json"), RouteDecisionSchema.parse(route));
560
+ }
123
561
  async readMeta(task) {
124
562
  return readJson(task.metaPath, TaskMetaSchema);
125
563
  }
126
564
  async updateTaskStatus(task, status) {
127
565
  const meta = await this.readMeta(task);
128
- await writeJson(task.metaPath, TaskMetaSchema.parse({ ...meta, status }));
129
- await this.index?.upsertTask({ ...meta, status });
130
- await this.appendEvent(task, `task.${status}`, `Task moved to ${status}`);
566
+ if (meta.status !== status && await this.taskStatusTransitionNeedsRepair(task, meta)) {
567
+ await this.syncTaskStatusTransition(task, meta);
568
+ }
569
+ if (meta.status === status) {
570
+ await this.syncTaskStatusTransition(task, meta);
571
+ return;
572
+ }
573
+ const completeEvidence = status === "done" || meta.status === "done"
574
+ ? await this.hasCompleteTaskEvidence(task)
575
+ : false;
576
+ if (meta.status === "done" && completeEvidence) {
577
+ throw new Error(`Task ${task.id} is completely done and cannot move backward to ${status}.`);
578
+ }
579
+ if (!taskStateTransitionAllowed(meta.status, status)) {
580
+ throw new Error(`Task ${task.id} cannot move from ${meta.status} to ${status}.`);
581
+ }
582
+ if (status === "done" && !completeEvidence) {
583
+ throw new Error(`Task ${task.id} cannot move to done before latest-turn completion evidence is published.`);
584
+ }
585
+ const nextMeta = TaskMetaSchema.parse({
586
+ ...meta,
587
+ status,
588
+ status_transition: {
589
+ id: randomUUID(),
590
+ from: meta.status,
591
+ to: status,
592
+ at: this.now().toISOString()
593
+ }
594
+ });
595
+ await writeJson(task.metaPath, nextMeta);
596
+ await this.syncTaskStatusTransition(task, nextMeta);
131
597
  }
132
598
  async initializeWorker(task, input) {
133
599
  const dir = join(task.dir, input.workerId);
@@ -136,6 +602,8 @@ export class SessionManager {
136
602
  const statusPath = join(dir, "status.json");
137
603
  const status = {
138
604
  worker_id: input.workerId,
605
+ ...(input.featureId ? { feature_id: input.featureId } : {}),
606
+ ...(input.featureTitle ? { feature_title: input.featureTitle } : {}),
139
607
  role: input.role,
140
608
  engine: input.engine,
141
609
  state: "idle",
@@ -146,7 +614,13 @@ export class SessionManager {
146
614
  await ensureDir(dir);
147
615
  await this.clearWorkerArtifacts(dir, input.role);
148
616
  await writeText(promptPath, input.prompt);
149
- await writeText(outputLogPath, "");
617
+ if (await pathExists(outputLogPath)) {
618
+ const continuation = input.preserveOutput ? "retry" : "resume";
619
+ await appendText(outputLogPath, `\n--- ${continuation} ${this.now().toISOString()} ---\n`);
620
+ }
621
+ else {
622
+ await writeText(outputLogPath, "");
623
+ }
150
624
  await writeJson(statusPath, status);
151
625
  await this.index?.upsertWorker(task.id, status, {
152
626
  dir,
@@ -163,11 +637,16 @@ export class SessionManager {
163
637
  }
164
638
  async readNativeSession(worker) {
165
639
  const path = join(worker.dir, "native-session.json");
640
+ const retired = await readRetiredNativeSessionIfValid(join(worker.dir, "native-session.retired.json"));
166
641
  if (!(await pathExists(path))) {
642
+ if (retired) {
643
+ await this.finalizeNativeSessionRetirement(worker, retired.session_id);
644
+ }
167
645
  return null;
168
646
  }
647
+ let active;
169
648
  try {
170
- return await readJson(path, NativeSessionSchema);
649
+ active = await readJson(path, NativeSessionSchema);
171
650
  }
172
651
  catch {
173
652
  await removeIfExists(path);
@@ -175,10 +654,72 @@ export class SessionManager {
175
654
  await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
176
655
  return null;
177
656
  }
657
+ if (retired?.session_id === active.session_id) {
658
+ await this.finalizeNativeSessionRetirement(worker, active.session_id);
659
+ return null;
660
+ }
661
+ await this.syncNativeSessionProjection(worker, active);
662
+ return active;
663
+ }
664
+ async hasRetiredNativeSession(worker) {
665
+ return Boolean(await readRetiredNativeSessionIfValid(join(worker.dir, "native-session.retired.json")));
666
+ }
667
+ async reconcileNativeSessionState() {
668
+ const root = sessionsRoot(this.projectRoot, this.dataDir);
669
+ if (!(await pathExists(root))) {
670
+ return 0;
671
+ }
672
+ let reconciled = 0;
673
+ const sessionEntries = await readdir(root, { withFileTypes: true });
674
+ for (const sessionEntry of sessionEntries) {
675
+ if (!sessionEntry.isDirectory()
676
+ || (sessionEntry.name !== "main" && !TaskIdSchema.safeParse(sessionEntry.name).success)) {
677
+ continue;
678
+ }
679
+ const sessionDir = join(root, sessionEntry.name);
680
+ const lease = await inspectTaskRunLease(sessionDir);
681
+ if (lease.state === "active") {
682
+ continue;
683
+ }
684
+ const workerEntries = await readdir(sessionDir, { withFileTypes: true });
685
+ for (const workerEntry of workerEntries) {
686
+ if (!workerEntry.isDirectory()) {
687
+ continue;
688
+ }
689
+ const worker = { dir: join(sessionDir, workerEntry.name) };
690
+ const activePath = join(worker.dir, "native-session.json");
691
+ const retiredPath = join(worker.dir, "native-session.retired.json");
692
+ const statusPath = join(worker.dir, "status.json");
693
+ const [hasActive, hasRetired, hasStatus] = await Promise.all([
694
+ pathExists(activePath),
695
+ pathExists(retiredPath),
696
+ pathExists(statusPath)
697
+ ]);
698
+ if (!hasActive && !hasRetired && !hasStatus) {
699
+ continue;
700
+ }
701
+ const retired = await readRetiredNativeSessionIfValid(retiredPath);
702
+ const active = await readNativeSessionIfValid(activePath);
703
+ if (retired && (!active || active.session_id === retired.session_id)) {
704
+ await this.finalizeNativeSessionRetirement(worker, retired.session_id);
705
+ reconciled += 1;
706
+ continue;
707
+ }
708
+ if (active) {
709
+ await this.syncNativeSessionProjection(worker, active);
710
+ continue;
711
+ }
712
+ await removeIfExists(activePath);
713
+ await this.clearWorkerStatusNativeSession(worker);
714
+ await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
715
+ }
716
+ }
717
+ return reconciled;
178
718
  }
179
719
  async writeNativeSession(worker, record) {
180
- await writeJson(join(worker.dir, "native-session.json"), NativeSessionSchema.parse(record));
181
- await this.index?.upsertNativeSession(this.taskIdFromWorkerDir(worker.dir), record);
720
+ const active = NativeSessionSchema.parse(record);
721
+ await writeJson(join(worker.dir, "native-session.json"), active);
722
+ await this.syncNativeSessionProjection(worker, active);
182
723
  }
183
724
  async retireNativeSession(worker, reason) {
184
725
  const nativeSessionPath = join(worker.dir, "native-session.json");
@@ -210,6 +751,60 @@ export class SessionManager {
210
751
  };
211
752
  await appendJsonLine(join(task.dir, "events.jsonl"), event);
212
753
  }
754
+ async withTaskManagementLease(taskId, operation, run) {
755
+ const id = TaskIdSchema.parse(taskId);
756
+ const task = this.taskFromId(id);
757
+ if (!(await this.hasTask(id))) {
758
+ throw new Error(`Task session not found: ${id}`);
759
+ }
760
+ const lease = await this.claimTaskRunLease(task.dir);
761
+ return runWithLeaseFinalization(`Task ${operation}`, lease, async () => {
762
+ const meta = await this.readMeta(task);
763
+ if (meta.id !== id) {
764
+ throw new Error(`Task session metadata does not match ${id}.`);
765
+ }
766
+ return run(task, meta);
767
+ });
768
+ }
769
+ assertTerminalTask(meta, operation) {
770
+ if (!TERMINAL_TASK_STATES.has(meta.status)) {
771
+ throw new Error(`Cannot ${operation} task ${meta.id} while it is ${meta.status}.`);
772
+ }
773
+ }
774
+ async syncTaskStatusTransition(task, meta) {
775
+ const transition = meta.status_transition;
776
+ if (transition && !(await this.hasTaskStatusTransitionEvent(task, transition.id))) {
777
+ const event = EventRecordSchema.parse({
778
+ time: transition.at,
779
+ type: `task.${transition.to}`,
780
+ message: `Task moved from ${transition.from} to ${transition.to}`,
781
+ task_id: task.id,
782
+ transition_id: transition.id,
783
+ from_state: transition.from,
784
+ to_state: transition.to
785
+ });
786
+ await appendJsonLine(task.eventsPath, event);
787
+ }
788
+ await this.index?.upsertTask(meta);
789
+ }
790
+ async hasTaskStatusTransitionEvent(task, transitionId) {
791
+ const events = await readTextIfExists(task.eventsPath);
792
+ for (const line of events.split(/\r?\n/)) {
793
+ if (!line.trim()) {
794
+ continue;
795
+ }
796
+ try {
797
+ const event = EventRecordSchema.safeParse(JSON.parse(line));
798
+ if (event.success && event.data.transition_id === transitionId) {
799
+ return true;
800
+ }
801
+ }
802
+ catch {
803
+ // A corrupt audit row does not invalidate later transition evidence.
804
+ }
805
+ }
806
+ return false;
807
+ }
213
808
  async nextTurnId(task) {
214
809
  const latest = await this.latestTurn(task);
215
810
  if (!latest) {
@@ -217,15 +812,348 @@ export class SessionManager {
217
812
  }
218
813
  return String(Number(latest.turnId) + 1).padStart(4, "0");
219
814
  }
815
+ async taskNeedsRecovery(task, meta) {
816
+ if (!TERMINAL_TASK_STATES.has(meta.status)) {
817
+ return true;
818
+ }
819
+ if (meta.status !== "done" || await this.hasCompleteTaskEvidence(task)) {
820
+ return false;
821
+ }
822
+ const latestTurn = await this.latestTurn(task);
823
+ return Boolean(latestTurn
824
+ && (latestTurn.turnId !== "0001"
825
+ || await this.hasIntegratedLatestTurnCheckpoint(task)));
826
+ }
827
+ async taskStatusTransitionNeedsRepair(task, meta) {
828
+ const transitionId = meta.status_transition?.id;
829
+ return Boolean(transitionId && !(await this.hasTaskStatusTransitionEvent(task, transitionId)));
830
+ }
831
+ async hasIntegratedLatestTurnCheckpoint(task) {
832
+ const latestTurn = await this.latestTurn(task);
833
+ if (!latestTurn) {
834
+ return false;
835
+ }
836
+ const root = join(task.dir, "workspaces", `turn-${latestTurn.turnId}`);
837
+ if (!(await pathExists(root))) {
838
+ return false;
839
+ }
840
+ const entries = await readdir(root, { withFileTypes: true });
841
+ for (const entry of entries) {
842
+ if (!entry.isDirectory() || !/^wave-\d{4}$/.test(entry.name)) {
843
+ continue;
844
+ }
845
+ try {
846
+ const value = JSON.parse(await readTextIfExists(join(root, entry.name, "integration.json")));
847
+ if (value
848
+ && typeof value === "object"
849
+ && !Array.isArray(value)
850
+ && value.state === "integrated") {
851
+ return true;
852
+ }
853
+ }
854
+ catch {
855
+ // A corrupt integration record is not proof that live commit completed.
856
+ }
857
+ }
858
+ return false;
859
+ }
860
+ async hasCompleteTaskEvidence(task) {
861
+ const latestTurn = await this.latestTurn(task);
862
+ if (!latestTurn) {
863
+ return false;
864
+ }
865
+ if (!(await readTextIfExists(join(latestTurn.dir, "supervisor-summary.md"))).trim()) {
866
+ return false;
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
+ }
879
+ const featuresRoot = join(task.dir, "features");
880
+ if (!(await pathExists(featuresRoot))) {
881
+ return true;
882
+ }
883
+ const entries = await readdir(featuresRoot, { withFileTypes: true });
884
+ for (const entry of entries) {
885
+ if (!entry.isDirectory()) {
886
+ continue;
887
+ }
888
+ const belongsToLatestTurn = entry.name === latestTurn.turnId
889
+ || entry.name.startsWith(`${latestTurn.turnId}-`);
890
+ const statusPath = join(featuresRoot, entry.name, "status.json");
891
+ try {
892
+ const status = await readJson(statusPath, FeatureStatusSchema);
893
+ const statusIsLatestTurn = status.turn_id === latestTurn.turnId;
894
+ if (belongsToLatestTurn !== statusIsLatestTurn) {
895
+ return false;
896
+ }
897
+ if (statusIsLatestTurn
898
+ && (status.feature_id !== entry.name
899
+ || status.task_id !== task.id
900
+ || status.state !== "approved")) {
901
+ return false;
902
+ }
903
+ }
904
+ catch {
905
+ if (belongsToLatestTurn) {
906
+ return false;
907
+ }
908
+ }
909
+ }
910
+ return true;
911
+ }
912
+ async reconcileTaskWorkers(task, recoverySubject = `task ${task.id}`) {
913
+ const entries = await readdir(task.dir, { withFileTypes: true });
914
+ const workerDirs = entries
915
+ .filter((entry) => entry.isDirectory())
916
+ .map((entry) => ({ workerId: entry.name, dir: join(task.dir, entry.name) }));
917
+ let terminated = 0;
918
+ const blocks = [];
919
+ for (const worker of workerDirs) {
920
+ const dir = worker.dir;
921
+ const processResult = await terminateOwnedWorkerProcess(dir);
922
+ if (processResult === "terminated") {
923
+ terminated += 1;
924
+ }
925
+ if (processResult === "unverifiable" || processResult === "still-running") {
926
+ blocks.push({
927
+ workerId: worker.workerId,
928
+ processPath: join(dir, "process.json"),
929
+ reason: processResult
930
+ });
931
+ }
932
+ }
933
+ if (blocks.length > 0) {
934
+ await this.appendEvent(task, task.id === "main" ? "main.recovery_blocked" : "task.recovery_blocked", `Startup recovery blocked by ${blocks.map((block) => `${block.workerId}:${block.reason}`).join(", ")}; ${recoverySubject} state left unchanged`);
935
+ throw new InterruptedTaskRecoveryBlockedError(task.id, blocks, recoverySubject);
936
+ }
937
+ let recovered = 0;
938
+ for (const worker of workerDirs) {
939
+ const dir = worker.dir;
940
+ const statusPath = join(dir, "status.json");
941
+ const status = await readWorkerStatusIfValid(statusPath);
942
+ if (!status || !ACTIVE_WORKER_STATES.has(status.state)) {
943
+ continue;
944
+ }
945
+ const nextStatus = WorkerStatusSchema.parse({
946
+ ...status,
947
+ state: "cancelled",
948
+ phase: "orphaned-after-restart",
949
+ last_event_at: this.now().toISOString(),
950
+ summary: "Previous TUI exited before this worker finished; checkpoint is ready to retry"
951
+ });
952
+ await writeJson(statusPath, nextStatus);
953
+ const outputLogPath = join(dir, "output.log");
954
+ await appendText(outputLogPath, "\nRecovered after previous TUI exit; worker marked cancelled for checkpoint retry.\n");
955
+ await this.index?.upsertWorker(task.id, nextStatus, { dir, statusPath, outputLogPath });
956
+ recovered += 1;
957
+ }
958
+ return { recovered, terminated };
959
+ }
960
+ async reconcileTaskFeatures(task) {
961
+ const root = join(task.dir, "features");
962
+ if (!(await pathExists(root))) {
963
+ return 0;
964
+ }
965
+ const entries = await readdir(root, { withFileTypes: true });
966
+ let recovered = 0;
967
+ for (const entry of entries) {
968
+ if (!entry.isDirectory()) {
969
+ continue;
970
+ }
971
+ const statusPath = join(root, entry.name, "status.json");
972
+ if (!(await pathExists(statusPath))) {
973
+ continue;
974
+ }
975
+ try {
976
+ const status = await readJson(statusPath, FeatureStatusSchema);
977
+ if (!ACTIVE_FEATURE_STATES.has(status.state)) {
978
+ continue;
979
+ }
980
+ await writeJson(statusPath, FeatureStatusSchema.parse({
981
+ ...status,
982
+ state: "cancelled",
983
+ updated_at: this.now().toISOString()
984
+ }));
985
+ recovered += 1;
986
+ }
987
+ catch {
988
+ // Corrupt feature evidence must not prevent other task checkpoints from being recovered.
989
+ }
990
+ }
991
+ return recovered;
992
+ }
220
993
  async indexTaskFromFiles(task) {
221
994
  if (!this.index || !(await pathExists(task.metaPath))) {
222
995
  return;
223
996
  }
224
997
  const meta = await readTaskMetaIfValid(task.metaPath);
225
- if (meta) {
998
+ if (meta?.id === task.id) {
226
999
  await this.index.upsertTask(meta);
227
1000
  }
228
1001
  }
1002
+ async pendingTaskCreationDirectories() {
1003
+ const root = sessionsRoot(this.projectRoot, this.dataDir);
1004
+ if (!(await pathExists(root))) {
1005
+ return [];
1006
+ }
1007
+ const entries = await readdir(root, { withFileTypes: true });
1008
+ return entries.flatMap((entry) => {
1009
+ const match = entry.isFile() ? entry.name.match(PENDING_TASK_CREATION_CLAIM) : null;
1010
+ if (!match?.[1]) {
1011
+ return [];
1012
+ }
1013
+ const taskId = match[1];
1014
+ if (!TaskIdSchema.safeParse(taskId).success) {
1015
+ return [];
1016
+ }
1017
+ return [{
1018
+ taskId,
1019
+ stagingDir: join(root, `.${taskId}.creating`),
1020
+ finalDir: taskDir(this.projectRoot, this.dataDir, taskId),
1021
+ claimPath: join(root, entry.name)
1022
+ }];
1023
+ }).sort((left, right) => left.taskId.localeCompare(right.taskId));
1024
+ }
1025
+ async taskCreationClaimIsActive(taskId) {
1026
+ const owner = await readTaskCreationOwnerIfValid(this.taskCreationClaimPath(taskId));
1027
+ return Boolean(owner?.task_id === taskId && await taskCreationOwnerIsActive(owner));
1028
+ }
1029
+ async readCompletePendingTaskCreation(pending) {
1030
+ return this.readCompleteTaskCreation(pending.stagingDir, pending.taskId);
1031
+ }
1032
+ async readCompleteTaskCreation(directory, taskId) {
1033
+ try {
1034
+ const firstTurnDir = join(directory, "turns", "0001");
1035
+ const [meta, route, request, turn, turnRoute, turnRequest] = await Promise.all([
1036
+ readTaskMetaIfValid(join(directory, "meta.json")),
1037
+ readRouteDecisionIfValid(join(directory, "route.json")),
1038
+ readTextIfExists(join(directory, "user-request.md")),
1039
+ readTurnMetaIfValid(join(firstTurnDir, "turn.json")),
1040
+ readRouteDecisionIfValid(join(firstTurnDir, "route.json")),
1041
+ readTextIfExists(join(firstTurnDir, "user.md"))
1042
+ ]);
1043
+ if (!meta
1044
+ || !route
1045
+ || !turn
1046
+ || !turnRoute
1047
+ || !request.trim()
1048
+ || request.trim() !== turnRequest.trim()
1049
+ || meta.id !== taskId
1050
+ || meta.mode !== route.mode
1051
+ || turn.task_id !== taskId
1052
+ || turn.turn_id !== "0001"
1053
+ || turn.request_path !== "turns/0001/user.md"
1054
+ || turnRoute.mode !== route.mode) {
1055
+ return null;
1056
+ }
1057
+ return { meta, turn };
1058
+ }
1059
+ catch {
1060
+ return null;
1061
+ }
1062
+ }
1063
+ async projectPublishedTaskCreation(taskId, meta) {
1064
+ if (!this.index) {
1065
+ return;
1066
+ }
1067
+ await this.index.upsertTask(meta);
1068
+ const snapshot = await this.readCompleteTaskCreation(this.taskFromId(taskId).dir, taskId);
1069
+ if (snapshot) {
1070
+ await this.index.upsertTurn(taskId, snapshot.turn);
1071
+ }
1072
+ }
1073
+ async quarantinePendingTaskCreation(pending) {
1074
+ const root = join(sessionsRoot(this.projectRoot, this.dataDir), ".abandoned");
1075
+ await ensureDir(root);
1076
+ const name = basename(pending.stagingDir);
1077
+ const preferred = join(root, name);
1078
+ const destination = await pathExists(preferred)
1079
+ ? join(root, `${name}.${randomUUID()}`)
1080
+ : preferred;
1081
+ try {
1082
+ await rename(pending.stagingDir, destination);
1083
+ return true;
1084
+ }
1085
+ catch (error) {
1086
+ if (error.code === "ENOENT") {
1087
+ return false;
1088
+ }
1089
+ throw error;
1090
+ }
1091
+ }
1092
+ async pendingTurnDirectories(task) {
1093
+ const root = join(task.dir, "turns");
1094
+ if (!(await pathExists(root))) {
1095
+ return [];
1096
+ }
1097
+ const entries = await readdir(root, { withFileTypes: true });
1098
+ return entries.flatMap((entry) => {
1099
+ const match = entry.isDirectory() ? entry.name.match(PENDING_TURN_DIRECTORY) : null;
1100
+ return match?.[1]
1101
+ ? [{ name: entry.name, turnId: match[1], dir: join(root, entry.name) }]
1102
+ : [];
1103
+ }).sort((left, right) => left.name.localeCompare(right.name));
1104
+ }
1105
+ async reconcilePendingTurns(task) {
1106
+ const summary = { published: 0, repaired: 0, abandoned: 0 };
1107
+ for (const pending of await this.pendingTurnDirectories(task)) {
1108
+ const files = this.turnFiles(task, pending.turnId);
1109
+ if (await pathExists(files.dir)) {
1110
+ await this.quarantinePendingTurn(task, pending);
1111
+ await this.appendEvent(task, "turn.pending_abandoned", `Archived pending turn ${pending.turnId} because the committed turn already exists`);
1112
+ summary.abandoned += 1;
1113
+ continue;
1114
+ }
1115
+ const request = (await readTextIfExists(join(pending.dir, "user.md"))).trim();
1116
+ const pendingRoute = await readRouteDecisionIfValid(join(pending.dir, "route.json"));
1117
+ const pendingMeta = await readTurnMetaIfValid(join(pending.dir, "turn.json"));
1118
+ const metaMatches = Boolean(pendingMeta
1119
+ && pendingMeta.task_id === task.id
1120
+ && pendingMeta.turn_id === pending.turnId
1121
+ && pendingMeta.request_path === `turns/${pending.turnId}/user.md`);
1122
+ if (request && pendingRoute && pendingMeta && metaMatches) {
1123
+ await rename(pending.dir, files.dir);
1124
+ await this.index?.upsertTurn(task.id, pendingMeta);
1125
+ await this.appendEvent(task, "turn.recovered_after_restart", `Published complete pending turn ${pending.turnId} after restart`);
1126
+ summary.published += 1;
1127
+ continue;
1128
+ }
1129
+ const fallbackRoute = pendingRoute
1130
+ ?? await readRouteDecisionIfValid(join(task.dir, "latest-route.json"))
1131
+ ?? await readRouteDecisionIfValid(task.routePath);
1132
+ if (request && fallbackRoute) {
1133
+ const createdAt = pendingMeta && metaMatches
1134
+ ? new Date(pendingMeta.created_at)
1135
+ : this.now();
1136
+ await this.quarantinePendingTurn(task, pending);
1137
+ await this.writeTurn(task, pending.turnId, request, fallbackRoute, createdAt);
1138
+ await this.appendEvent(task, "turn.repaired_after_restart", `Rebuilt partial pending turn ${pending.turnId} from its durable request and route evidence`);
1139
+ summary.repaired += 1;
1140
+ continue;
1141
+ }
1142
+ await this.quarantinePendingTurn(task, pending);
1143
+ await this.appendEvent(task, "turn.pending_abandoned", `Archived incomplete pending turn ${pending.turnId}; no durable request and route pair was available`);
1144
+ summary.abandoned += 1;
1145
+ }
1146
+ return summary;
1147
+ }
1148
+ async quarantinePendingTurn(task, pending) {
1149
+ const root = join(task.dir, "turns", ".abandoned");
1150
+ await ensureDir(root);
1151
+ const preferred = join(root, pending.name);
1152
+ const destination = await pathExists(preferred)
1153
+ ? join(root, `${pending.name}.${randomUUID()}`)
1154
+ : preferred;
1155
+ await rename(pending.dir, destination);
1156
+ }
229
1157
  async backfillInitialTurn(task, fallbackRoute) {
230
1158
  const firstTurn = this.turnFiles(task, "0001");
231
1159
  if (await pathExists(firstTurn.metaPath)) {
@@ -243,23 +1171,44 @@ export class SessionManager {
243
1171
  const meta = await readTaskMetaIfValid(task.metaPath);
244
1172
  await this.writeTurn(task, "0001", request, route, meta ? new Date(meta.created_at) : this.now());
245
1173
  }
246
- async writeTurn(task, turnId, request, route, createdAt) {
1174
+ async writeTurn(task, turnId, request, route, createdAt, projectIndex = true) {
247
1175
  const files = this.turnFiles(task, turnId);
248
- const turnMeta = {
1176
+ const turnMeta = TurnMetaSchema.parse({
249
1177
  task_id: task.id,
250
1178
  turn_id: turnId,
251
1179
  created_at: createdAt.toISOString(),
252
1180
  request_path: `turns/${turnId}/user.md`
253
- };
254
- await ensureDir(files.dir);
255
- await writeText(files.userPath, `${request.trim()}\n`);
256
- await writeJson(files.routePath, RouteDecisionSchema.parse(route));
257
- await writeJson(files.metaPath, TurnMetaSchema.parse(turnMeta));
258
- await this.index?.upsertTurn(task.id, turnMeta);
1181
+ });
1182
+ const parsedRoute = RouteDecisionSchema.parse(route);
1183
+ if (await pathExists(files.dir)) {
1184
+ throw new Error(`Turn ${turnId} already exists for task ${task.id}.`);
1185
+ }
1186
+ const pendingDir = join(task.dir, "turns", `.turn-${turnId}-${randomUUID()}.pending`);
1187
+ const pendingFiles = this.turnFilesAtDir(turnId, pendingDir);
1188
+ let published = false;
1189
+ try {
1190
+ await ensureDir(pendingDir);
1191
+ await writeText(pendingFiles.userPath, `${request.trim()}\n`);
1192
+ await writeJson(pendingFiles.routePath, parsedRoute);
1193
+ await writeJson(pendingFiles.metaPath, turnMeta);
1194
+ await rename(pendingDir, files.dir);
1195
+ published = true;
1196
+ }
1197
+ finally {
1198
+ if (!published) {
1199
+ await rm(pendingDir, { recursive: true, force: true });
1200
+ }
1201
+ }
1202
+ if (projectIndex) {
1203
+ await this.index?.upsertTurn(task.id, turnMeta);
1204
+ }
259
1205
  return files;
260
1206
  }
261
1207
  turnFiles(task, turnId) {
262
1208
  const dir = join(task.dir, "turns", turnId);
1209
+ return this.turnFilesAtDir(turnId, dir);
1210
+ }
1211
+ turnFilesAtDir(turnId, dir) {
263
1212
  return {
264
1213
  turnId,
265
1214
  dir,
@@ -274,7 +1223,34 @@ export class SessionManager {
274
1223
  workerIdFromWorkerDir(workerDir) {
275
1224
  return basename(workerDir);
276
1225
  }
277
- async clearWorkerStatusNativeSession(worker) {
1226
+ async finalizeNativeSessionRetirement(worker, sessionId) {
1227
+ await removeIfExists(join(worker.dir, "native-session.json"));
1228
+ await this.clearWorkerStatusNativeSession(worker, sessionId);
1229
+ await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
1230
+ }
1231
+ async syncNativeSessionProjection(worker, record) {
1232
+ await this.setWorkerStatusNativeSession(worker, record.session_id);
1233
+ await this.index?.upsertNativeSession(this.taskIdFromWorkerDir(worker.dir), record);
1234
+ }
1235
+ async setWorkerStatusNativeSession(worker, sessionId) {
1236
+ const statusPath = join(worker.dir, "status.json");
1237
+ const status = await readWorkerStatusIfValid(statusPath);
1238
+ if (!status) {
1239
+ return;
1240
+ }
1241
+ const nextStatus = status.native_session_id === sessionId
1242
+ ? status
1243
+ : WorkerStatusSchema.parse({ ...status, native_session_id: sessionId });
1244
+ if (nextStatus !== status) {
1245
+ await writeJson(statusPath, nextStatus);
1246
+ }
1247
+ await this.index?.upsertWorker(this.taskIdFromWorkerDir(worker.dir), nextStatus, {
1248
+ dir: worker.dir,
1249
+ statusPath,
1250
+ outputLogPath: join(worker.dir, "output.log")
1251
+ });
1252
+ }
1253
+ async clearWorkerStatusNativeSession(worker, expectedSessionId) {
278
1254
  const statusPath = join(worker.dir, "status.json");
279
1255
  if (!(await pathExists(statusPath))) {
280
1256
  return;
@@ -286,6 +1262,9 @@ export class SessionManager {
286
1262
  if (!status.native_session_id) {
287
1263
  return;
288
1264
  }
1265
+ if (expectedSessionId && status.native_session_id !== expectedSessionId) {
1266
+ return;
1267
+ }
289
1268
  const nextStatus = { ...status };
290
1269
  delete nextStatus.native_session_id;
291
1270
  await writeJson(statusPath, WorkerStatusSchema.parse(nextStatus));
@@ -296,6 +1275,19 @@ export class SessionManager {
296
1275
  });
297
1276
  }
298
1277
  async clearWorkerArtifacts(dir, role) {
1278
+ if (role === "judge") {
1279
+ for (const file of [
1280
+ "requirements.md",
1281
+ "plan.md",
1282
+ "acceptance.md",
1283
+ "actor-brief.md",
1284
+ "critic-brief.md",
1285
+ "features.json"
1286
+ ]) {
1287
+ await removeIfExists(join(dir, file));
1288
+ }
1289
+ return;
1290
+ }
299
1291
  const files = role === "actor"
300
1292
  ? ["worklog.md", "patch.diff"]
301
1293
  : role === "critic"
@@ -310,6 +1302,30 @@ function titleFromRequest(request) {
310
1302
  const firstLine = request.trim().split("\n")[0] ?? "Untitled task";
311
1303
  return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
312
1304
  }
1305
+ function normalizeTaskTitle(title) {
1306
+ const normalized = title
1307
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
1308
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1309
+ .replace(/\s+/g, " ")
1310
+ .trim();
1311
+ if (!normalized) {
1312
+ throw new Error("Task title cannot be empty.");
1313
+ }
1314
+ if (Array.from(normalized).length > 160) {
1315
+ throw new Error("Task title cannot exceed 160 characters.");
1316
+ }
1317
+ return normalized;
1318
+ }
1319
+ function isTransientTaskLeasePath(taskDir, source) {
1320
+ if (dirname(source) !== taskDir) {
1321
+ return false;
1322
+ }
1323
+ const name = basename(source);
1324
+ return name === "run-owner.json" || name.startsWith(".run-owner-claim-");
1325
+ }
1326
+ function errorMessage(error) {
1327
+ return error instanceof Error ? error.message : String(error);
1328
+ }
313
1329
  async function readTaskMetaIfValid(metaPath) {
314
1330
  if (!(await pathExists(metaPath))) {
315
1331
  return null;
@@ -321,6 +1337,38 @@ async function readTaskMetaIfValid(metaPath) {
321
1337
  return null;
322
1338
  }
323
1339
  }
1340
+ async function readTaskCreationOwnerIfValid(path) {
1341
+ if (!(await pathExists(path))) {
1342
+ return null;
1343
+ }
1344
+ try {
1345
+ return await readJson(path, TaskCreationOwnerSchema);
1346
+ }
1347
+ catch {
1348
+ return null;
1349
+ }
1350
+ }
1351
+ async function taskCreationOwnerIsActive(owner) {
1352
+ if (!processIsAlive(owner.pid)) {
1353
+ return false;
1354
+ }
1355
+ if (!owner.process_start_token) {
1356
+ return true;
1357
+ }
1358
+ return (await readProcessStartToken(owner.pid)) === owner.process_start_token;
1359
+ }
1360
+ async function writeJsonExclusive(path, value) {
1361
+ try {
1362
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
1363
+ return true;
1364
+ }
1365
+ catch (error) {
1366
+ if (error.code === "EEXIST") {
1367
+ return false;
1368
+ }
1369
+ throw error;
1370
+ }
1371
+ }
324
1372
  async function readRouteDecisionIfValid(routePath) {
325
1373
  if (!(await pathExists(routePath))) {
326
1374
  return null;
@@ -332,6 +1380,17 @@ async function readRouteDecisionIfValid(routePath) {
332
1380
  return null;
333
1381
  }
334
1382
  }
1383
+ async function readTurnMetaIfValid(metaPath) {
1384
+ if (!(await pathExists(metaPath))) {
1385
+ return null;
1386
+ }
1387
+ try {
1388
+ return await readJson(metaPath, TurnMetaSchema);
1389
+ }
1390
+ catch {
1391
+ return null;
1392
+ }
1393
+ }
335
1394
  async function readNativeSessionIfValid(nativeSessionPath) {
336
1395
  if (!(await pathExists(nativeSessionPath))) {
337
1396
  return null;
@@ -343,6 +1402,17 @@ async function readNativeSessionIfValid(nativeSessionPath) {
343
1402
  return null;
344
1403
  }
345
1404
  }
1405
+ async function readRetiredNativeSessionIfValid(retiredSessionPath) {
1406
+ if (!(await pathExists(retiredSessionPath))) {
1407
+ return null;
1408
+ }
1409
+ try {
1410
+ return await readJson(retiredSessionPath, RetiredNativeSessionSchema);
1411
+ }
1412
+ catch {
1413
+ return null;
1414
+ }
1415
+ }
346
1416
  async function readWorkerStatusIfValid(statusPath) {
347
1417
  if (!(await pathExists(statusPath))) {
348
1418
  return null;