@longtable/cli 0.1.57 → 0.1.59

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.
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { appendFile, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  function nowIso() {
6
6
  return new Date().toISOString();
@@ -26,6 +26,30 @@ function appendDiagnostic(existing, diagnostic) {
26
26
  function panelRunsDirectory(workingDirectory) {
27
27
  return join(workingDirectory, ".longtable", "panel-runs");
28
28
  }
29
+ function safeName(value) {
30
+ return value.replace(/[^A-Za-z0-9._/-]/g, "-").replace(/\/+/g, "/").slice(0, 180);
31
+ }
32
+ function workerSafeName(value) {
33
+ return value.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80);
34
+ }
35
+ function gitOutput(workingDirectory, args) {
36
+ return execFileSync("git", args, { cwd: workingDirectory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
37
+ }
38
+ function currentGitCommit(workingDirectory) {
39
+ try {
40
+ return gitOutput(workingDirectory, ["rev-parse", "HEAD"]);
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ async function appendLifecycleEvent(run, event) {
47
+ if (!run.eventLogPath) {
48
+ return;
49
+ }
50
+ await mkdir(dirname(run.eventLogPath), { recursive: true });
51
+ await appendFile(run.eventLogPath, `${JSON.stringify({ id: createId("event"), createdAt: nowIso(), ...event })}\n`, "utf8");
52
+ }
29
53
  export function panelWorkerRunDirectory(workingDirectory, runId) {
30
54
  return join(panelRunsDirectory(workingDirectory), runId);
31
55
  }
@@ -38,16 +62,41 @@ async function writeJsonAtomic(path, value) {
38
62
  await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
39
63
  await rename(tempPath, path);
40
64
  }
41
- function workerTaskPrompt(fallback, worker) {
65
+ function workerRuntimeDirectory(worker) {
66
+ return join(worker.worktreePath ?? dirname(worker.resultPath), ".longtable-worker");
67
+ }
68
+ function workerRuntimePaths(worktreePath) {
69
+ const workerDirectory = join(worktreePath, ".longtable-worker");
70
+ return {
71
+ taskPath: join(workerDirectory, "task.md"),
72
+ resultPath: join(workerDirectory, "result.json"),
73
+ logPath: join(workerDirectory, "worker.log"),
74
+ launcherPath: join(workerDirectory, "launch.sh"),
75
+ exitCodePath: join(workerDirectory, "result.exit.json"),
76
+ mailboxPath: join(workerDirectory, "mailbox.jsonl"),
77
+ taskStatePath: join(workerDirectory, "state.json")
78
+ };
79
+ }
80
+ function shouldNormalizeWorkerRuntimePaths(run, worker) {
81
+ if (worker.status === "completed" || worker.status === "blocked" || worker.status === "running") {
82
+ return false;
83
+ }
84
+ return run.status === "planned" ||
85
+ run.status === "failed" ||
86
+ run.status === "stopped" ||
87
+ run.status === "stop_requested" ||
88
+ run.status === "resumable";
89
+ }
90
+ function workerTaskPrompt(run, worker) {
42
91
  return [
43
92
  "LongTable native panel worker",
44
93
  "",
45
94
  `Role: ${worker.label} (${worker.role})`,
46
- `Invocation: ${fallback.invocationRecord.id}`,
47
- `Panel plan: ${fallback.plan.id}`,
95
+ `Invocation: ${run.invocationId}`,
96
+ `Panel plan: ${run.planId}`,
48
97
  "",
49
98
  "Instructions:",
50
- "- Work read-only unless the researcher explicitly asked for drafting.",
99
+ "- Work inside the assigned writable worker worktree; do not mutate the leader checkout directly.",
51
100
  "- Do not expose or persist hidden reasoning, private tool traces, or chain-of-thought.",
52
101
  "- Persist only the structured final role output to the result path below.",
53
102
  "- Return JSON matching this shape:",
@@ -55,9 +104,12 @@ function workerTaskPrompt(fallback, worker) {
55
104
  "",
56
105
  `Result path: ${worker.resultPath}`,
57
106
  `Log path: ${worker.logPath}`,
107
+ worker.worktreePath ? `Writable worker worktree: ${worker.worktreePath}` : "",
108
+ worker.mailboxPath ? `Worker mailbox: ${worker.mailboxPath}` : "",
109
+ worker.taskStatePath ? `Worker task lifecycle state: ${worker.taskStatePath}` : "",
58
110
  "",
59
111
  "Research object:",
60
- fallback.plan.prompt
112
+ run.prompt
61
113
  ].join("\n");
62
114
  }
63
115
  const PANEL_WORKER_OUTPUT_SCHEMA = {
@@ -80,18 +132,22 @@ function launcherScript(run, worker) {
80
132
  const role = shellQuote(worker.role);
81
133
  const label = shellQuote(worker.label);
82
134
  const stdoutPath = `${worker.resultPath}.stdout`;
135
+ const worktreePath = worker.worktreePath ?? run.workingDirectory;
136
+ const commitPath = `${worker.resultPath}.commit`;
83
137
  return [
84
138
  "#!/usr/bin/env bash",
85
139
  "set +e",
86
140
  `printf 'started_at=%s\\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" > ${shellQuote(worker.logPath)}`,
141
+ `mkdir -p ${shellQuote(join(worktreePath, ".longtable-worker"))}`,
142
+ `printf '{"workerId":%s,"runId":%s,"startedAt":"%s"}\\n' ${shellQuote(JSON.stringify(worker.id))} ${shellQuote(JSON.stringify(run.id))} "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" > ${shellQuote(join(worktreePath, ".longtable-worker", `${worker.id}.json`))}`,
87
143
  `if [ -f ${shellQuote(run.stopFilePath)} ]; then`,
88
144
  ` printf 'stop_requested_before_launch\\n' >> ${shellQuote(worker.logPath)}`,
89
145
  " exit 0",
90
146
  "fi",
91
147
  [
92
148
  "codex exec",
93
- "-s read-only",
94
- `-C ${shellQuote(run.workingDirectory)}`,
149
+ "-s workspace-write",
150
+ `-C ${shellQuote(worktreePath)}`,
95
151
  "--skip-git-repo-check",
96
152
  `--output-schema ${shellQuote(run.outputSchemaPath)}`,
97
153
  "-",
@@ -100,7 +156,7 @@ function launcherScript(run, worker) {
100
156
  ].join(" "),
101
157
  "code=$?",
102
158
  `if [ -s ${shellQuote(stdoutPath)} ]; then`,
103
- ` node -e 'const fs=require("fs"); const [stdoutPath,resultPath]=process.argv.slice(1); const parsed=JSON.parse(fs.readFileSync(stdoutPath,"utf8").trim()); fs.writeFileSync(resultPath, JSON.stringify(parsed, null, 2)+"\\n");' ${shellQuote(stdoutPath)} ${shellQuote(worker.resultPath)}`,
159
+ ` node -e 'const fs=require("fs"); const [stdoutPath,resultPath]=process.argv.slice(1); const parsed=JSON.parse(fs.readFileSync(stdoutPath,"utf8").trim()); const strings=(value)=>Array.isArray(value)?value.filter((entry)=>typeof entry==="string"):[]; const status=["completed","blocked","error"].includes(parsed.status)?parsed.status:"error"; const sanitized={role:typeof parsed.role==="string"?parsed.role:"",label:typeof parsed.label==="string"?parsed.label:"",status,summary:typeof parsed.summary==="string"?parsed.summary:"",claims:strings(parsed.claims),objections:strings(parsed.objections),openQuestions:strings(parsed.openQuestions),evidenceRefs:strings(parsed.evidenceRefs),error:typeof parsed.error==="string"?parsed.error:""}; fs.writeFileSync(resultPath, JSON.stringify(sanitized, null, 2)+"\\n");' ${shellQuote(stdoutPath)} ${shellQuote(worker.resultPath)}`,
104
160
  " parse_code=$?",
105
161
  ` rm -f ${shellQuote(stdoutPath)}`,
106
162
  ` if [ "$parse_code" -ne 0 ]; then code=1; fi`,
@@ -110,6 +166,12 @@ function launcherScript(run, worker) {
110
166
  `if [ "$code" -ne 0 ] && [ ! -s ${shellQuote(worker.resultPath)} ]; then`,
111
167
  ` node -e 'const fs=require("fs"); const [path,role,label,code]=process.argv.slice(1); fs.writeFileSync(path, JSON.stringify({role,label,status:"error",summary:"",claims:[],objections:[],openQuestions:[],evidenceRefs:[],error:\`codex exec exited ${"${code}"}\`}, null, 2)+"\\n");' ${shellQuote(worker.resultPath)} ${role} ${label} "$code"`,
112
168
  "fi",
169
+ `if git -C ${shellQuote(worktreePath)} status --porcelain >/tmp/longtable-worker-status.$$ 2>/dev/null && [ -s /tmp/longtable-worker-status.$$ ]; then`,
170
+ ` git -C ${shellQuote(worktreePath)} add -A >/dev/null 2>&1`,
171
+ ` git -C ${shellQuote(worktreePath)} -c user.name='LongTable Panel Worker' -c user.email='longtable-panel-worker@example.invalid' commit -m ${shellQuote(`longtable panel worker ${worker.id}`)} >/dev/null 2>&1`,
172
+ "fi",
173
+ `rm -f /tmp/longtable-worker-status.$$`,
174
+ `git -C ${shellQuote(worktreePath)} rev-parse HEAD > ${shellQuote(commitPath)} 2>/dev/null`,
113
175
  "exit $code",
114
176
  ""
115
177
  ].join("\n");
@@ -125,24 +187,32 @@ export async function createPanelWorkerRun(options) {
125
187
  const resultDirectory = join(runDirectory, "results");
126
188
  const logDirectory = join(runDirectory, "logs");
127
189
  const launcherDirectory = join(runDirectory, "launchers");
128
- await mkdir(taskDirectory, { recursive: true });
129
- await mkdir(resultDirectory, { recursive: true });
130
- await mkdir(logDirectory, { recursive: true });
131
- await mkdir(launcherDirectory, { recursive: true });
190
+ const worktreeDirectory = join(runDirectory, "worktrees");
191
+ const mailboxDirectory = join(runDirectory, "mailbox");
192
+ await mkdir(runDirectory, { recursive: true });
193
+ await mkdir(worktreeDirectory, { recursive: true });
132
194
  const runStatus = options.initialStatus ?? "planned";
133
195
  const workers = options.fallback.plan.members.map((member, index) => {
134
196
  const workerId = `worker-${index + 1}-${member.role}`;
197
+ const worktreePath = join(worktreeDirectory, workerSafeName(workerId));
198
+ const paths = workerRuntimePaths(worktreePath);
135
199
  return {
136
200
  id: workerId,
137
201
  role: member.role,
138
202
  label: member.label,
139
203
  required: member.required,
140
204
  status: plannedWorkerStatus(runStatus),
141
- taskPath: join(taskDirectory, `${workerId}.md`),
142
- resultPath: join(resultDirectory, `${workerId}.json`),
143
- logPath: join(logDirectory, `${workerId}.log`),
144
- launcherPath: join(launcherDirectory, `${workerId}.sh`),
145
- exitCodePath: join(resultDirectory, `${workerId}.exit.json`),
205
+ taskPath: paths.taskPath,
206
+ resultPath: paths.resultPath,
207
+ logPath: paths.logPath,
208
+ launcherPath: paths.launcherPath,
209
+ exitCodePath: paths.exitCodePath,
210
+ worktreePath,
211
+ worktreeBranch: safeName(`longtable/panel/${runId}/${workerId}`),
212
+ mailboxPath: paths.mailboxPath,
213
+ taskStatePath: paths.taskStatePath,
214
+ cleanupStatus: "not_started",
215
+ executionState: "not_started",
146
216
  updatedAt: createdAt,
147
217
  diagnostics: []
148
218
  };
@@ -167,9 +237,14 @@ export async function createPanelWorkerRun(options) {
167
237
  resultDirectory,
168
238
  logDirectory,
169
239
  launcherDirectory,
240
+ worktreeDirectory,
241
+ mailboxDirectory,
242
+ eventLogPath: join(runDirectory, "events.jsonl"),
170
243
  outputSchemaPath: join(runDirectory, "panel-worker-output.schema.json"),
171
244
  stopFilePath: join(runDirectory, "stop-requested"),
172
245
  aggregateResultPath: join(runDirectory, "panel-result.json"),
246
+ bridgeStatus: runStatus === "planned" ? "not_requested" : "running",
247
+ sequentialFallbackAvailable: true,
173
248
  workers,
174
249
  diagnostics: options.diagnostics ?? []
175
250
  };
@@ -179,7 +254,7 @@ export async function createPanelWorkerRun(options) {
179
254
  note: "Durable worker state stores task/status/result metadata, not hidden reasoning or raw tool traces."
180
255
  });
181
256
  await writeJsonAtomic(run.outputSchemaPath, PANEL_WORKER_OUTPUT_SCHEMA);
182
- await Promise.all(workers.map((worker) => writeFile(worker.taskPath, workerTaskPrompt(options.fallback, worker), "utf8")));
257
+ await appendLifecycleEvent(run, { type: "run_created", message: "LongTable native worker bridge run created." });
183
258
  await writePanelWorkerRun(run);
184
259
  return run;
185
260
  }
@@ -189,17 +264,38 @@ export async function readPanelWorkerRun(workingDirectory, runId) {
189
264
  function normalizePanelWorkerRun(run) {
190
265
  const launcherDirectory = run.launcherDirectory ?? join(run.runDirectory, "launchers");
191
266
  const resultDirectory = run.resultDirectory ?? join(run.runDirectory, "results");
267
+ const worktreeDirectory = run.worktreeDirectory ?? join(run.runDirectory, "worktrees");
268
+ const mailboxDirectory = run.mailboxDirectory ?? join(run.runDirectory, "mailbox");
192
269
  return {
193
270
  ...run,
194
271
  launcherDirectory,
272
+ worktreeDirectory,
273
+ mailboxDirectory,
274
+ eventLogPath: run.eventLogPath ?? join(run.runDirectory, "events.jsonl"),
275
+ bridgeStatus: run.bridgeStatus ?? bridgeStatusFromRunStatus(run.status),
276
+ sequentialFallbackAvailable: run.sequentialFallbackAvailable ?? true,
195
277
  outputSchemaPath: run.outputSchemaPath ?? join(run.runDirectory, "panel-worker-output.schema.json"),
196
278
  stopFilePath: run.stopFilePath ?? join(run.runDirectory, "stop-requested"),
197
- workers: run.workers.map((worker) => ({
198
- ...worker,
199
- launcherPath: worker.launcherPath ?? join(launcherDirectory, `${worker.id}.sh`),
200
- exitCodePath: worker.exitCodePath ?? join(resultDirectory, `${worker.id}.exit.json`),
201
- diagnostics: worker.diagnostics ?? []
202
- }))
279
+ workers: run.workers.map((worker) => {
280
+ const worktreePath = worker.worktreePath ?? join(worktreeDirectory, workerSafeName(worker.id));
281
+ const runtimePaths = workerRuntimePaths(worktreePath);
282
+ const normalizePaths = shouldNormalizeWorkerRuntimePaths(run, worker);
283
+ return {
284
+ ...worker,
285
+ taskPath: normalizePaths ? runtimePaths.taskPath : worker.taskPath,
286
+ resultPath: normalizePaths ? runtimePaths.resultPath : worker.resultPath,
287
+ logPath: normalizePaths ? runtimePaths.logPath : worker.logPath,
288
+ launcherPath: normalizePaths ? runtimePaths.launcherPath : worker.launcherPath ?? join(launcherDirectory, `${worker.id}.sh`),
289
+ exitCodePath: normalizePaths ? runtimePaths.exitCodePath : worker.exitCodePath ?? join(resultDirectory, `${worker.id}.exit.json`),
290
+ worktreePath,
291
+ worktreeBranch: worker.worktreeBranch ?? safeName(`longtable/panel/${run.id}/${worker.id}`),
292
+ mailboxPath: normalizePaths ? runtimePaths.mailboxPath : worker.mailboxPath ?? join(mailboxDirectory, `${worker.id}.jsonl`),
293
+ taskStatePath: normalizePaths ? runtimePaths.taskStatePath : worker.taskStatePath ?? join(run.taskDirectory, `${worker.id}.state.json`),
294
+ cleanupStatus: worker.cleanupStatus ?? "not_started",
295
+ executionState: worker.executionState ?? "not_started",
296
+ diagnostics: worker.diagnostics ?? []
297
+ };
298
+ })
203
299
  };
204
300
  }
205
301
  export async function writePanelWorkerRun(run) {
@@ -207,6 +303,24 @@ export async function writePanelWorkerRun(run) {
207
303
  ...run,
208
304
  updatedAt: nowIso()
209
305
  });
306
+ await Promise.all(run.workers.map((worker) => worker.taskStatePath
307
+ && existsSync(dirname(worker.taskStatePath))
308
+ && (!worker.worktreePath || existsSync(worker.worktreePath))
309
+ ? writeJsonAtomic(worker.taskStatePath, {
310
+ workerId: worker.id,
311
+ status: worker.status,
312
+ executionState: worker.executionState,
313
+ paneId: worker.paneId,
314
+ runtime: worker.runtime,
315
+ worktreePath: worker.worktreePath,
316
+ worktreeBranch: worker.worktreeBranch,
317
+ worktreeCommit: worker.worktreeCommit,
318
+ cleanupStatus: worker.cleanupStatus,
319
+ failureReason: worker.failureReason,
320
+ error: worker.error,
321
+ updatedAt: worker.updatedAt
322
+ })
323
+ : Promise.resolve()));
210
324
  }
211
325
  function parseWorkerResult(worker) {
212
326
  if (!existsSync(worker.resultPath)) {
@@ -242,7 +356,7 @@ function statusFromWorkers(workers) {
242
356
  return "running";
243
357
  }
244
358
  if (workers.some((worker) => worker.status === "failed")) {
245
- return "resumable";
359
+ return "failed";
246
360
  }
247
361
  if (workers.some((worker) => worker.status === "blocked")) {
248
362
  return "blocked";
@@ -255,6 +369,15 @@ function statusFromWorkers(workers) {
255
369
  }
256
370
  return "running";
257
371
  }
372
+ function bridgeStatusFromRunStatus(status) {
373
+ return status === "planned" || status === "resumable" ? "not_requested" : status;
374
+ }
375
+ function bridgeStatusFromWorkers(status, workers) {
376
+ if (status === "failed" && workers.every((worker) => worker.executionState === "preflight_failed")) {
377
+ return "preflight_failed";
378
+ }
379
+ return bridgeStatusFromRunStatus(status);
380
+ }
258
381
  function tmuxPaneAlive(paneId) {
259
382
  if (!commandAvailable("tmux")) {
260
383
  return false;
@@ -268,35 +391,169 @@ function tmuxPaneAlive(paneId) {
268
391
  }
269
392
  }
270
393
  function terminalStatus(status) {
271
- return status === "completed" || status === "blocked" || status === "stopped" || status === "degraded";
394
+ return status === "completed" || status === "blocked" || status === "stopped" || status === "degraded" || status === "failed";
272
395
  }
273
- function launchWorkerPane(run, worker) {
274
- const command = `bash ${shellQuote(worker.launcherPath)}`;
396
+ function preflightNativeWorkerBridge(run) {
397
+ const failures = [
398
+ commandAvailable("tmux") ? null : "tmux:unavailable",
399
+ commandAvailable("codex") ? null : "codex:unavailable",
400
+ commandAvailable("git") ? null : "git:unavailable"
401
+ ].filter((entry) => Boolean(entry));
402
+ if (!process.env.TMUX && !process.env.TMUX_PANE) {
403
+ failures.push("tmux:attached-pane-unavailable");
404
+ }
405
+ try {
406
+ execFileSync("tmux", ["display-message", "-p", "#{pane_id}"], { stdio: "ignore" });
407
+ }
408
+ catch {
409
+ failures.push("tmux:current-pane-unavailable");
410
+ }
275
411
  try {
276
- return execFileSync("tmux", [
277
- "new-window",
278
- "-d",
279
- "-P",
280
- "-F",
281
- "#{pane_id}",
282
- "-n",
283
- `lt-${worker.id.slice(0, 12)}`,
284
- command
285
- ], { encoding: "utf8" }).trim();
412
+ gitOutput(run.workingDirectory, ["rev-parse", "--is-inside-work-tree"]);
286
413
  }
287
414
  catch {
288
- const sessionName = `longtable-${run.id}-${worker.id}`.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80);
289
- return execFileSync("tmux", [
290
- "new-session",
291
- "-d",
292
- "-P",
293
- "-F",
294
- "#{pane_id}",
295
- "-s",
296
- sessionName,
297
- command
298
- ], { encoding: "utf8" }).trim();
415
+ failures.push("git:working-directory-unavailable");
416
+ }
417
+ return failures;
418
+ }
419
+ function applyLeaderTrackedChanges(run, worker) {
420
+ if (!worker.worktreePath) {
421
+ return undefined;
422
+ }
423
+ const patch = execFileSync("git", ["diff", "--binary", "HEAD"], {
424
+ cwd: run.workingDirectory,
425
+ encoding: "buffer",
426
+ stdio: ["ignore", "pipe", "pipe"]
427
+ });
428
+ if (patch.length === 0) {
429
+ return undefined;
430
+ }
431
+ try {
432
+ execFileSync("git", ["apply", "--check", "--whitespace=nowarn", "-"], {
433
+ cwd: worker.worktreePath,
434
+ input: patch,
435
+ stdio: ["pipe", "ignore", "pipe"]
436
+ });
437
+ execFileSync("git", ["apply", "--whitespace=nowarn", "-"], {
438
+ cwd: worker.worktreePath,
439
+ input: patch,
440
+ stdio: ["pipe", "ignore", "pipe"]
441
+ });
442
+ return "Leader tracked workspace changes were applied to the worker worktree before launch.";
443
+ }
444
+ catch (error) {
445
+ try {
446
+ execFileSync("git", ["apply", "--reverse", "--check", "--whitespace=nowarn", "-"], {
447
+ cwd: worker.worktreePath,
448
+ input: patch,
449
+ stdio: ["pipe", "ignore", "pipe"]
450
+ });
451
+ return "Leader tracked workspace changes were already present in the worker worktree before launch.";
452
+ }
453
+ catch {
454
+ throw error;
455
+ }
456
+ }
457
+ }
458
+ async function markBridgePreflightFailed(run, failures) {
459
+ const updatedAt = nowIso();
460
+ const reason = failures.join(", ");
461
+ const nextRun = {
462
+ ...run,
463
+ status: "failed",
464
+ bridgeStatus: "preflight_failed",
465
+ bridgeFailureReason: reason,
466
+ updatedAt,
467
+ diagnostics: [...run.diagnostics, ...failures, "Native worker bridge preflight failed; sequential fallback was not executed implicitly."],
468
+ workers: run.workers.map((worker) => ({
469
+ ...worker,
470
+ status: worker.status === "completed" ? worker.status : "failed",
471
+ executionState: "preflight_failed",
472
+ failureReason: reason,
473
+ error: reason,
474
+ updatedAt,
475
+ diagnostics: appendDiagnostic(worker.diagnostics, "Native worker bridge preflight failed before launch.")
476
+ }))
477
+ };
478
+ await appendLifecycleEvent(nextRun, { type: "preflight_failed", message: reason });
479
+ await writePanelWorkerRun(nextRun);
480
+ return nextRun;
481
+ }
482
+ async function provisionWorkerWorktree(run, worker) {
483
+ if (!worker.worktreePath || !worker.worktreeBranch) {
484
+ throw new Error(`missing worktree metadata for ${worker.id}`);
299
485
  }
486
+ await mkdir(dirname(worker.worktreePath), { recursive: true });
487
+ if (!existsSync(worker.worktreePath)) {
488
+ try {
489
+ gitOutput(run.workingDirectory, ["worktree", "add", worker.worktreePath, "-b", worker.worktreeBranch, "HEAD"]);
490
+ }
491
+ catch (firstError) {
492
+ try {
493
+ gitOutput(run.workingDirectory, ["worktree", "add", worker.worktreePath, worker.worktreeBranch]);
494
+ }
495
+ catch {
496
+ throw firstError;
497
+ }
498
+ }
499
+ }
500
+ const commit = currentGitCommit(worker.worktreePath);
501
+ const snapshotDiagnostic = applyLeaderTrackedChanges(run, worker);
502
+ await mkdir(workerRuntimeDirectory(worker), { recursive: true });
503
+ await writeFile(worker.mailboxPath ?? join(workerRuntimeDirectory(worker), "mailbox.jsonl"), "", { flag: "a" });
504
+ await appendLifecycleEvent(run, {
505
+ workerId: worker.id,
506
+ type: "worktree_provisioned",
507
+ message: `Worker worktree provisioned at ${worker.worktreePath}.`,
508
+ path: worker.worktreePath
509
+ });
510
+ return {
511
+ ...worker,
512
+ worktreeCommit: commit,
513
+ cleanupStatus: "retained",
514
+ executionState: "provisioned",
515
+ diagnostics: snapshotDiagnostic
516
+ ? appendDiagnostic(appendDiagnostic(worker.diagnostics, "Writable git worktree provisioned for LongTable native worker."), snapshotDiagnostic)
517
+ : appendDiagnostic(worker.diagnostics, "Writable git worktree provisioned for LongTable native worker.")
518
+ };
519
+ }
520
+ async function writeWorkerTask(run, worker) {
521
+ await mkdir(workerRuntimeDirectory(worker), { recursive: true });
522
+ await writeFile(worker.taskPath, workerTaskPrompt(run, worker), "utf8");
523
+ }
524
+ async function clearWorkerAttemptArtifacts(worker) {
525
+ const paths = [
526
+ worker.resultPath,
527
+ `${worker.resultPath}.stdout`,
528
+ `${worker.resultPath}.commit`,
529
+ worker.exitCodePath,
530
+ worker.logPath
531
+ ].filter((path) => typeof path === "string" && path.length > 0);
532
+ await Promise.all(paths.map((path) => rm(path, { force: true })));
533
+ }
534
+ function launchWorkerPane(run, worker) {
535
+ const command = `cd ${shellQuote(worker.worktreePath ?? run.workingDirectory)} && bash ${shellQuote(worker.launcherPath)}`;
536
+ const args = [
537
+ "split-window",
538
+ "-d",
539
+ "-P",
540
+ "-F",
541
+ "#{pane_id}"
542
+ ];
543
+ if (process.env.TMUX_PANE) {
544
+ args.push("-t", process.env.TMUX_PANE);
545
+ }
546
+ args.push(command);
547
+ const paneId = execFileSync("tmux", args, { encoding: "utf8" }).trim();
548
+ if (paneId) {
549
+ try {
550
+ execFileSync("tmux", ["set-option", "-p", "-t", paneId, "remain-on-exit", "on"], { stdio: "ignore" });
551
+ }
552
+ catch {
553
+ // Pane retention is best-effort on older tmux versions; the pane id is still recorded.
554
+ }
555
+ }
556
+ return paneId;
300
557
  }
301
558
  async function writeWorkerLauncher(run, worker) {
302
559
  if (!worker.launcherPath) {
@@ -309,66 +566,81 @@ export async function launchPanelWorkerRun(run) {
309
566
  if (terminalStatus(run.status) || run.status === "stop_requested") {
310
567
  return run;
311
568
  }
312
- const tmuxAvailable = commandAvailable("tmux");
313
- const codexAvailable = commandAvailable("codex");
314
- if (!tmuxAvailable || !codexAvailable) {
315
- const unavailable = [
316
- tmuxAvailable ? null : "tmux:unavailable",
317
- codexAvailable ? null : "codex:unavailable"
318
- ].filter((entry) => Boolean(entry));
319
- const nextRun = {
320
- ...run,
321
- status: "degraded",
322
- updatedAt: nowIso(),
323
- diagnostics: [...run.diagnostics, ...unavailable, "Native worker launch degraded; use sequential_fallback or resume when local runtime is available."],
324
- workers: run.workers.map((worker) => worker.status === "completed"
325
- ? worker
326
- : {
327
- ...worker,
328
- status: "pending",
329
- updatedAt: nowIso(),
330
- diagnostics: appendDiagnostic(worker.diagnostics, "Launch skipped because tmux or codex is unavailable.")
331
- })
332
- };
333
- await writePanelWorkerRun(nextRun);
334
- return nextRun;
569
+ const preflightFailures = preflightNativeWorkerBridge(run);
570
+ if (preflightFailures.length > 0) {
571
+ return markBridgePreflightFailed(run, preflightFailures);
335
572
  }
336
573
  const launchedAt = nowIso();
337
- const workers = await Promise.all(run.workers.map(async (worker) => {
574
+ const workers = [];
575
+ for (const worker of run.workers) {
338
576
  if (worker.status !== "pending" && worker.status !== "stopped" && worker.status !== "failed") {
339
- return worker;
577
+ workers.push(worker);
578
+ continue;
340
579
  }
341
- const launchable = {
342
- ...worker,
343
- launcherPath: worker.launcherPath ?? join(run.launcherDirectory, `${worker.id}.sh`),
344
- exitCodePath: worker.exitCodePath ?? join(run.resultDirectory, `${worker.id}.exit.json`)
345
- };
346
- await writeWorkerLauncher(run, launchable);
347
580
  try {
581
+ const provisioned = await provisionWorkerWorktree(run, {
582
+ ...worker,
583
+ launcherPath: worker.launcherPath ?? join(run.launcherDirectory, `${worker.id}.sh`),
584
+ exitCodePath: worker.exitCodePath ?? join(run.resultDirectory, `${worker.id}.exit.json`),
585
+ executionState: "provisioning"
586
+ });
587
+ const launchable = {
588
+ ...provisioned,
589
+ executionState: "launching"
590
+ };
591
+ await writeWorkerTask(run, launchable);
592
+ await writeWorkerLauncher(run, launchable);
348
593
  const paneId = launchWorkerPane(run, launchable);
349
- return {
594
+ const launchedWorker = {
350
595
  ...launchable,
351
596
  status: "running",
352
597
  paneId: paneId || launchable.paneId,
598
+ runtime: {
599
+ transport: "tmux",
600
+ paneId: paneId || launchable.paneId,
601
+ paneTarget: process.env.TMUX_PANE,
602
+ splitCommand: "split-window",
603
+ retainPane: true,
604
+ launchedAt
605
+ },
606
+ executionState: "running",
353
607
  startedAt: launchedAt,
354
608
  updatedAt: launchedAt,
355
- diagnostics: appendDiagnostic(launchable.diagnostics, "Launched with tmux/codex read-only native worker command; launcher persists the structured final output file.")
609
+ diagnostics: appendDiagnostic(launchable.diagnostics, "Launched in a retained current-window tmux split pane with Codex workspace-write scoped to the worker worktree.")
356
610
  };
611
+ await appendLifecycleEvent(run, {
612
+ workerId: worker.id,
613
+ type: "worker_launched",
614
+ message: `Worker launched in tmux split pane ${paneId}.`,
615
+ path: launchable.worktreePath
616
+ });
617
+ workers.push(launchedWorker);
357
618
  }
358
619
  catch (error) {
359
- return {
360
- ...launchable,
620
+ const failedWorker = {
621
+ ...worker,
361
622
  status: "failed",
623
+ executionState: worker.worktreePath && existsSync(worker.worktreePath) ? "launch_failed" : "provision_failed",
624
+ failureReason: error instanceof Error ? error.message : String(error),
362
625
  updatedAt: nowIso(),
363
626
  error: error instanceof Error ? error.message : String(error),
364
- diagnostics: appendDiagnostic(launchable.diagnostics, "tmux launch failed before worker result was created.")
627
+ diagnostics: appendDiagnostic(worker.diagnostics, "Native worker bridge provisioning or tmux launch failed before worker result was created.")
365
628
  };
629
+ await appendLifecycleEvent(run, {
630
+ workerId: worker.id,
631
+ type: "worker_failed",
632
+ message: failedWorker.error ?? "worker launch failed",
633
+ path: worker.worktreePath
634
+ });
635
+ workers.push(failedWorker);
366
636
  }
367
- }));
637
+ }
368
638
  const nextRun = {
369
639
  ...run,
370
640
  workers,
371
641
  status: statusFromWorkers(workers),
642
+ bridgeStatus: bridgeStatusFromWorkers(statusFromWorkers(workers), workers),
643
+ bridgeFailureReason: workers.find((worker) => worker.status === "failed")?.error,
372
644
  updatedAt: nowIso()
373
645
  };
374
646
  await writePanelWorkerRun(nextRun);
@@ -405,12 +677,21 @@ export async function refreshPanelWorkerRun(run) {
405
677
  }
406
678
  memberResults.push(result);
407
679
  const nextStatus = result.status === "error" ? "failed" : result.status === "blocked" ? "blocked" : "completed";
680
+ const commitPath = `${worker.resultPath}.commit`;
681
+ const worktreeCommit = existsSync(commitPath)
682
+ ? readFileSyncUtf8(commitPath).trim()
683
+ : worker.worktreePath
684
+ ? currentGitCommit(worker.worktreePath)
685
+ : worker.worktreeCommit;
408
686
  return {
409
687
  ...worker,
410
688
  status: nextStatus,
689
+ executionState: nextStatus === "completed" || nextStatus === "blocked" ? "result_ready" : "launch_failed",
411
690
  completedAt: nextStatus === "failed" ? worker.completedAt : updatedAt,
691
+ worktreeCommit,
412
692
  updatedAt,
413
- error: result.error
693
+ error: result.error,
694
+ failureReason: nextStatus === "failed" ? result.error : worker.failureReason
414
695
  };
415
696
  }
416
697
  catch (error) {
@@ -427,6 +708,8 @@ export async function refreshPanelWorkerRun(run) {
427
708
  ...run,
428
709
  workers,
429
710
  status: statusFromWorkers(workers),
711
+ bridgeStatus: bridgeStatusFromWorkers(statusFromWorkers(workers), workers),
712
+ bridgeFailureReason: workers.find((worker) => worker.status === "failed")?.error,
430
713
  updatedAt
431
714
  };
432
715
  if (nextRun.status === "completed" || nextRun.status === "blocked") {
@@ -457,6 +740,7 @@ export async function requestPanelWorkerStop(run) {
457
740
  const nextRun = {
458
741
  ...run,
459
742
  status: "stop_requested",
743
+ bridgeStatus: "stop_requested",
460
744
  updatedAt,
461
745
  workers: run.workers.map((worker) => {
462
746
  if (worker.status === "completed") {
@@ -475,20 +759,32 @@ export async function requestPanelWorkerStop(run) {
475
759
  return {
476
760
  ...worker,
477
761
  status: stopped ? "stopped" : "stop_requested",
762
+ executionState: stopped ? "stopped" : "stopping",
763
+ runtime: worker.runtime
764
+ ? { ...worker.runtime, stoppedAt: updatedAt }
765
+ : worker.paneId
766
+ ? { transport: "tmux", paneId: worker.paneId, retainPane: true, stoppedAt: updatedAt }
767
+ : worker.runtime,
478
768
  updatedAt,
479
769
  diagnostics: appendDiagnostic(worker.diagnostics, "Stop requested through LongTable panel runtime.")
480
770
  };
481
771
  })
482
772
  };
773
+ await appendLifecycleEvent(nextRun, { type: "stop_requested", message: "Stop requested through LongTable panel runtime." });
483
774
  await writePanelWorkerRun(nextRun);
484
775
  return nextRun;
485
776
  }
486
777
  export async function resumePanelWorkerRun(run) {
487
778
  const updatedAt = nowIso();
488
779
  await rm(run.stopFilePath, { force: true });
780
+ await Promise.all(run.workers
781
+ .filter((worker) => worker.status !== "completed")
782
+ .map((worker) => clearWorkerAttemptArtifacts(worker)));
489
783
  const nextRun = {
490
784
  ...run,
491
785
  status: "planned",
786
+ bridgeStatus: "not_requested",
787
+ bridgeFailureReason: undefined,
492
788
  updatedAt,
493
789
  workers: run.workers.map((worker) => worker.status === "completed"
494
790
  ? worker
@@ -497,10 +793,73 @@ export async function resumePanelWorkerRun(run) {
497
793
  status: "pending",
498
794
  paneId: undefined,
499
795
  error: undefined,
796
+ failureReason: undefined,
797
+ executionState: "not_started",
500
798
  updatedAt,
501
799
  diagnostics: appendDiagnostic(worker.diagnostics, "Resume requested; worker is ready to be relaunched.")
502
800
  })
503
801
  };
802
+ await appendLifecycleEvent(nextRun, { type: "resume_requested", message: "Resume requested; incomplete workers are ready to relaunch." });
803
+ await writePanelWorkerRun(nextRun);
804
+ return nextRun;
805
+ }
806
+ export async function shutdownPanelWorkerRun(run) {
807
+ const updatedAt = nowIso();
808
+ await writeFile(run.stopFilePath, `${updatedAt}\n`, "utf8");
809
+ const workers = [];
810
+ for (const worker of run.workers) {
811
+ let cleanupStatus = worker.cleanupStatus ?? "not_started";
812
+ let cleanupError;
813
+ if (worker.paneId && commandAvailable("tmux")) {
814
+ try {
815
+ execFileSync("tmux", ["kill-pane", "-t", worker.paneId], { stdio: "ignore" });
816
+ }
817
+ catch {
818
+ // A missing pane is already shut down for LongTable lifecycle purposes.
819
+ }
820
+ }
821
+ if (worker.worktreePath && existsSync(worker.worktreePath) && commandAvailable("git")) {
822
+ try {
823
+ gitOutput(run.workingDirectory, ["worktree", "remove", "--force", worker.worktreePath]);
824
+ cleanupStatus = "removed";
825
+ }
826
+ catch (error) {
827
+ cleanupStatus = "failed";
828
+ cleanupError = error instanceof Error ? error.message : String(error);
829
+ await appendLifecycleEvent(run, {
830
+ workerId: worker.id,
831
+ type: "cleanup_failed",
832
+ message: cleanupError,
833
+ path: worker.worktreePath
834
+ });
835
+ }
836
+ }
837
+ workers.push({
838
+ ...worker,
839
+ status: worker.status === "completed" ? "completed" : "stopped",
840
+ executionState: cleanupStatus === "failed" ? "cleanup_failed" : "shutdown",
841
+ shutdownRequestedAt: updatedAt,
842
+ cleanupStatus,
843
+ error: cleanupError ?? worker.error,
844
+ failureReason: cleanupError ?? worker.failureReason,
845
+ runtime: worker.runtime
846
+ ? { ...worker.runtime, shutdownAt: updatedAt }
847
+ : worker.paneId
848
+ ? { transport: "tmux", paneId: worker.paneId, retainPane: true, shutdownAt: updatedAt }
849
+ : worker.runtime,
850
+ updatedAt,
851
+ diagnostics: appendDiagnostic(worker.diagnostics, "Shutdown requested through LongTable panel runtime.")
852
+ });
853
+ }
854
+ const nextRun = {
855
+ ...run,
856
+ status: workers.some((worker) => worker.cleanupStatus === "failed") ? "failed" : "stopped",
857
+ bridgeStatus: workers.some((worker) => worker.cleanupStatus === "failed") ? "failed" : "shutdown",
858
+ bridgeFailureReason: workers.find((worker) => worker.cleanupStatus === "failed")?.error,
859
+ workers,
860
+ updatedAt
861
+ };
862
+ await appendLifecycleEvent(nextRun, { type: "shutdown_requested", message: "Shutdown requested; panes killed and worker worktrees removed when possible." });
504
863
  await writePanelWorkerRun(nextRun);
505
864
  return nextRun;
506
865
  }