taskplane 0.22.12 → 0.22.13

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.
@@ -621,6 +621,58 @@ function renderLanesTasks(batch, tmuxSessions) {
621
621
 
622
622
  // ─── Render: Merge Agents ───────────────────────────────────────────────────
623
623
 
624
+ /** Build full telemetry HTML for a merge agent (parity with worker stats).
625
+ * Shows: elapsed, tool count, context %, cost, current tool, retry/compaction badges.
626
+ * Returns empty string if no meaningful telemetry exists.
627
+ */
628
+ function mergeTelemetryHtml(tel, alive) {
629
+ if (!tel) return '<span class="merge-no-data">—</span>';
630
+ const hasData = (tel.inputTokens || 0) > 0 || (tel.outputTokens || 0) > 0 ||
631
+ (tel.toolCalls || 0) > 0 || (tel.cost || 0) > 0;
632
+ if (!hasData) return '<span class="merge-no-data">—</span>';
633
+
634
+ let html = '<div class="merge-stats">';
635
+
636
+ // Elapsed time
637
+ if (tel.startedAt) {
638
+ const elapsed = Date.now() - tel.startedAt;
639
+ html += `<span class="worker-stat" title="Merge elapsed">⏱ ${formatDuration(elapsed)}</span>`;
640
+ }
641
+
642
+ // Tool calls
643
+ if (tel.toolCalls > 0) {
644
+ html += `<span class="worker-stat" title="Tool calls">🔧 ${tel.toolCalls}</span>`;
645
+ }
646
+
647
+ // Context %
648
+ if (tel.contextPct > 0) {
649
+ html += `<span class="worker-stat" title="Context window used">📊 ${Math.round(tel.contextPct)}%</span>`;
650
+ }
651
+
652
+ // Tokens + cost
653
+ const inp = (tel.inputTokens || 0) + (tel.cacheReadTokens || 0);
654
+ const out = tel.outputTokens || 0;
655
+ const cost = tel.cost || 0;
656
+ if (inp > 0 || out > 0) {
657
+ let tokenStr = `↑${formatTokens(inp)} ↓${formatTokens(out)}`;
658
+ if (cost > 0) tokenStr += ` ${formatCost(cost)}`;
659
+ html += `<span class="worker-stat" title="Tokens">🪙 ${tokenStr}</span>`;
660
+ }
661
+
662
+ // Current tool (if alive/active) or last tool (completed merges)
663
+ if (alive && tel.currentTool) {
664
+ html += `<span class="worker-stat worker-last-tool" title="Current tool">${escapeHtml(tel.currentTool)}</span>`;
665
+ } else if (!alive && tel.lastTool) {
666
+ html += `<span class="worker-stat worker-last-tool" title="Last tool">${escapeHtml(tel.lastTool)}</span>`;
667
+ }
668
+
669
+ // Retry/compaction badges (reuse shared helper)
670
+ html += telemetryBadgesHtml(tel);
671
+
672
+ html += '</div>';
673
+ return html;
674
+ }
675
+
624
676
  function renderMergeAgents(batch, tmuxSessions) {
625
677
  const mergeResults = batch?.mergeResults || [];
626
678
  const tmuxSet = new Set(tmuxSessions || []);
@@ -642,8 +694,8 @@ function renderMergeAgents(batch, tmuxSessions) {
642
694
  mergePrefix = laneMatch[1] + "-merge";
643
695
  }
644
696
  }
645
- // Helper: get merge session name for a lane number
646
- const getMergeSessionName = (laneNum) => `${mergePrefix}-${laneNum}`;
697
+ // Helper: get merge session name for a merge number
698
+ const getMergeSessionName = (mergeNum) => `${mergePrefix}-${mergeNum}`;
647
699
 
648
700
  if (mergeResults.length === 0 && mergeSessions.length === 0) {
649
701
  $mergeBody.innerHTML = '<div class="empty-state">No merge agents active</div>';
@@ -671,49 +723,64 @@ function renderMergeAgents(batch, tmuxSessions) {
671
723
  : mr.status === "partial" ? "status-stalled"
672
724
  : "status-failed";
673
725
 
674
- // Look for matching tmux merge sessions for this wave result.
675
- // Merge sessions follow the naming pattern: {prefix}-{opId}-merge-{laneNumber}
676
- // (e.g., "orch-henrylach-merge-1"). Find any alive merge sessions.
677
- const waveMergeSessions = mergeSessions.filter(s => tmuxSet.has(s));
678
- const sessionName = waveMergeSessions.length > 0 ? waveMergeSessions[0] : null;
679
- const alive = sessionName !== null;
680
- if (alive) shownSessions.add(sessionName);
726
+ // Merge session mapping: derive from lane numbers involved in this wave.
727
+ // Merge sessions are named by lane number (e.g., ...-merge-1), not wave index.
728
+ // Extract lane numbers from repoResults or from batch tasks for this wave.
729
+ const waveLaneNums = new Set();
730
+ const repoResults2 = mr.repoResults || [];
731
+ for (const rr of repoResults2) {
732
+ for (const ln of (rr.laneNumbers || [])) waveLaneNums.add(ln);
733
+ }
734
+ // Fallback: find lane numbers from tasks assigned to this wave
735
+ if (waveLaneNums.size === 0 && batch.wavePlan && batch.wavePlan[mr.waveIndex]) {
736
+ const waveTaskIds = new Set(batch.wavePlan[mr.waveIndex]);
737
+ for (const t of (batch.tasks || [])) {
738
+ if (waveTaskIds.has(t.taskId) && t.laneNumber != null) {
739
+ waveLaneNums.add(t.laneNumber);
740
+ }
741
+ }
742
+ }
743
+ // Find alive merge sessions matching the wave's lane numbers
744
+ let effectiveSession = null;
745
+ for (const ln of waveLaneNums) {
746
+ const candidate = getMergeSessionName(ln);
747
+ if (tmuxSet.has(candidate) && !shownSessions.has(candidate)) {
748
+ effectiveSession = candidate;
749
+ break;
750
+ }
751
+ }
752
+ // Fallback: any unshown alive merge session
753
+ if (!effectiveSession) {
754
+ effectiveSession = mergeSessions.find(s => tmuxSet.has(s) && !shownSessions.has(s)) || null;
755
+ }
756
+ const effectiveAlive = !!effectiveSession;
757
+ if (effectiveSession) shownSessions.add(effectiveSession);
681
758
 
682
- // Look for merge telemetry data check all merge sessions
759
+ // Find merge telemetry: try sessions by lane number first
683
760
  let mergeTel = null;
684
- for (const ms of mergeSessions) {
685
- if (telemetry[ms]) { mergeTel = telemetry[ms]; break; }
761
+ for (const ln of waveLaneNums) {
762
+ const candidate = getMergeSessionName(ln);
763
+ if (telemetry[candidate]) { mergeTel = telemetry[candidate]; break; }
686
764
  }
765
+ // Fallback: effective session telemetry or any merge session
766
+ if (!mergeTel && effectiveSession) mergeTel = telemetry[effectiveSession] || null;
767
+ if (!mergeTel) mergeTel = mergeSessions.reduce((found, ms) => found || telemetry[ms] || null, null);
687
768
 
688
769
  html += `<tr>`;
689
- html += `<td style="font-family:var(--font-mono);">Wave ${mr.waveIndex + 1}</td>`;
770
+ html += `<td class="merge-wave-cell">Wave ${mr.waveIndex + 1}</td>`;
690
771
  html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
691
- html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${alive ? escapeHtml(sessionName) : "—"}</td>`;
692
- // Telemetry cell
693
- html += `<td style="font-size:0.75rem;">`;
694
- if (mergeTel) {
695
- const totalTok = (mergeTel.inputTokens || 0) + (mergeTel.outputTokens || 0);
696
- const cost = mergeTel.cost || 0;
697
- if (totalTok > 0 || cost > 0) {
698
- html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
699
- if (cost > 0) html += ` · $${cost.toFixed(4)}`;
700
- html += `</span>`;
701
- } else {
702
- html += '<span style="color:var(--text-faint);">—</span>';
703
- }
704
- } else {
705
- html += '<span style="color:var(--text-faint);">—</span>';
706
- }
707
- html += `</td>`;
772
+ html += `<td class="merge-session-cell">${effectiveAlive ? escapeHtml(effectiveSession) : "—"}</td>`;
773
+ // Full telemetry cell
774
+ html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
708
775
  html += `<td>`;
709
- if (alive) {
710
- const cmd = `tmux attach -t ${sessionName}`;
711
- html += `<span class="tmux-cmd" data-tmux="${escapeHtml(sessionName)}" onclick="copyTmuxCmd('${escapeHtml(sessionName)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
776
+ if (effectiveAlive) {
777
+ const cmd = `tmux attach -t ${effectiveSession}`;
778
+ html += `<span class="tmux-cmd" data-tmux="${escapeHtml(effectiveSession)}" onclick="copyTmuxCmd('${escapeHtml(effectiveSession)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
712
779
  } else {
713
- html += '<span style="color:var(--text-faint);">—</span>';
780
+ html += '<span class="merge-no-data">—</span>';
714
781
  }
715
782
  html += `</td>`;
716
- html += `<td style="font-size:0.8rem;color:var(--text-muted);">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
783
+ html += `<td class="merge-detail-cell">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
717
784
  html += `</tr>`;
718
785
 
719
786
  // Per-repo sub-rows: show when workspace mode has repo results
@@ -732,10 +799,10 @@ function renderMergeAgents(batch, tmuxSessions) {
732
799
  html += `<tr class="merge-repo-row">`;
733
800
  html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
734
801
  html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
735
- html += `<td style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-faint);">${rrLanes}</td>`;
802
+ html += `<td class="merge-session-cell">${rrLanes}</td>`;
736
803
  html += `<td></td>`; /* telemetry placeholder */
737
804
  html += `<td></td>`; /* attach placeholder */
738
- html += `<td style="font-size:0.75rem;color:var(--text-faint);">${rrDetail}</td>`;
805
+ html += `<td class="merge-detail-cell">${rrDetail}</td>`;
739
806
  html += `</tr>`;
740
807
  }
741
808
  }
@@ -748,25 +815,11 @@ function renderMergeAgents(batch, tmuxSessions) {
748
815
  const sessTel = telemetry[sess] || null;
749
816
  const cmd = `tmux attach -t ${sess}`;
750
817
  html += `<tr>`;
751
- html += `<td style="font-family:var(--font-mono);">—</td>`;
818
+ html += `<td class="merge-wave-cell">—</td>`;
752
819
  html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
753
- html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${escapeHtml(sess)}</td>`;
754
- // Telemetry cell for active merge session
755
- html += `<td style="font-size:0.75rem;">`;
756
- if (sessTel) {
757
- const totalTok = (sessTel.inputTokens || 0) + (sessTel.outputTokens || 0);
758
- const cost = sessTel.cost || 0;
759
- if (totalTok > 0 || cost > 0) {
760
- html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
761
- if (cost > 0) html += ` · $${cost.toFixed(4)}`;
762
- html += `</span>`;
763
- } else {
764
- html += '<span style="color:var(--text-faint);">—</span>';
765
- }
766
- } else {
767
- html += '<span style="color:var(--text-faint);">—</span>';
768
- }
769
- html += `</td>`;
820
+ html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
821
+ // Full telemetry cell for active merge session
822
+ html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
770
823
  html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
771
824
  html += `<td>—</td>`;
772
825
  html += `</tr>`;
@@ -712,6 +712,24 @@ body {
712
712
  .merge-table tr:last-child td { border-bottom: none; }
713
713
  .merge-table tr:hover td { background: var(--bg-surface-hover); }
714
714
 
715
+ .merge-wave-cell { font-family: var(--font-mono); }
716
+ .merge-session-cell { font-family: var(--font-mono); font-size: 0.8rem; }
717
+ .merge-detail-cell { font-size: 0.8rem; color: var(--text-muted); }
718
+ .merge-no-data { color: var(--text-faint); }
719
+
720
+ .merge-stats {
721
+ display: flex;
722
+ flex-wrap: wrap;
723
+ gap: 4px 8px;
724
+ align-items: center;
725
+ font-size: 0.75rem;
726
+ }
727
+
728
+ .merge-telemetry-cell {
729
+ font-size: 0.75rem;
730
+ min-width: 120px;
731
+ }
732
+
715
733
  /* ─── Terminal Panel ────────────────────────────────────────────────────── */
716
734
 
717
735
  .terminal-panel .panel-header {
@@ -427,8 +427,9 @@ function loadTelemetryData(batchState) {
427
427
  const fresh = {
428
428
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0,
429
429
  cacheWriteTokens: 0, cost: 0, toolCalls: 0,
430
- lastTool: "", retries: 0, retryActive: false,
430
+ lastTool: "", currentTool: "", retries: 0, retryActive: false,
431
431
  lastRetryError: "", compactions: 0, latestTotalTokens: 0,
432
+ contextPct: 0, startedAt: 0,
432
433
  };
433
434
  telemetryAccumulators.set(prefix, fresh);
434
435
  // Also reset tail states for ALL files of this prefix to re-read from beginning
@@ -450,8 +451,9 @@ function loadTelemetryData(batchState) {
450
451
  if (ts && ts.wasReset) {
451
452
  acc.inputTokens = 0; acc.outputTokens = 0; acc.cacheReadTokens = 0;
452
453
  acc.cacheWriteTokens = 0; acc.cost = 0; acc.toolCalls = 0;
453
- acc.lastTool = ""; acc.retries = 0; acc.retryActive = false;
454
+ acc.lastTool = ""; acc.currentTool = ""; acc.retries = 0; acc.retryActive = false;
454
455
  acc.lastRetryError = ""; acc.compactions = 0; acc.latestTotalTokens = 0;
456
+ acc.contextPct = 0; acc.startedAt = 0;
455
457
  ts.wasReset = false;
456
458
  }
457
459
  for (const event of events) {
@@ -497,7 +499,31 @@ function loadTelemetryData(batchState) {
497
499
  }
498
500
  }
499
501
  }
500
- acc.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
502
+ const toolLabel = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
503
+ acc.lastTool = toolLabel;
504
+ acc.currentTool = toolLabel;
505
+ break;
506
+ }
507
+ case "tool_execution_end": {
508
+ acc.currentTool = "";
509
+ break;
510
+ }
511
+ case "agent_start": {
512
+ if (event.ts && !acc.startedAt) {
513
+ acc.startedAt = event.ts;
514
+ }
515
+ break;
516
+ }
517
+ case "response": {
518
+ // Extract context usage from get_session_stats responses
519
+ const ctxUsage = event.data?.contextUsage;
520
+ if (ctxUsage) {
521
+ // Support both percent (current) and percentUsed (legacy pi versions)
522
+ const pct = typeof ctxUsage.percent === "number" ? ctxUsage.percent
523
+ : typeof ctxUsage.percentUsed === "number" ? ctxUsage.percentUsed
524
+ : null;
525
+ if (pct !== null) acc.contextPct = pct;
526
+ }
501
527
  break;
502
528
  }
503
529
  case "auto_retry_start": {
@@ -456,6 +456,34 @@ function writeLaneState(state: TaskState): void {
456
456
  }
457
457
  }
458
458
 
459
+ /**
460
+ * Write a context % snapshot at worker iteration boundary (TP-094).
461
+ * Best-effort JSONL append to `.pi/context-snapshots/{batchId}/{sessionName}.jsonl`.
462
+ * Non-fatal on any failure — never blocks execution.
463
+ */
464
+ function writeContextSnapshot(state: TaskState, contextWindow: number): void {
465
+ const batchId = process.env.ORCH_BATCH_ID || "standalone";
466
+ const sessionName = isOrchestratedMode() ? `${getTmuxPrefix()}-worker` : "task-worker";
467
+ try {
468
+ const dir = join(getSidecarDir(), "context-snapshots", batchId);
469
+ mkdirSync(dir, { recursive: true });
470
+ const filePath = join(dir, `${sessionName}.jsonl`);
471
+ const snapshot = {
472
+ iteration: state.totalIterations,
473
+ contextPct: state.workerContextPct,
474
+ tokens: state.workerInputTokens + state.workerOutputTokens + state.workerCacheReadTokens + state.workerCacheWriteTokens,
475
+ contextWindow,
476
+ cost: state.workerCostUsd,
477
+ toolCalls: state.workerToolCount,
478
+ exitReason: state.workerExitDiagnostic?.classification || null,
479
+ timestamp: Date.now(),
480
+ };
481
+ appendFileSync(filePath, JSON.stringify(snapshot) + "\n");
482
+ } catch {
483
+ // Best effort — don't crash the runner
484
+ }
485
+ }
486
+
459
487
  /**
460
488
  * Append a JSON event to the conversation JSONL log file.
461
489
  * Used in orchestrated mode to capture the full worker conversation for the web dashboard.
@@ -1371,7 +1399,9 @@ interface SidecarTelemetryDelta {
1371
1399
  /** Whether any sidecar events were parsed in this tick (used for callback gating) */
1372
1400
  hadEvents: boolean;
1373
1401
  /** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
1374
- contextUsage: { percentUsed: number; totalTokens: number; maxTokens: number } | null;
1402
+ contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
1403
+ /** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
1404
+ sawStatsResponseWithoutContextUsage: boolean;
1375
1405
  }
1376
1406
 
1377
1407
  /**
@@ -1390,7 +1420,7 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1390
1420
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
1391
1421
  cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
1392
1422
  retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
1393
- hadEvents: false, contextUsage: null,
1423
+ hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
1394
1424
  };
1395
1425
 
1396
1426
  // Gracefully handle missing file (wrapper hasn't written yet)
@@ -1506,13 +1536,18 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1506
1536
  // get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
1507
1537
  if (event.success === true && event.data?.contextUsage) {
1508
1538
  const cu = event.data.contextUsage;
1509
- if (typeof cu.percentUsed === "number") {
1539
+ // pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
1540
+ const pctValue = cu.percent ?? cu.percentUsed;
1541
+ if (typeof pctValue === "number") {
1510
1542
  delta.contextUsage = {
1511
- percentUsed: cu.percentUsed,
1543
+ percent: pctValue,
1512
1544
  totalTokens: cu.totalTokens || 0,
1513
1545
  maxTokens: cu.maxTokens || 0,
1514
1546
  };
1515
1547
  }
1548
+ } else if (event.success === true && event.data && !event.data.contextUsage) {
1549
+ // Successful get_session_stats response but no contextUsage — older pi
1550
+ delta.sawStatsResponseWithoutContextUsage = true;
1516
1551
  }
1517
1552
  break;
1518
1553
  }
@@ -1656,6 +1691,25 @@ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
1656
1691
 
1657
1692
  // ── TMUX Agent Spawner ───────────────────────────────────────────────
1658
1693
 
1694
+ /**
1695
+ * Synchronous sleep helper for tmux spawn stabilization checks.
1696
+ *
1697
+ * Uses Atomics.wait for cross-platform blocking delays without relying on
1698
+ * shell `sleep` availability (important on Windows environments).
1699
+ */
1700
+ function sleepSyncMs(ms: number): void {
1701
+ if (!Number.isFinite(ms) || ms <= 0) return;
1702
+ try {
1703
+ const arr = new Int32Array(new SharedArrayBuffer(4));
1704
+ Atomics.wait(arr, 0, 0, Math.floor(ms));
1705
+ } catch {
1706
+ const start = Date.now();
1707
+ while (Date.now() - start < ms) {
1708
+ // Busy-wait fallback (rare path)
1709
+ }
1710
+ }
1711
+ }
1712
+
1659
1713
  /**
1660
1714
  * Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
1661
1715
  * Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
@@ -1890,6 +1944,73 @@ function spawnAgentTmux(opts: {
1890
1944
  );
1891
1945
  }
1892
1946
 
1947
+ // ── TP-095: Post-spawn verification with retry (#335) ──────────
1948
+ // On Windows/MSYS2, rapid sequential tmux session creation is unreliable.
1949
+ // Pi process can exit with code 1 in 0 seconds on the first 3-5 attempts.
1950
+ // Verify the session is alive after a brief delay, and retry if it died.
1951
+ const SPAWN_VERIFY_DELAY_MS = 300;
1952
+ const SPAWN_VERIFY_POLL_ATTEMPTS = 3;
1953
+ const SPAWN_VERIFY_POLL_INTERVAL_MS = 200;
1954
+ const SPAWN_MAX_RETRIES = 2;
1955
+
1956
+ const verifySessionAlive = (): boolean => {
1957
+ for (let poll = 0; poll < SPAWN_VERIFY_POLL_ATTEMPTS; poll++) {
1958
+ const check = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
1959
+ if (check.status === 0) return true;
1960
+ if (poll < SPAWN_VERIFY_POLL_ATTEMPTS - 1) {
1961
+ sleepSyncMs(SPAWN_VERIFY_POLL_INTERVAL_MS);
1962
+ }
1963
+ }
1964
+ return false;
1965
+ };
1966
+
1967
+ // Wait briefly for session to stabilize, then verify
1968
+ sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
1969
+
1970
+ // Derive the stderr log path for diagnostic messages (mirrors execution.ts convention)
1971
+ const stderrLogHint = `${sidecarPath.replace(/\.jsonl$/, "-stderr.log")}`;
1972
+
1973
+ let spawnRetries = 0;
1974
+ while (!verifySessionAlive() && spawnRetries < SPAWN_MAX_RETRIES) {
1975
+ spawnRetries++;
1976
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' died on startup — retrying (${spawnRetries}/${SPAWN_MAX_RETRIES}). Stderr log: ${stderrLogHint}`);
1977
+
1978
+ // Brief delay before retry (increases with each attempt)
1979
+ const retryDelay = spawnRetries * 500;
1980
+ sleepSyncMs(retryDelay);
1981
+
1982
+ // Kill any remnant and re-create
1983
+ spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
1984
+
1985
+ const retryResult = spawnSync("tmux", [
1986
+ "new-session", "-d",
1987
+ "-s", opts.sessionName,
1988
+ wrappedCommand,
1989
+ ]);
1990
+
1991
+ if (retryResult.status !== 0) {
1992
+ const retryStderr = retryResult.stderr?.toString().trim() || "unknown error";
1993
+ console.error(`[task-runner] tmux: retry ${spawnRetries} session creation failed: ${retryStderr}`);
1994
+ continue;
1995
+ }
1996
+
1997
+ // Wait for the retried session to stabilize
1998
+ sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
1999
+ }
2000
+
2001
+ if (spawnRetries > 0) {
2002
+ const finalAlive = verifySessionAlive();
2003
+ if (!finalAlive) {
2004
+ cleanupTmp();
2005
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' failed after ${SPAWN_MAX_RETRIES} retries. Stderr log: ${stderrLogHint}`);
2006
+ throw new Error(
2007
+ `TMUX session '${opts.sessionName}' died on startup after ${SPAWN_MAX_RETRIES} retries. ` +
2008
+ `Stderr log: ${stderrLogHint}`
2009
+ );
2010
+ }
2011
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' alive after ${spawnRetries} retry(ies)`);
2012
+ }
2013
+
1893
2014
  console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
1894
2015
 
1895
2016
 
@@ -2461,11 +2582,9 @@ export default function (pi: ExtensionAPI) {
2461
2582
  state.reviewerLastTool = delta.lastTool;
2462
2583
  }
2463
2584
 
2464
- // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2585
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
2465
2586
  if (delta.contextUsage) {
2466
- state.reviewerContextPct = delta.contextUsage.percentUsed;
2467
- } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2468
- state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2587
+ state.reviewerContextPct = delta.contextUsage.percent;
2469
2588
  }
