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.
- package/dashboard/public/app.js +108 -55
- package/dashboard/public/style.css +18 -0
- package/dashboard/server.cjs +29 -3
- package/extensions/task-runner.ts +170 -21
- package/extensions/taskplane/cleanup.ts +30 -9
- package/extensions/taskplane/engine.ts +11 -12
- package/extensions/taskplane/execution.ts +20 -3
- package/extensions/taskplane/extension.ts +499 -8
- package/extensions/taskplane/supervisor-primer.md +6 -0
- package/package.json +1 -1
package/dashboard/public/app.js
CHANGED
|
@@ -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
|
|
646
|
-
const getMergeSessionName = (
|
|
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
|
-
//
|
|
675
|
-
// Merge sessions
|
|
676
|
-
//
|
|
677
|
-
const
|
|
678
|
-
const
|
|
679
|
-
const
|
|
680
|
-
|
|
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
|
-
//
|
|
759
|
+
// Find merge telemetry: try sessions by lane number first
|
|
683
760
|
let mergeTel = null;
|
|
684
|
-
for (const
|
|
685
|
-
|
|
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
|
|
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
|
|
692
|
-
//
|
|
693
|
-
html += `<td
|
|
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 (
|
|
710
|
-
const cmd = `tmux attach -t ${
|
|
711
|
-
html += `<span class="tmux-cmd" data-tmux="${escapeHtml(
|
|
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
|
|
780
|
+
html += '<span class="merge-no-data">—</span>';
|
|
714
781
|
}
|
|
715
782
|
html += `</td>`;
|
|
716
|
-
html += `<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
|
|
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
|
|
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
|
|
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
|
|
754
|
-
//
|
|
755
|
-
html += `<td
|
|
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 {
|
package/dashboard/server.cjs
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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 % —
|
|
2585
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2465
2586
|
if (delta.contextUsage) {
|
|
2466
|
-
state.reviewerContextPct = delta.contextUsage.
|
|
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 % —
|
|
2790
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2672
2791
|
if (delta.contextUsage) {
|
|
2673
|
-
state.reviewerContextPct = delta.contextUsage.
|
|
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
|
-
|
|
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 % —
|
|
3299
|
-
//
|
|
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();
|