taskplane 0.18.1 → 0.20.0

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.
@@ -3,7 +3,8 @@
3
3
  * @module orch/execution
4
4
  */
5
5
  import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync } from "fs";
6
- import { spawnSync } from "child_process";
6
+ import { access as fsAccess, readFile as fsReadFile, stat as fsStat } from "fs/promises";
7
+ import { spawnSync, spawn } from "child_process";
7
8
  import { join, dirname, resolve, relative, delimiter as pathDelimiter } from "path";
8
9
  import { userInfo } from "os";
9
10
 
@@ -264,6 +265,135 @@ export function killLaneAndChildren(sessionName: string): void {
264
265
  tmuxKillSession(sessionName);
265
266
  }
266
267
 
268
+ // ── Async TMUX Helpers (TP-070) ──────────────────────────────────────
269
+
270
+ /**
271
+ * Run a tmux command asynchronously, without blocking the event loop.
272
+ *
273
+ * Wraps `child_process.spawn` in a promise. The process is spawned and
274
+ * stdout is collected incrementally; the promise resolves when the process
275
+ * exits.
276
+ *
277
+ * @param args - Arguments to pass to the `tmux` command
278
+ * @param timeoutMs - Optional timeout in milliseconds (default: 5000)
279
+ * @returns Promise resolving to `{ status, stdout }` where status is the exit code (0 = success)
280
+ *
281
+ * @since TP-070
282
+ */
283
+ export function tmuxAsync(args: string[], timeoutMs: number = 5_000): Promise<{ status: number; stdout: string }> {
284
+ return new Promise((resolve) => {
285
+ const proc = spawn("tmux", args, {
286
+ stdio: ["ignore", "pipe", "pipe"],
287
+ timeout: timeoutMs,
288
+ });
289
+
290
+ let stdout = "";
291
+
292
+ proc.stdout.on("data", (chunk: Buffer) => {
293
+ stdout += chunk.toString("utf-8");
294
+ });
295
+
296
+ proc.on("error", () => {
297
+ // Spawn failure — treat as non-zero exit
298
+ resolve({ status: 1, stdout: "" });
299
+ });
300
+
301
+ proc.on("close", (code) => {
302
+ resolve({ status: code ?? 1, stdout });
303
+ });
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Async version of tmuxHasSession — checks if a TMUX session exists
309
+ * without blocking the event loop.
310
+ *
311
+ * @param sessionName - TMUX session name to check
312
+ * @returns Promise resolving to true if session exists
313
+ *
314
+ * @since TP-070
315
+ */
316
+ export async function tmuxHasSessionAsync(sessionName: string): Promise<boolean> {
317
+ const result = await tmuxAsync(["has-session", "-t", sessionName]);
318
+ return result.status === 0;
319
+ }
320
+
321
+ /**
322
+ * Async version of tmuxKillSession — kills a TMUX session without
323
+ * blocking the event loop.
324
+ *
325
+ * Idempotent: resolves to true if session was killed or was already absent.
326
+ *
327
+ * @param sessionName - TMUX session name to kill
328
+ * @returns Promise resolving to true if session is now absent
329
+ *
330
+ * @since TP-070
331
+ */
332
+ export async function tmuxKillSessionAsync(sessionName: string): Promise<boolean> {
333
+ const wasAlive = await tmuxHasSessionAsync(sessionName);
334
+ if (!wasAlive) return true;
335
+
336
+ await tmuxAsync(["kill-session", "-t", sessionName]);
337
+ return !(await tmuxHasSessionAsync(sessionName));
338
+ }
339
+
340
+ /**
341
+ * Async version of captureTmuxPaneTail — captures tail output from a live
342
+ * TMUX pane without blocking the event loop.
343
+ *
344
+ * @param sessionName - TMUX session name
345
+ * @param maxLines - Maximum number of lines to return
346
+ * @param maxChars - Maximum character count
347
+ * @returns Promise resolving to captured text (empty string on failure)
348
+ *
349
+ * @since TP-070
350
+ */
351
+ export async function captureTmuxPaneTailAsync(
352
+ sessionName: string,
353
+ maxLines: number = 40,
354
+ maxChars: number = 1200,
355
+ ): Promise<string> {
356
+ const result = await tmuxAsync(["capture-pane", "-p", "-t", sessionName], 3000);
357
+ if (result.status !== 0) return "";
358
+ const raw = (result.stdout || "").replace(/\r\n/g, "\n").trim();
359
+ if (!raw) return "";
360
+ const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
361
+ if (!tail) return "";
362
+ return tail.length > maxChars ? tail.slice(-maxChars) : tail;
363
+ }
364
+
365
+ /**
366
+ * Async version of readTaskStatusTail — reads STATUS.md tail without
367
+ * blocking the event loop.
368
+ *
369
+ * @param statusPath - Path to STATUS.md
370
+ * @param maxLines - Maximum number of lines to return
371
+ * @param maxChars - Maximum character count
372
+ * @returns Promise resolving to status tail text (empty string if missing/unreadable)
373
+ *
374
+ * @since TP-070
375
+ */
376
+ export async function readTaskStatusTailAsync(
377
+ statusPath: string,
378
+ maxLines: number = 40,
379
+ maxChars: number = 1200,
380
+ ): Promise<string> {
381
+ try {
382
+ await fsAccess(statusPath);
383
+ } catch {
384
+ return "";
385
+ }
386
+ try {
387
+ const raw = (await fsReadFile(statusPath, "utf-8")).replace(/\r\n/g, "\n").trim();
388
+ if (!raw) return "";
389
+ const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
390
+ if (!tail) return "";
391
+ return tail.length > maxChars ? tail.slice(-maxChars) : tail;
392
+ } catch {
393
+ return "";
394
+ }
395
+ }
396
+
267
397
  /**
268
398
  * Build environment variables for a lane task execution.
269
399
  *
@@ -496,6 +626,50 @@ export function readLaneLogTail(
496
626
  }
497
627
  }
498
628
 
629
+ /**
630
+ * Async version of readLaneLogTail — reads lane log tail without
631
+ * blocking the event loop.
632
+ *
633
+ * @since TP-070
634
+ */
635
+ export async function readLaneLogTailAsync(
636
+ logPath: string,
637
+ maxLines: number = 40,
638
+ maxChars: number = 1200,
639
+ ): Promise<string> {
640
+ try {
641
+ await fsAccess(logPath);
642
+ } catch {
643
+ return "";
644
+ }
645
+ try {
646
+ const raw = (await fsReadFile(logPath, "utf-8")).replace(/\r\n/g, "\n");
647
+ const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
648
+ if (!tail) return "";
649
+ return tail.length > maxChars ? tail.slice(-maxChars) : tail;
650
+ } catch {
651
+ return "";
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Async file existence check — non-blocking replacement for existsSync
657
+ * in polling paths.
658
+ *
659
+ * @param filePath - Path to check
660
+ * @returns Promise resolving to true if file exists
661
+ *
662
+ * @since TP-070
663
+ */
664
+ export async function fileExistsAsync(filePath: string): Promise<boolean> {
665
+ try {
666
+ await fsAccess(filePath);
667
+ return true;
668
+ } catch {
669
+ return false;
670
+ }
671
+ }
672
+
499
673
  /**
500
674
  * Capture tail output from a live TMUX pane for diagnostics.
501
675
  *
@@ -838,13 +1012,13 @@ export async function pollUntilTaskComplete(
838
1012
  };
839
1013
  }
840
1014
 
841
- // Check file-based abort signal
842
- if (existsSync(abortSignalFile)) {
1015
+ // Check file-based abort signal (TP-070: async)
1016
+ if (await fileExistsAsync(abortSignalFile)) {
843
1017
  execLog(laneId, task.taskId, "abort signal file detected — killing session and aborting");
844
- tmuxKillSession(sessionName);
1018
+ await tmuxKillSessionAsync(sessionName);
845
1019
  // Also kill child sessions (worker, reviewer)
846
- tmuxKillSession(`${sessionName}-worker`);
847
- tmuxKillSession(`${sessionName}-reviewer`);
1020
+ await tmuxKillSessionAsync(`${sessionName}-worker`);
1021
+ await tmuxKillSessionAsync(`${sessionName}-reviewer`);
848
1022
  return {
849
1023
  status: "failed",
850
1024
  exitReason: "Aborted by signal file (.pi/orch-abort-signal)",
@@ -852,14 +1026,14 @@ export async function pollUntilTaskComplete(
852
1026
  };
853
1027
  }
854
1028
 
855
- // Capture live pane output for diagnostics (best effort).
856
- const paneTail = captureTmuxPaneTail(sessionName);
1029
+ // Capture live pane output for diagnostics (best effort) — async to avoid blocking.
1030
+ const paneTail = await captureTmuxPaneTailAsync(sessionName);
857
1031
  if (paneTail) {
858
1032
  lastPaneTail = paneTail;
859
1033
  }
860
1034
 
861
- // Priority 1: Check for .DONE file
862
- if (existsSync(donePath)) {
1035
+ // Priority 1: Check for .DONE file (TP-070: async)
1036
+ if (await fileExistsAsync(donePath)) {
863
1037
  execLog(laneId, task.taskId, ".DONE file found — task succeeded", {
864
1038
  session: sessionName,
865
1039
  });
@@ -870,8 +1044,8 @@ export async function pollUntilTaskComplete(
870
1044
  };
871
1045
  }
872
1046
 
873
- // Priority 2: Check if TMUX session is still alive
874
- if (!tmuxHasSession(sessionName)) {
1047
+ // Priority 2: Check if TMUX session is still alive — async to avoid blocking
1048
+ if (!(await tmuxHasSessionAsync(sessionName))) {
875
1049
  // Session exited — start grace period for .DONE file
876
1050
  execLog(laneId, task.taskId, "TMUX session exited, entering grace period", {
877
1051
  session: sessionName,
@@ -883,7 +1057,7 @@ export async function pollUntilTaskComplete(
883
1057
  while (Date.now() - graceStart < DONE_GRACE_MS) {
884
1058
  await new Promise((r) => setTimeout(r, 500));
885
1059
 
886
- if (existsSync(donePath)) {
1060
+ if (await fileExistsAsync(donePath)) {
887
1061
  execLog(laneId, task.taskId, ".DONE file found during grace period — task succeeded", {
888
1062
  session: sessionName,
889
1063
  });
@@ -895,8 +1069,8 @@ export async function pollUntilTaskComplete(
895
1069
  }
896
1070
  }
897
1071
 
898
- // Grace period expired without .DONE → task failed
899
- const logTail = readLaneLogTail(laneLogPath);
1072
+ // Grace period expired without .DONE → task failed (TP-070: async)
1073
+ const logTail = await readLaneLogTailAsync(laneLogPath);
900
1074
  execLog(laneId, task.taskId, "grace period expired without .DONE — task failed", {
901
1075
  session: sessionName,
902
1076
  logPath: laneLogPath,
@@ -904,8 +1078,8 @@ export async function pollUntilTaskComplete(
904
1078
  if (logTail) {
905
1079
  execLog(laneId, task.taskId, `lane session output (tail):\n${logTail}`);
906
1080
  }
907
- const statusTail = readTaskStatusTail(statusPath);
908
- const hasLogFile = existsSync(laneLogPath);
1081
+ const statusTail = await readTaskStatusTailAsync(statusPath);
1082
+ const hasLogFile = await fileExistsAsync(laneLogPath);
909
1083
  const outputForHint = logTail || lastPaneTail || statusTail;
910
1084
  const logHint = outputForHint
911
1085
  ? ` Last output: ${outputForHint.replace(/\s+/g, " ").slice(-300)}`
@@ -1284,6 +1458,105 @@ export function parseWorktreeStatusMd(
1284
1458
  };
1285
1459
  }
1286
1460
 
1461
+ /**
1462
+ * Async version of parseWorktreeStatusMd — reads and parses STATUS.md
1463
+ * without blocking the event loop. Used in monitoring poll loops.
1464
+ *
1465
+ * @since TP-070
1466
+ */
1467
+ export async function parseWorktreeStatusMdAsync(
1468
+ taskFolder: string,
1469
+ worktreePath: string,
1470
+ repoRoot: string,
1471
+ isWorkspaceMode?: boolean,
1472
+ ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
1473
+ const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode);
1474
+ const statusPath = resolved.statusPath;
1475
+
1476
+ if (!(await fileExistsAsync(statusPath))) {
1477
+ return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
1478
+ }
1479
+
1480
+ let content: string;
1481
+ let mtime: number;
1482
+ try {
1483
+ content = await fsReadFile(statusPath, "utf-8");
1484
+ mtime = (await fsStat(statusPath)).mtimeMs;
1485
+ } catch (err: unknown) {
1486
+ return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` };
1487
+ }
1488
+
1489
+ // Parse logic is identical to the sync version
1490
+ const text = content.replace(/\r\n/g, "\n");
1491
+ const steps: ParsedWorktreeStatus["steps"] = [];
1492
+ let currentStep: {
1493
+ number: number;
1494
+ name: string;
1495
+ status: "not-started" | "in-progress" | "complete";
1496
+ checkboxes: boolean[];
1497
+ } | null = null;
1498
+ let reviewCounter = 0;
1499
+ let iteration = 0;
1500
+
1501
+ for (const line of text.split("\n")) {
1502
+ const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/);
1503
+ if (rcMatch) reviewCounter = parseInt(rcMatch[1]);
1504
+ const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/);
1505
+ if (itMatch) iteration = parseInt(itMatch[1]);
1506
+
1507
+ const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/);
1508
+ if (stepMatch) {
1509
+ if (currentStep) {
1510
+ const totalChecked = currentStep.checkboxes.filter(c => c).length;
1511
+ steps.push({
1512
+ number: currentStep.number,
1513
+ name: currentStep.name,
1514
+ status: currentStep.status,
1515
+ totalChecked,
1516
+ totalItems: currentStep.checkboxes.length,
1517
+ });
1518
+ }
1519
+ currentStep = {
1520
+ number: parseInt(stepMatch[1]),
1521
+ name: stepMatch[2].trim(),
1522
+ status: "not-started",
1523
+ checkboxes: [],
1524
+ };
1525
+ continue;
1526
+ }
1527
+ if (currentStep) {
1528
+ const ss = line.match(/\*\*Status:\*\*\s*(.*)/);
1529
+ if (ss) {
1530
+ const s = ss[1];
1531
+ if (s.includes("✅") || s.toLowerCase().includes("complete")) {
1532
+ currentStep.status = "complete";
1533
+ } else if (s.includes("🟨") || s.includes("🟡") || s.toLowerCase().includes("progress")) {
1534
+ currentStep.status = "in-progress";
1535
+ }
1536
+ }
1537
+ const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/);
1538
+ if (cb) {
1539
+ currentStep.checkboxes.push(cb[1].toLowerCase() === "x");
1540
+ }
1541
+ }
1542
+ }
1543
+ if (currentStep) {
1544
+ const totalChecked = currentStep.checkboxes.filter(c => c).length;
1545
+ steps.push({
1546
+ number: currentStep.number,
1547
+ name: currentStep.name,
1548
+ status: currentStep.status,
1549
+ totalChecked,
1550
+ totalItems: currentStep.checkboxes.length,
1551
+ });
1552
+ }
1553
+
1554
+ return {
1555
+ parsed: { steps, reviewCounter, iteration, mtime },
1556
+ error: null,
1557
+ };
1558
+ }
1559
+
1287
1560
 
1288
1561
  // ── State Resolution ─────────────────────────────────────────────────
1289
1562
 
@@ -1307,7 +1580,7 @@ export function parseWorktreeStatusMd(
1307
1580
  * @param stallTimeoutMs - Stall timeout in milliseconds
1308
1581
  * @param now - Current timestamp (epoch ms) for deterministic testing
1309
1582
  */
1310
- export function resolveTaskMonitorState(
1583
+ export async function resolveTaskMonitorState(
1311
1584
  taskId: string,
1312
1585
  donePath: string,
1313
1586
  sessionName: string,
@@ -1315,9 +1588,9 @@ export function resolveTaskMonitorState(
1315
1588
  tracker: MtimeTracker,
1316
1589
  stallTimeoutMs: number,
1317
1590
  now: number,
1318
- ): TaskMonitorSnapshot {
1319
- const sessionAlive = tmuxHasSession(sessionName);
1320
- const doneFileFound = existsSync(donePath);
1591
+ ): Promise<TaskMonitorSnapshot> {
1592
+ const sessionAlive = await tmuxHasSessionAsync(sessionName);
1593
+ const doneFileFound = await fileExistsAsync(donePath);
1321
1594
 
1322
1595
  // Build base snapshot from parsed status
1323
1596
  let currentStepName: string | null = null;
@@ -1606,9 +1879,9 @@ export async function monitorLanes(
1606
1879
 
1607
1880
  const tracker = getOrCreateTracker(task.taskId, now);
1608
1881
  const donePath = resolveTaskDonePath(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
1609
- const statusResult = parseWorktreeStatusMd(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
1882
+ const statusResult = await parseWorktreeStatusMdAsync(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
1610
1883
 
1611
- const snapshot = resolveTaskMonitorState(
1884
+ const snapshot = await resolveTaskMonitorState(
1612
1885
  task.taskId,
1613
1886
  donePath,
1614
1887
  lane.tmuxSessionName,
@@ -1654,7 +1927,7 @@ export async function monitorLanes(
1654
1927
  allTerminal = false;
1655
1928
  }
1656
1929
 
1657
- const sessionAlive = tmuxHasSession(lane.tmuxSessionName);
1930
+ const sessionAlive = await tmuxHasSessionAsync(lane.tmuxSessionName);
1658
1931
 
1659
1932
  laneSnapshots.push({
1660
1933
  laneId: lane.laneId,
@@ -1718,7 +1991,7 @@ export async function monitorLanes(
1718
1991
  laneId: lane.laneId,
1719
1992
  laneNumber: lane.laneNumber,
1720
1993
  sessionName: lane.tmuxSessionName,
1721
- sessionAlive: tmuxHasSession(lane.tmuxSessionName),
1994
+ sessionAlive: false, // Best-effort during pause — don't block with tmux call
1722
1995
  currentTaskId: null,
1723
1996
  currentTaskSnapshot: null,
1724
1997
  completedTasks: [],