2470
2589
 
2471
2590
  writeLaneState(state);
@@ -2668,11 +2787,9 @@ export default function (pi: ExtensionAPI) {
2668
2787
  state.reviewerCostUsd += delta.cost;
2669
2788
  state.reviewerToolCount += delta.toolCalls;
2670
2789
  if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
2671
- // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2790
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
2672
2791
  if (delta.contextUsage) {
2673
- state.reviewerContextPct = delta.contextUsage.percentUsed;
2674
- } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2675
- state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2792
+ state.reviewerContextPct = delta.contextUsage.percent;
2676
2793
  }
2677
2794
  writeLaneState(state);
2678
2795
  updateWidgets();
@@ -2823,8 +2940,35 @@ export default function (pi: ExtensionAPI) {
2823
2940
  if (isStepComplete(ss)) completedBefore.add(ss.number);
2824
2941
  }
2825
2942
 
2943
+ // ── TP-095: Reset stale lane-state fields before new worker spawn (#333) ──
2944
+ // When a worker crashes and restarts, the lane-state JSON retains stale
2945
+ // values (workerStatus: "done", phase: "error", workerExitDiagnostic from
2946
+ // the crash). Reset STATUS fields BEFORE the new worker spawns so the
2947
+ // dashboard immediately reflects the new running state.
2948
+ // IMPORTANT: Do NOT reset telemetry counters (tokens, cost) here — they
2949
+ // accumulate across worker iterations via += in onTelemetry (#334).
2950
+ if (state.totalIterations > 1) {
2951
+ state.phase = "running";
2952
+ state.workerStatus = "idle"; // Will be set to "running" by runWorker()
2953
+ state.workerExitDiagnostic = null;
2954
+ state.workerElapsed = 0;
2955
+ state.workerContextPct = 0;
2956
+ state.workerLastTool = "";
2957
+ state.workerRetryActive = false;
2958
+ state.workerRetryCount = 0;
2959
+ state.workerLastRetryError = "";
2960
+ // Note: workerToolCount, workerInputTokens, workerOutputTokens,
2961
+ // workerCacheReadTokens, workerCacheWriteTokens, workerCostUsd
2962
+ // are intentionally NOT reset — they persist across iterations.
2963
+ writeLaneState(state);
2964
+ }
2965
+
2826
2966
  await runWorker(remainingSteps, ctx);
2827
2967
 
2968
+ // Write context % snapshot at iteration boundary (TP-094)
2969
+ const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
2970
+ writeContextSnapshot(state, snapshotContextWindow);
2971
+
2828
2972
  if (state.phase === "error") {
2829
2973
  await shutdownPersistentReviewer("worker error");
2830
2974
  return;
@@ -3229,7 +3373,10 @@ export default function (pi: ExtensionAPI) {
3229
3373
  state.workerElapsed = 0;
3230
3374
  state.workerContextPct = 0;
3231
3375
  state.workerLastTool = "";
3232
- state.workerToolCount = 0;
3376
+ // TP-095: Don't reset workerToolCount accumulate across iterations (#334).
3377
+ // Previous behavior zeroed the counter on each iteration, losing totals
3378
+ // when a worker crashed and restarted. Token/cost counters already
3379
+ // accumulate via += in onTelemetry and were never reset here.
3233
3380
  state.workerRetryActive = false;
3234
3381
  state.workerRetryCount = 0;
3235
3382
  state.workerLastRetryError = "";
@@ -3257,6 +3404,8 @@ export default function (pi: ExtensionAPI) {
3257
3404
  const warnPct = config.context.warn_percent;
3258
3405
  const killPct = config.context.kill_percent;
3259
3406
  console.error(`[task-runner] worker context window: ${contextWindow} (${contextWindowSource})`);
3407
+ // One-shot warning when pi doesn't provide authoritative contextUsage (TP-094)
3408
+ let warnedNoContextUsage = false;
3260
3409
 
3261
3410
  if (spawnMode === "tmux") {
3262
3411
  // ── TMUX mode ────────────────────────────────────────
@@ -3295,14 +3444,10 @@ export default function (pi: ExtensionAPI) {
3295
3444
  state.workerLastRetryError = delta.lastRetryError;
3296
3445
  }
3297
3446
 
3298
- // Context % — prefer authoritative contextUsage from pi ≥ 0.63.0,
3299
- // fall back to manual calculation from totalTokens + cacheRead.
3300
- {
3301
- const pct = delta.contextUsage
3302
- ? delta.contextUsage.percentUsed
3303
- : (delta.latestTotalTokens > 0 && contextWindow > 0)
3304
- ? (delta.latestTotalTokens / contextWindow) * 100
3305
- : 0;
3447
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
3448
+ // Manual token-based fallback removed: avoids false thresholds on older pi.
3449
+ if (delta.contextUsage) {
3450
+ const pct = delta.contextUsage.percent;
3306
3451
  if (pct > 0) {
3307
3452
  state.workerContextPct = pct;
3308
3453
  if (pct >= warnPct) {
@@ -3314,6 +3459,10 @@ export default function (pi: ExtensionAPI) {
3314
3459
  spawned.kill();
3315
3460
  }
3316
3461
  }
3462
+ } else if (delta.sawStatsResponseWithoutContextUsage && !warnedNoContextUsage) {
3463
+ // One-shot warning: pi responded to get_session_stats but omitted contextUsage (older pi)
3464
+ warnedNoContextUsage = true;
3465
+ console.error(`[task-runner] warning: pi did not provide contextUsage — context pressure thresholds disabled`);
3317
3466
  }
3318
3467
 
3319
3468
  updateWidgets();