taskplane 0.24.5 → 0.24.6
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/bin/rpc-wrapper.mjs +4 -4
- package/dashboard/public/app.js +41 -26
- package/dashboard/public/style.css +12 -12
- package/dashboard/server.cjs +25 -87
- package/extensions/task-orchestrator.ts +1 -1
- package/extensions/taskplane/agent-host.ts +11 -5
- package/extensions/taskplane/engine-worker.ts +71 -6
- package/extensions/taskplane/extension.ts +92 -4
- package/extensions/taskplane/lane-runner.ts +22 -2
- package/package.json +1 -1
- package/templates/config/task-runner.yaml +2 -3
package/bin/rpc-wrapper.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Spawns pi in RPC mode, sends a prompt, captures RPC events to a sidecar JSONL
|
|
7
7
|
* file, and writes a final exit summary JSON on process exit. Displays minimal
|
|
8
|
-
* live progress on stderr for
|
|
8
|
+
* live progress on stderr for dashboard/session visibility.
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
11
11
|
* node bin/rpc-wrapper.mjs \
|
|
@@ -271,7 +271,7 @@ function writeSidecarEvent(sidecarPath, event) {
|
|
|
271
271
|
// ── Progress Display ─────────────────────────────────────────────────
|
|
272
272
|
|
|
273
273
|
/**
|
|
274
|
-
* Display minimal progress on stderr for
|
|
274
|
+
* Display minimal progress on stderr for dashboard/session visibility.
|
|
275
275
|
*/
|
|
276
276
|
function displayProgress(state) {
|
|
277
277
|
const parts = [];
|
|
@@ -774,8 +774,8 @@ piArgs.push(...args.passthrough);
|
|
|
774
774
|
// Windows CreateProcess has a ~32K command line limit. Orchestrated worker
|
|
775
775
|
// system prompts routinely exceed this (PROMPT.md + context docs + steps).
|
|
776
776
|
// When the system prompt is large, write it to a temp file and use shell
|
|
777
|
-
// expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash
|
|
778
|
-
//
|
|
777
|
+
// expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash shells
|
|
778
|
+
// used by lane sessions without hitting the Win32 limit.
|
|
779
779
|
//
|
|
780
780
|
// For small system prompts (< 8K), pass inline for simplicity.
|
|
781
781
|
const SYSTEM_PROMPT_FILE_THRESHOLD = 8192;
|
package/dashboard/public/app.js
CHANGED
|
@@ -126,6 +126,20 @@ function tokenSummaryFromLaneState(ls) {
|
|
|
126
126
|
return s;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
function tokenSummaryFromReviewerLaneState(ls) {
|
|
130
|
+
if (!ls) return "";
|
|
131
|
+
const inp = ls.reviewerInputTokens || 0;
|
|
132
|
+
const out = ls.reviewerOutputTokens || 0;
|
|
133
|
+
const cr = ls.reviewerCacheReadTokens || 0;
|
|
134
|
+
const cw = ls.reviewerCacheWriteTokens || 0;
|
|
135
|
+
const cost = ls.reviewerCostUsd || 0;
|
|
136
|
+
const totalIn = inp + cr; // uncached + cached = total input processed
|
|
137
|
+
if (totalIn === 0 && out === 0) return "";
|
|
138
|
+
let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
|
|
139
|
+
if (cost > 0) s += ` ${formatCost(cost)}`;
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
/** Build compact telemetry badge HTML for retry/compaction indicators.
|
|
130
144
|
* Only shows badges when telemetry data has meaningful values.
|
|
131
145
|
* @param {object|null} tel - Telemetry data for a lane (from currentData.telemetry[prefix])
|
|
@@ -311,10 +325,10 @@ $repoFilter.addEventListener("change", (e) => {
|
|
|
311
325
|
// Re-render with current data
|
|
312
326
|
if (currentData) {
|
|
313
327
|
const batch = currentData.batch;
|
|
314
|
-
const
|
|
328
|
+
const sessions = currentData.sessions ?? currentData.tmuxSessions ?? [];
|
|
315
329
|
if (batch) {
|
|
316
|
-
renderLanesTasks(batch,
|
|
317
|
-
renderMergeAgents(batch,
|
|
330
|
+
renderLanesTasks(batch, sessions);
|
|
331
|
+
renderMergeAgents(batch, sessions);
|
|
318
332
|
}
|
|
319
333
|
}
|
|
320
334
|
});
|
|
@@ -496,14 +510,14 @@ function renderSummary(batch) {
|
|
|
496
510
|
|
|
497
511
|
// ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
|
|
498
512
|
|
|
499
|
-
function renderLanesTasks(batch,
|
|
513
|
+
function renderLanesTasks(batch, sessions) {
|
|
500
514
|
if (!batch || !batch.lanes || batch.lanes.length === 0) {
|
|
501
515
|
$lanesTasksBody.innerHTML = '<div class="empty-state">No lanes</div>';
|
|
502
516
|
return;
|
|
503
517
|
}
|
|
504
518
|
|
|
505
519
|
const tasks = batch.tasks || [];
|
|
506
|
-
const
|
|
520
|
+
const sessionSet = new Set(sessions || []);
|
|
507
521
|
const laneStates = currentData?.laneStates || {};
|
|
508
522
|
const telemetry = currentData?.telemetry || {};
|
|
509
523
|
// TP-107: V2 lane snapshots take precedence over legacy lane states when present
|
|
@@ -520,10 +534,10 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
520
534
|
if (!laneMatchesRepo) continue;
|
|
521
535
|
}
|
|
522
536
|
|
|
523
|
-
// TP-107: check V2 registry for liveness first, fall back to
|
|
537
|
+
// TP-107: check Runtime V2 registry for liveness first, fall back to session list
|
|
524
538
|
const laneSessionId = lane.laneSessionId;
|
|
525
539
|
const v2Alive = isLaneAliveV2(lane.laneNumber);
|
|
526
|
-
const alive = v2Alive !== null ? v2Alive :
|
|
540
|
+
const alive = v2Alive !== null ? v2Alive : sessionSet.has(laneSessionId);
|
|
527
541
|
const sessionChip = `session: ${laneSessionId}`;
|
|
528
542
|
|
|
529
543
|
// Lane header
|
|
@@ -538,14 +552,14 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
538
552
|
}
|
|
539
553
|
html += ` </div>`;
|
|
540
554
|
html += ` <div class="lane-right">`;
|
|
541
|
-
html += ` <span class="
|
|
555
|
+
html += ` <span class="session-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
|
|
542
556
|
// View button: shows conversation stream when available
|
|
543
557
|
const isViewingConv = viewerMode === 'conversation' && viewerTarget === laneSessionId;
|
|
544
|
-
html += ` <button class="
|
|
558
|
+
html += ` <button class="session-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
|
|
545
559
|
if (alive) {
|
|
546
|
-
html += ` <span class="
|
|
560
|
+
html += ` <span class="session-cmd" data-session="${escapeHtml(laneSessionId)}" onclick="copySessionId('${escapeHtml(laneSessionId)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
|
|
547
561
|
} else {
|
|
548
|
-
html += ` <span class="
|
|
562
|
+
html += ` <span class="session-cmd dead-session">${escapeHtml(sessionChip)}</span>`;
|
|
549
563
|
}
|
|
550
564
|
html += ` </div>`;
|
|
551
565
|
html += `</div>`;
|
|
@@ -659,7 +673,7 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
659
673
|
const rTools = ls.reviewerToolCount || 0;
|
|
660
674
|
const rCtx = ls.reviewerContextPct ? `${Math.round(ls.reviewerContextPct)}%` : "";
|
|
661
675
|
const rLastTool = ls.reviewerLastTool || "";
|
|
662
|
-
const
|
|
676
|
+
const rTokenStr = tokenSummaryFromReviewerLaneState(ls);
|
|
663
677
|
const rType = ls.reviewerType || "review";
|
|
664
678
|
const rStep = ls.reviewerStep || "?";
|
|
665
679
|
reviewerRowHtml = `
|
|
@@ -668,13 +682,14 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
668
682
|
<span class="task-actions"></span>
|
|
669
683
|
<span class="reviewer-label">📋 Reviewer</span>
|
|
670
684
|
<span class="reviewer-type">${escapeHtml(rType)} · Step ${rStep}</span>
|
|
671
|
-
<span class="task-duration"
|
|
685
|
+
<span class="task-duration"></span>
|
|
672
686
|
<span></span>
|
|
673
687
|
<span class="task-step">
|
|
674
688
|
<div class="worker-stats reviewer-stats">
|
|
689
|
+
<span class="worker-stat" title="Reviewer elapsed">⏱ ${rElapsed}</span>
|
|
675
690
|
<span class="worker-stat" title="Reviewer tool calls">🔧 ${rTools}</span>
|
|
676
691
|
${rCtx ? `<span class="worker-stat" title="Reviewer context used">📊 ${rCtx}</span>` : ""}
|
|
677
|
-
${
|
|
692
|
+
${rTokenStr ? `<span class="worker-stat" title="Reviewer tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${rTokenStr}</span>` : ""}
|
|
678
693
|
${rLastTool ? `<span class="worker-stat worker-last-tool" title="Reviewer last tool">${escapeHtml(rLastTool)}</span>` : ""}
|
|
679
694
|
</div>
|
|
680
695
|
</span>
|
|
@@ -759,14 +774,14 @@ function mergeTelemetryHtml(tel, alive) {
|
|
|
759
774
|
return html;
|
|
760
775
|
}
|
|
761
776
|
|
|
762
|
-
function renderMergeAgents(batch,
|
|
777
|
+
function renderMergeAgents(batch, sessions) {
|
|
763
778
|
const mergeResults = batch?.mergeResults || [];
|
|
764
|
-
const
|
|
779
|
+
const sessionSet = new Set(sessions || []);
|
|
765
780
|
const showRepos = knownRepos.length >= 2;
|
|
766
781
|
const telemetry = currentData?.telemetry || {};
|
|
767
782
|
|
|
768
783
|
// Check for active merge sessions (convention: {prefix}-{opId}-merge-{N})
|
|
769
|
-
const mergeSessions = (
|
|
784
|
+
const mergeSessions = (sessions || []).filter(s => s.includes("-merge-"));
|
|
770
785
|
|
|
771
786
|
// Derive merge session name from lane session naming pattern.
|
|
772
787
|
// Lane sessions: "{prefix}-{opId}-lane-{N}", merge sessions: "{prefix}-{opId}-merge-{N}".
|
|
@@ -830,14 +845,14 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
830
845
|
let effectiveSession = null;
|
|
831
846
|
for (const ln of waveLaneNums) {
|
|
832
847
|
const candidate = getMergeSessionName(ln);
|
|
833
|
-
if (
|
|
848
|
+
if (sessionSet.has(candidate) && !shownSessions.has(candidate)) {
|
|
834
849
|
effectiveSession = candidate;
|
|
835
850
|
break;
|
|
836
851
|
}
|
|
837
852
|
}
|
|
838
853
|
// Fallback: any unshown alive merge session
|
|
839
854
|
if (!effectiveSession) {
|
|
840
|
-
effectiveSession = mergeSessions.find(s =>
|
|
855
|
+
effectiveSession = mergeSessions.find(s => sessionSet.has(s) && !shownSessions.has(s)) || null;
|
|
841
856
|
}
|
|
842
857
|
const effectiveAlive = !!effectiveSession;
|
|
843
858
|
if (effectiveSession) shownSessions.add(effectiveSession);
|
|
@@ -861,7 +876,7 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
861
876
|
html += `<td>`;
|
|
862
877
|
if (effectiveAlive) {
|
|
863
878
|
const sessionChip = `session: ${effectiveSession}`;
|
|
864
|
-
html += `<span class="
|
|
879
|
+
html += `<span class="session-cmd" data-session="${escapeHtml(effectiveSession)}" onclick="copySessionId('${escapeHtml(effectiveSession)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
|
|
865
880
|
} else {
|
|
866
881
|
html += '<span class="merge-no-data">—</span>';
|
|
867
882
|
}
|
|
@@ -906,7 +921,7 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
906
921
|
html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
|
|
907
922
|
// Full telemetry cell for active merge session
|
|
908
923
|
html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
|
|
909
|
-
html += `<td><span class="
|
|
924
|
+
html += `<td><span class="session-cmd" data-session="${escapeHtml(sess)}" onclick="copySessionId('${escapeHtml(sess)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span></td>`;
|
|
910
925
|
html += `<td>—</td>`;
|
|
911
926
|
html += `</tr>`;
|
|
912
927
|
}
|
|
@@ -1374,7 +1389,7 @@ let currentData = null;
|
|
|
1374
1389
|
function render(data) {
|
|
1375
1390
|
currentData = data;
|
|
1376
1391
|
const batch = data.batch;
|
|
1377
|
-
const
|
|
1392
|
+
const sessions = data.sessions ?? data.tmuxSessions ?? [];
|
|
1378
1393
|
|
|
1379
1394
|
$lastUpdate.textContent = new Date().toLocaleTimeString();
|
|
1380
1395
|
|
|
@@ -1408,8 +1423,8 @@ function render(data) {
|
|
|
1408
1423
|
updateRepoFilter(repos);
|
|
1409
1424
|
|
|
1410
1425
|
renderSupervisor(data);
|
|
1411
|
-
renderLanesTasks(batch,
|
|
1412
|
-
renderMergeAgents(batch,
|
|
1426
|
+
renderLanesTasks(batch, sessions);
|
|
1427
|
+
renderMergeAgents(batch, sessions);
|
|
1413
1428
|
// TP-107: Runtime V2 panels
|
|
1414
1429
|
renderAgentsPanel(data.runtimeRegistry);
|
|
1415
1430
|
renderMessagesPanel(data.mailbox);
|
|
@@ -1477,7 +1492,7 @@ let lastStatusMdText = "";
|
|
|
1477
1492
|
// ── Open conversation viewer (TP-107: V2 events preferred, legacy fallback) ──
|
|
1478
1493
|
|
|
1479
1494
|
/**
|
|
1480
|
-
* Resolve a lane
|
|
1495
|
+
* Resolve a lane session ID to a Runtime V2 agent ID via the registry.
|
|
1481
1496
|
* Returns null if no V2 registry data is available.
|
|
1482
1497
|
*/
|
|
1483
1498
|
function resolveV2AgentId(sessionName) {
|
|
@@ -1485,7 +1500,7 @@ function resolveV2AgentId(sessionName) {
|
|
|
1485
1500
|
const agents = currentData.runtimeRegistry.agents;
|
|
1486
1501
|
// Direct match on agentId
|
|
1487
1502
|
if (agents[sessionName]) return sessionName;
|
|
1488
|
-
// Match by
|
|
1503
|
+
// Match by session ID prefix + "-worker" suffix (common V2 naming)
|
|
1489
1504
|
const workerKey = sessionName + '-worker';
|
|
1490
1505
|
if (agents[workerKey]) return workerKey;
|
|
1491
1506
|
// Search by laneNumber match from lane snapshots
|
|
@@ -134,8 +134,8 @@ body {
|
|
|
134
134
|
.status-badge,
|
|
135
135
|
.count-chip,
|
|
136
136
|
.wave-chip,
|
|
137
|
-
.
|
|
138
|
-
.
|
|
137
|
+
.session-view-btn,
|
|
138
|
+
.session-cmd,
|
|
139
139
|
.repo-filter-select,
|
|
140
140
|
.history-select,
|
|
141
141
|
.task-row,
|
|
@@ -513,17 +513,17 @@ body {
|
|
|
513
513
|
gap: 10px;
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
-
.
|
|
516
|
+
.session-dot {
|
|
517
517
|
width: 8px;
|
|
518
518
|
height: 8px;
|
|
519
519
|
border-radius: 50%;
|
|
520
520
|
flex-shrink: 0;
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
.
|
|
524
|
-
.
|
|
523
|
+
.session-dot.alive { background: var(--green); }
|
|
524
|
+
.session-dot.dead { background: var(--red); opacity: 0.6; }
|
|
525
525
|
|
|
526
|
-
.
|
|
526
|
+
.session-view-btn {
|
|
527
527
|
font-family: var(--font-sans);
|
|
528
528
|
font-size: 0.72rem;
|
|
529
529
|
font-weight: 600;
|
|
@@ -537,12 +537,12 @@ body {
|
|
|
537
537
|
transition: background 0.2s, border-color 0.2s;
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
-
.
|
|
540
|
+
.session-view-btn:hover {
|
|
541
541
|
background: var(--accent);
|
|
542
542
|
border-color: var(--accent);
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
.
|
|
545
|
+
.session-cmd {
|
|
546
546
|
font-family: var(--font-mono);
|
|
547
547
|
font-size: 0.7rem;
|
|
548
548
|
padding: 3px 8px;
|
|
@@ -556,17 +556,17 @@ body {
|
|
|
556
556
|
transition: border-color 0.2s, color 0.2s;
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
.
|
|
559
|
+
.session-cmd:hover {
|
|
560
560
|
border-color: var(--accent-dim);
|
|
561
561
|
color: var(--accent);
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
-
.
|
|
564
|
+
.session-cmd.copied {
|
|
565
565
|
border-color: var(--green-dim);
|
|
566
566
|
color: var(--green);
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
-
.
|
|
569
|
+
.session-cmd.dead-session {
|
|
570
570
|
color: var(--text-muted);
|
|
571
571
|
cursor: default;
|
|
572
572
|
}
|
|
@@ -945,7 +945,7 @@ body {
|
|
|
945
945
|
box-shadow: 0 0 0 1px var(--accent-dim);
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
-
.
|
|
948
|
+
.session-view-btn.active {
|
|
949
949
|
background: var(--accent);
|
|
950
950
|
border-color: var(--accent);
|
|
951
951
|
}
|
package/dashboard/server.cjs
CHANGED
|
@@ -75,6 +75,8 @@ function normalizeBatchStateIngress(state) {
|
|
|
75
75
|
|
|
76
76
|
for (const lane of state.lanes) {
|
|
77
77
|
if (!lane || typeof lane !== "object") continue;
|
|
78
|
+
// Legacy compatibility: older persisted states stored lane session IDs under
|
|
79
|
+
// `tmuxSessionName`. Normalize to `laneSessionId` at ingress and drop legacy key.
|
|
78
80
|
const laneSessionId = typeof lane.laneSessionId === "string"
|
|
79
81
|
? lane.laneSessionId
|
|
80
82
|
: (typeof lane.tmuxSessionName === "string" ? lane.tmuxSessionName : undefined);
|
|
@@ -191,9 +193,9 @@ function parseStatusMd(taskFolder) {
|
|
|
191
193
|
return null;
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
function
|
|
195
|
-
// Runtime V2 no longer relies on
|
|
196
|
-
//
|
|
196
|
+
function getActiveSessions() {
|
|
197
|
+
// Runtime V2 no longer relies on external terminal multiplexers for liveness.
|
|
198
|
+
// Dashboard session data is driven by runtime registry + persisted state.
|
|
197
199
|
return [];
|
|
198
200
|
}
|
|
199
201
|
|
|
@@ -236,15 +238,15 @@ function loadLaneStates() {
|
|
|
236
238
|
const telemetryTailStates = new Map();
|
|
237
239
|
|
|
238
240
|
/**
|
|
239
|
-
* Module-level accumulated telemetry per
|
|
241
|
+
* Module-level accumulated telemetry per session prefix.
|
|
240
242
|
* Persists across poll ticks so incremental tail reads accumulate correctly.
|
|
241
|
-
* Key:
|
|
243
|
+
* Key: session prefix → { inputTokens, outputTokens, ... }
|
|
242
244
|
*/
|
|
243
245
|
const telemetryAccumulators = new Map();
|
|
244
246
|
|
|
245
247
|
/**
|
|
246
248
|
* Tracks which files are currently contributing to each prefix.
|
|
247
|
-
* Key:
|
|
249
|
+
* Key: session prefix → Set of absolute file paths
|
|
248
250
|
* Used to detect file rotation: when files change, accumulator is reset.
|
|
249
251
|
*/
|
|
250
252
|
const telemetryPrefixFiles = new Map();
|
|
@@ -530,7 +532,7 @@ function loadTelemetryData(batchState) {
|
|
|
530
532
|
const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
|
|
531
533
|
const result = {};
|
|
532
534
|
|
|
533
|
-
// Build lane number →
|
|
535
|
+
// Build lane number → session prefix mapping from batch state
|
|
534
536
|
const laneToPrefix = {};
|
|
535
537
|
if (batchState && batchState.lanes) {
|
|
536
538
|
for (const lane of batchState.lanes) {
|
|
@@ -563,11 +565,11 @@ function loadTelemetryData(batchState) {
|
|
|
563
565
|
const parsed = parseTelemetryFilename(file);
|
|
564
566
|
if (!parsed) continue;
|
|
565
567
|
|
|
566
|
-
// Determine the key (
|
|
568
|
+
// Determine the key (session prefix)
|
|
567
569
|
let prefix;
|
|
568
570
|
if (parsed.role === "merger") {
|
|
569
|
-
// Merge agent — derive prefix from lane naming so it matches the
|
|
570
|
-
// session
|
|
571
|
+
// Merge agent — derive prefix from lane naming so it matches the
|
|
572
|
+
// session ID used by the client (e.g. "orch-henrylach-merge-1").
|
|
571
573
|
// Lane sessions: "orch-{opId}-lane-{N}" → merge sessions: "orch-{opId}-merge-{N}".
|
|
572
574
|
const firstLanePrefix = Object.values(laneToPrefix)[0]; // e.g. "orch-henrylach-lane-1"
|
|
573
575
|
const opPrefix = firstLanePrefix?.replace(/-lane-\d+$/, ""); // "orch-henrylach"
|
|
@@ -1029,7 +1031,7 @@ function synthesizeLaneStateFromSnapshot(key, snap, fallbackBatchId) {
|
|
|
1029
1031
|
/** Build full dashboard state object for the frontend. */
|
|
1030
1032
|
function buildDashboardState() {
|
|
1031
1033
|
const state = loadBatchState();
|
|
1032
|
-
const
|
|
1034
|
+
const sessions = getActiveSessions();
|
|
1033
1035
|
const rawLaneStates = loadLaneStates();
|
|
1034
1036
|
// Filter stale lane states from previous batches.
|
|
1035
1037
|
// Lane state files persist across batches (same filename), so without
|
|
@@ -1046,7 +1048,16 @@ function buildDashboardState() {
|
|
|
1046
1048
|
const supervisor = loadSupervisorData(state);
|
|
1047
1049
|
|
|
1048
1050
|
if (!state) {
|
|
1049
|
-
return {
|
|
1051
|
+
return {
|
|
1052
|
+
batch: null,
|
|
1053
|
+
sessions,
|
|
1054
|
+
tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
|
|
1055
|
+
laneStates: {},
|
|
1056
|
+
telemetry: {},
|
|
1057
|
+
batchTotalCost: 0,
|
|
1058
|
+
supervisor: null,
|
|
1059
|
+
timestamp: Date.now(),
|
|
1060
|
+
};
|
|
1050
1061
|
}
|
|
1051
1062
|
|
|
1052
1063
|
const tasks = (state.tasks || []).map((task) => {
|
|
@@ -1108,7 +1119,8 @@ function buildDashboardState() {
|
|
|
1108
1119
|
// Additive field — absent in v1 state files, frontend must default to "repo".
|
|
1109
1120
|
mode: state.mode || "repo",
|
|
1110
1121
|
},
|
|
1111
|
-
|
|
1122
|
+
sessions,
|
|
1123
|
+
tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
|
|
1112
1124
|
timestamp: Date.now(),
|
|
1113
1125
|
};
|
|
1114
1126
|
}
|
|
@@ -1157,68 +1169,6 @@ function serveStatic(req, res) {
|
|
|
1157
1169
|
|
|
1158
1170
|
const sseClients = new Set();
|
|
1159
1171
|
|
|
1160
|
-
// ─── Pane Capture SSE ───────────────────────────────────────────────────
|
|
1161
|
-
|
|
1162
|
-
const paneClients = new Map(); // sessionName → Set<res>
|
|
1163
|
-
|
|
1164
|
-
function handlePaneSSE(req, res, sessionName) {
|
|
1165
|
-
// Validate session name (alphanumeric, dashes, underscores only)
|
|
1166
|
-
if (!/^[\w-]+$/.test(sessionName)) {
|
|
1167
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1168
|
-
res.end("Invalid session name");
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
res.writeHead(200, {
|
|
1173
|
-
"Content-Type": "text/event-stream",
|
|
1174
|
-
"Cache-Control": "no-cache",
|
|
1175
|
-
Connection: "keep-alive",
|
|
1176
|
-
"Access-Control-Allow-Origin": "*",
|
|
1177
|
-
});
|
|
1178
|
-
|
|
1179
|
-
if (!paneClients.has(sessionName)) {
|
|
1180
|
-
paneClients.set(sessionName, new Set());
|
|
1181
|
-
}
|
|
1182
|
-
paneClients.get(sessionName).add(res);
|
|
1183
|
-
|
|
1184
|
-
// Send initial capture immediately
|
|
1185
|
-
const initial = captureTmuxPane(sessionName);
|
|
1186
|
-
if (initial !== null) {
|
|
1187
|
-
res.write(`data: ${JSON.stringify({ output: initial, session: sessionName })}\n\n`);
|
|
1188
|
-
} else {
|
|
1189
|
-
res.write(`data: ${JSON.stringify({ error: "Session not found or not accessible", session: sessionName })}\n\n`);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
req.on("close", () => {
|
|
1193
|
-
const clients = paneClients.get(sessionName);
|
|
1194
|
-
if (clients) {
|
|
1195
|
-
clients.delete(res);
|
|
1196
|
-
if (clients.size === 0) paneClients.delete(sessionName);
|
|
1197
|
-
}
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
function captureTmuxPane(_sessionName) {
|
|
1202
|
-
// Runtime V2 no longer captures TMUX panes.
|
|
1203
|
-
return null;
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
function broadcastPaneCaptures() {
|
|
1207
|
-
for (const [sessionName, clients] of paneClients) {
|
|
1208
|
-
if (clients.size === 0) continue;
|
|
1209
|
-
const output = captureTmuxPane(sessionName);
|
|
1210
|
-
if (output === null) continue;
|
|
1211
|
-
const payload = `data: ${JSON.stringify({ output, session: sessionName })}\n\n`;
|
|
1212
|
-
for (const client of clients) {
|
|
1213
|
-
try {
|
|
1214
|
-
client.write(payload);
|
|
1215
|
-
} catch {
|
|
1216
|
-
clients.delete(client);
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
1172
|
// ─── Conversation JSONL ─────────────────────────────────────────────────
|
|
1223
1173
|
|
|
1224
1174
|
function serveConversation(req, res, prefix) {
|
|
@@ -1441,9 +1391,6 @@ function createServer() {
|
|
|
1441
1391
|
|
|
1442
1392
|
if (pathname === "/api/stream" && req.method === "GET") {
|
|
1443
1393
|
handleSSE(req, res);
|
|
1444
|
-
} else if (pathname.startsWith("/api/pane/") && req.method === "GET") {
|
|
1445
|
-
const sessionName = pathname.slice("/api/pane/".length);
|
|
1446
|
-
handlePaneSSE(req, res, sessionName);
|
|
1447
1394
|
} else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
|
|
1448
1395
|
const prefix = pathname.slice("/api/conversation/".length);
|
|
1449
1396
|
serveConversation(req, res, prefix);
|
|
@@ -1594,9 +1541,6 @@ async function main() {
|
|
|
1594
1541
|
// Broadcast state to all SSE clients on interval
|
|
1595
1542
|
const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
|
|
1596
1543
|
|
|
1597
|
-
// Broadcast pane captures more frequently (1s) for smooth terminal viewing
|
|
1598
|
-
const paneTimer = setInterval(broadcastPaneCaptures, 1000);
|
|
1599
|
-
|
|
1600
1544
|
// Also watch batch-state.json for immediate push on change
|
|
1601
1545
|
try {
|
|
1602
1546
|
const batchDir = path.dirname(BATCH_STATE_PATH);
|
|
@@ -1621,12 +1565,6 @@ async function main() {
|
|
|
1621
1565
|
// Graceful shutdown
|
|
1622
1566
|
function cleanup() {
|
|
1623
1567
|
clearInterval(pollTimer);
|
|
1624
|
-
clearInterval(paneTimer);
|
|
1625
|
-
for (const [, clients] of paneClients) {
|
|
1626
|
-
for (const client of clients) {
|
|
1627
|
-
try { client.end(); } catch {}
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
1568
|
for (const client of sseClients) {
|
|
1631
1569
|
try { client.end(); } catch {}
|
|
1632
1570
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* /orch-resume — Resume a paused batch
|
|
13
13
|
* /orch-abort [--hard] — Abort batch (graceful or immediate)
|
|
14
14
|
* /orch-deps <areas|paths|all> — Show dependency graph
|
|
15
|
-
* /orch-sessions — List active
|
|
15
|
+
* /orch-sessions — List active lane sessions
|
|
16
16
|
*
|
|
17
17
|
* Configuration:
|
|
18
18
|
* .pi/task-orchestrator.yaml — orchestrator-specific settings
|
|
@@ -314,7 +314,8 @@ export function spawnAgent(
|
|
|
314
314
|
let timedOut = false;
|
|
315
315
|
let agentEnded = false;
|
|
316
316
|
let stdinClosed = false;
|
|
317
|
-
let
|
|
317
|
+
let assistantMessageEnds = 0;
|
|
318
|
+
const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5;
|
|
318
319
|
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
|
319
320
|
let costUsd = 0, toolCalls = 0, retries = 0, compactions = 0;
|
|
320
321
|
let lastTool = "", error: string | null = null;
|
|
@@ -586,10 +587,13 @@ export function spawnAgent(
|
|
|
586
587
|
emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
|
|
587
588
|
}
|
|
588
589
|
}
|
|
589
|
-
// Request session stats
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
590
|
+
// Request session stats immediately on first assistant message,
|
|
591
|
+
// then periodically at a bounded cadence to refresh context usage.
|
|
592
|
+
if (event.message?.role === "assistant") {
|
|
593
|
+
assistantMessageEnds += 1;
|
|
594
|
+
if (assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0) {
|
|
595
|
+
try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ }
|
|
596
|
+
}
|
|
593
597
|
}
|
|
594
598
|
// Check mailbox
|
|
595
599
|
checkMailbox();
|
|
@@ -636,6 +640,8 @@ export function spawnAgent(
|
|
|
636
640
|
if (event.success === true && event.data?.contextUsage) {
|
|
637
641
|
contextUsage = event.data.contextUsage;
|
|
638
642
|
emitEvent("context_usage", { ...event.data.contextUsage });
|
|
643
|
+
// Emit telemetry immediately so context % is live in dashboard
|
|
644
|
+
onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
|
|
639
645
|
}
|
|
640
646
|
break;
|
|
641
647
|
}
|
|
@@ -32,6 +32,8 @@ import type {
|
|
|
32
32
|
/**
|
|
33
33
|
* Messages sent FROM the worker TO the main thread.
|
|
34
34
|
*/
|
|
35
|
+
export type WorkerErrorSource = "enginePromise" | "uncaughtException" | "unhandledRejection";
|
|
36
|
+
|
|
35
37
|
export type WorkerToMainMessage =
|
|
36
38
|
| { type: "notify"; msg: string; level: "info" | "warning" | "error" }
|
|
37
39
|
| { type: "monitor-update"; state: MonitorState }
|
|
@@ -39,7 +41,7 @@ export type WorkerToMainMessage =
|
|
|
39
41
|
| { type: "supervisor-alert"; alert: SupervisorAlert }
|
|
40
42
|
| { type: "state-sync"; state: SerializedBatchState }
|
|
41
43
|
| { type: "complete"; state: SerializedBatchState }
|
|
42
|
-
| { type: "error"; message: string };
|
|
44
|
+
| { type: "error"; message: string; stack?: string; source?: WorkerErrorSource };
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Messages sent FROM the main thread TO the worker.
|
|
@@ -194,12 +196,73 @@ export function applySerializedState(
|
|
|
194
196
|
|
|
195
197
|
// Guard: only run engine main when launched via fork() with the sentinel env var.
|
|
196
198
|
if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "function") {
|
|
197
|
-
const send = (msg: WorkerToMainMessage) =>
|
|
199
|
+
const send = (msg: WorkerToMainMessage) => {
|
|
200
|
+
try {
|
|
201
|
+
process.send?.(msg);
|
|
202
|
+
} catch {
|
|
203
|
+
// best effort only
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const sendWithAck = (msg: WorkerToMainMessage, onFlushed: () => void) => {
|
|
208
|
+
if (typeof process.send !== "function" || !process.connected) {
|
|
209
|
+
onFlushed();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let flushed = false;
|
|
214
|
+
const done = () => {
|
|
215
|
+
if (flushed) return;
|
|
216
|
+
flushed = true;
|
|
217
|
+
onFlushed();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
(process.send as (
|
|
222
|
+
message: WorkerToMainMessage,
|
|
223
|
+
sendHandle?: unknown,
|
|
224
|
+
options?: unknown,
|
|
225
|
+
callback?: (error: Error | null) => void,
|
|
226
|
+
) => boolean)(msg, undefined, undefined, () => done());
|
|
227
|
+
setTimeout(done, 75).unref();
|
|
228
|
+
} catch {
|
|
229
|
+
done();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const normalizeError = (err: unknown): { message: string; stack?: string } => {
|
|
234
|
+
if (err instanceof Error) return { message: err.message, stack: err.stack };
|
|
235
|
+
return { message: String(err) };
|
|
236
|
+
};
|
|
198
237
|
|
|
199
238
|
// Wait for the init message carrying workerData, then start the engine.
|
|
200
239
|
process.once("message", async (initMsg: { type: string; data: EngineWorkerData }) => {
|
|
201
240
|
if (initMsg?.type !== "init") return;
|
|
202
241
|
|
|
242
|
+
let batchState: OrchBatchRuntimeState | null = null;
|
|
243
|
+
let fatalHandled = false;
|
|
244
|
+
const reportFatalAndExit = (source: WorkerErrorSource, err: unknown) => {
|
|
245
|
+
if (fatalHandled) return;
|
|
246
|
+
fatalHandled = true;
|
|
247
|
+
|
|
248
|
+
const normalized = normalizeError(err);
|
|
249
|
+
if (batchState && batchState.phase !== "completed" && batchState.phase !== "failed") {
|
|
250
|
+
batchState.phase = "failed";
|
|
251
|
+
batchState.endedAt = Date.now();
|
|
252
|
+
batchState.errors.push(`[${source}] ${normalized.message}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (batchState) send({ type: "state-sync", state: serializeBatchState(batchState) });
|
|
256
|
+
sendWithAck(
|
|
257
|
+
{ type: "error", source, message: normalized.message, stack: normalized.stack },
|
|
258
|
+
() => process.exit(1),
|
|
259
|
+
);
|
|
260
|
+
setTimeout(() => process.exit(1), 200).unref();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
process.once("uncaughtException", (err: unknown) => reportFatalAndExit("uncaughtException", err));
|
|
264
|
+
process.once("unhandledRejection", (reason: unknown) => reportFatalAndExit("unhandledRejection", reason));
|
|
265
|
+
|
|
203
266
|
// Dynamic imports — only loaded in engine context to avoid circular
|
|
204
267
|
// dependencies when this module is imported from extension.ts
|
|
205
268
|
const { executeOrchBatch } = await import("./engine.ts");
|
|
@@ -209,7 +272,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
209
272
|
const data = initMsg.data;
|
|
210
273
|
|
|
211
274
|
// Create a fresh batch state for this process
|
|
212
|
-
|
|
275
|
+
batchState = freshOrchBatchState();
|
|
213
276
|
batchState.phase = "launching";
|
|
214
277
|
batchState.startedAt = Date.now();
|
|
215
278
|
|
|
@@ -220,6 +283,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
220
283
|
// Main process sends pause/resume/abort signals via IPC.
|
|
221
284
|
// We apply them to the in-process batchState.pauseSignal.
|
|
222
285
|
process.on("message", (msg: WorkerInMessage) => {
|
|
286
|
+
if (!batchState) return;
|
|
223
287
|
switch (msg.type) {
|
|
224
288
|
case "pause":
|
|
225
289
|
batchState.pauseSignal.paused = true;
|
|
@@ -236,6 +300,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
236
300
|
// ── Callback factories (replace ctx-dependent callbacks) ─────
|
|
237
301
|
const onNotify = (message: string, level: "info" | "warning" | "error") => {
|
|
238
302
|
send({ type: "notify", msg: message, level });
|
|
303
|
+
if (!batchState) return;
|
|
239
304
|
// Sync batch state on every notify (lightweight — just the summary fields)
|
|
240
305
|
send({ type: "state-sync", state: serializeBatchState(batchState) });
|
|
241
306
|
};
|
|
@@ -292,15 +357,15 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
292
357
|
process.disconnect?.();
|
|
293
358
|
})
|
|
294
359
|
.catch((err: unknown) => {
|
|
295
|
-
const
|
|
360
|
+
const normalized = normalizeError(err);
|
|
296
361
|
// Ensure batch state reflects the failure
|
|
297
362
|
if (batchState.phase !== "completed" && batchState.phase !== "failed") {
|
|
298
363
|
batchState.phase = "failed";
|
|
299
364
|
batchState.endedAt = Date.now();
|
|
300
|
-
batchState.errors.push(`Unhandled engine error: ${
|
|
365
|
+
batchState.errors.push(`Unhandled engine error: ${normalized.message}`);
|
|
301
366
|
}
|
|
302
367
|
send({ type: "state-sync", state: serializeBatchState(batchState) });
|
|
303
|
-
send({ type: "error", message:
|
|
368
|
+
send({ type: "error", source: "enginePromise", message: normalized.message, stack: normalized.stack });
|
|
304
369
|
process.disconnect?.();
|
|
305
370
|
});
|
|
306
371
|
});
|
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
|
|
|
2
2
|
import { Type } from "@mariozechner/pi-ai";
|
|
3
3
|
|
|
4
4
|
import { execSync, execFileSync } from "child_process";
|
|
5
|
-
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
5
|
+
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream } from "fs";
|
|
6
6
|
import { join, dirname } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { fork, type ChildProcess } from "child_process";
|
|
@@ -964,6 +964,7 @@ export function startBatchInWorker(
|
|
|
964
964
|
child = fork(workerPath, [], {
|
|
965
965
|
env: { ...process.env, TASKPLANE_ENGINE_FORK: "1" },
|
|
966
966
|
serialization: "advanced",
|
|
967
|
+
stdio: ["inherit", "inherit", "pipe", "ipc"],
|
|
967
968
|
});
|
|
968
969
|
} catch (spawnErr: unknown) {
|
|
969
970
|
const errMsg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
|
|
@@ -1007,11 +1008,78 @@ export function startBatchInWorker(
|
|
|
1007
1008
|
return null;
|
|
1008
1009
|
}
|
|
1009
1010
|
|
|
1011
|
+
const telemetryDir = join(wkData.cwd, ".pi", "telemetry");
|
|
1012
|
+
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1013
|
+
const pendingBatchId = `pending-${Date.now()}`;
|
|
1014
|
+
const toSafeBatchId = (batchId: string) => batchId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
1015
|
+
let stderrBatchId = toSafeBatchId(batchState.batchId || pendingBatchId);
|
|
1016
|
+
let stderrLogPath = join(telemetryDir, `${stderrBatchId}-engine-worker-stderr.log`);
|
|
1017
|
+
let stderrLogStream = createWriteStream(stderrLogPath, { flags: "a" });
|
|
1018
|
+
stderrLogStream.on("error", () => { /* non-fatal: telemetry stream */ });
|
|
1019
|
+
let stderrTailBuffer = "";
|
|
1020
|
+
|
|
1021
|
+
const appendStderr = (chunk: Buffer | string) => {
|
|
1022
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : chunk;
|
|
1023
|
+
if (!text) return;
|
|
1024
|
+
process.stderr.write(text);
|
|
1025
|
+
stderrLogStream.write(text);
|
|
1026
|
+
stderrTailBuffer = (stderrTailBuffer + text).slice(-24_000);
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const rotateStderrLogToBatch = (batchId: string | undefined) => {
|
|
1030
|
+
if (!batchId) return;
|
|
1031
|
+
const resolvedBatchId = toSafeBatchId(batchId.trim());
|
|
1032
|
+
if (!resolvedBatchId || resolvedBatchId === stderrBatchId) return;
|
|
1033
|
+
|
|
1034
|
+
const nextPath = join(telemetryDir, `${resolvedBatchId}-engine-worker-stderr.log`);
|
|
1035
|
+
try {
|
|
1036
|
+
// Flush pending writes before rotating. cork→uncork ensures buffered
|
|
1037
|
+
// data is flushed synchronously before we read the file contents.
|
|
1038
|
+
stderrLogStream.cork();
|
|
1039
|
+
stderrLogStream.uncork();
|
|
1040
|
+
stderrLogStream.end();
|
|
1041
|
+
const pendingContent = existsSync(stderrLogPath) ? readFileSync(stderrLogPath, "utf-8") : "";
|
|
1042
|
+
if (pendingContent) writeFileSync(nextPath, pendingContent, { flag: "a" });
|
|
1043
|
+
if (existsSync(stderrLogPath)) unlinkSync(stderrLogPath);
|
|
1044
|
+
} catch {
|
|
1045
|
+
// best effort rename/copy
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
stderrBatchId = resolvedBatchId;
|
|
1049
|
+
stderrLogPath = nextPath;
|
|
1050
|
+
stderrLogStream = createWriteStream(stderrLogPath, { flags: "a" });
|
|
1051
|
+
stderrLogStream.on("error", () => { /* non-fatal: telemetry stream */ });
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const readStderrTail = (lineCount = 25): string => {
|
|
1055
|
+
// Prefer in-memory tail buffer (always has the freshest unflushed data)
|
|
1056
|
+
// over the disk file (which may lag behind due to stream buffering).
|
|
1057
|
+
let content = stderrTailBuffer;
|
|
1058
|
+
if (!content) {
|
|
1059
|
+
try {
|
|
1060
|
+
if (existsSync(stderrLogPath)) content = readFileSync(stderrLogPath, "utf-8");
|
|
1061
|
+
} catch { /* fallback: empty */ }
|
|
1062
|
+
}
|
|
1063
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
1064
|
+
if (lines.length === 0) return "(no stderr output captured)";
|
|
1065
|
+
return lines.slice(-lineCount).join("\n");
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
child.stderr?.on("data", appendStderr);
|
|
1069
|
+
child.on("close", () => {
|
|
1070
|
+
try {
|
|
1071
|
+
stderrLogStream.end();
|
|
1072
|
+
} catch {
|
|
1073
|
+
// ignore
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1010
1077
|
// Send workerData as first IPC message
|
|
1011
1078
|
child.send({ type: "init", data: wkData });
|
|
1012
1079
|
|
|
1013
1080
|
// Terminal settlement guard (R001 §3): ensures onTerminal fires at most once.
|
|
1014
1081
|
let settled = false;
|
|
1082
|
+
let errorReceivedViaIpc = false;
|
|
1015
1083
|
const settle = () => {
|
|
1016
1084
|
if (settled) return;
|
|
1017
1085
|
settled = true;
|
|
@@ -1039,32 +1107,42 @@ export function startBatchInWorker(
|
|
|
1039
1107
|
|
|
1040
1108
|
case "state-sync":
|
|
1041
1109
|
applySerializedState(batchState, msg.state);
|
|
1110
|
+
rotateStderrLogToBatch(msg.state.batchId);
|
|
1042
1111
|
updateWidget();
|
|
1043
1112
|
break;
|
|
1044
1113
|
|
|
1045
1114
|
case "complete":
|
|
1046
1115
|
applySerializedState(batchState, msg.state);
|
|
1116
|
+
rotateStderrLogToBatch(msg.state.batchId);
|
|
1047
1117
|
updateWidget();
|
|
1048
1118
|
settle();
|
|
1049
1119
|
break;
|
|
1050
1120
|
|
|
1051
|
-
case "error":
|
|
1121
|
+
case "error": {
|
|
1122
|
+
errorReceivedViaIpc = true;
|
|
1123
|
+
const sourceLabel = msg.source ? ` (${msg.source})` : "";
|
|
1124
|
+
const stackLine = msg.stack?.split("\n")[0]?.trim();
|
|
1052
1125
|
if (batchState.phase !== "completed" && batchState.phase !== "failed") {
|
|
1053
1126
|
batchState.phase = "failed";
|
|
1054
1127
|
batchState.endedAt = Date.now();
|
|
1055
|
-
batchState.errors.push(`Unhandled engine error: ${msg.message}`);
|
|
1128
|
+
batchState.errors.push(`Unhandled engine error${sourceLabel}: ${msg.message}`);
|
|
1129
|
+
if (stackLine) batchState.errors.push(`Engine stack: ${stackLine}`);
|
|
1056
1130
|
}
|
|
1057
1131
|
ctx.ui.notify(
|
|
1058
|
-
`❌ Engine crashed with unhandled error: ${msg.message}\n` +
|
|
1132
|
+
`❌ Engine crashed with unhandled error${sourceLabel}: ${msg.message}\n` +
|
|
1133
|
+
(stackLine ? ` ${stackLine}\n` : "") +
|
|
1059
1134
|
` Batch ${batchState.batchId} marked as failed.`,
|
|
1060
1135
|
"error",
|
|
1061
1136
|
);
|
|
1062
1137
|
updateWidget();
|
|
1063
1138
|
break;
|
|
1139
|
+
}
|
|
1064
1140
|
}
|
|
1065
1141
|
});
|
|
1066
1142
|
|
|
1067
1143
|
child.on("error", (err: Error) => {
|
|
1144
|
+
rotateStderrLogToBatch(batchState.batchId || undefined);
|
|
1145
|
+
const stderrTail = readStderrTail();
|
|
1068
1146
|
if (batchState.phase !== "completed" && batchState.phase !== "failed") {
|
|
1069
1147
|
batchState.phase = "failed";
|
|
1070
1148
|
batchState.endedAt = Date.now();
|
|
@@ -1082,6 +1160,7 @@ export function startBatchInWorker(
|
|
|
1082
1160
|
summary:
|
|
1083
1161
|
`🔴 Engine process error — batch ${batchState.batchId} marked as failed\n` +
|
|
1084
1162
|
` Error: ${err.message}\n\n` +
|
|
1163
|
+
`Engine stderr tail (${stderrLogPath}):\n${stderrTail}\n\n` +
|
|
1085
1164
|
`This is a critical engine failure. The batch cannot continue.\n` +
|
|
1086
1165
|
`Available actions:\n` +
|
|
1087
1166
|
` - orch_status() to inspect state\n` +
|
|
@@ -1103,6 +1182,14 @@ export function startBatchInWorker(
|
|
|
1103
1182
|
|
|
1104
1183
|
child.on("exit", (code: number | null) => {
|
|
1105
1184
|
if (code !== 0 && !settled) {
|
|
1185
|
+
// If we already received an error IPC with diagnostics, the exit is expected
|
|
1186
|
+
// (the worker calls process.exit(1) after sending the error). Skip duplicate alert.
|
|
1187
|
+
if (errorReceivedViaIpc) {
|
|
1188
|
+
settle();
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
rotateStderrLogToBatch(batchState.batchId || undefined);
|
|
1192
|
+
const stderrTail = readStderrTail();
|
|
1106
1193
|
if (batchState.phase !== "completed" && batchState.phase !== "failed") {
|
|
1107
1194
|
batchState.phase = "failed";
|
|
1108
1195
|
batchState.endedAt = Date.now();
|
|
@@ -1119,6 +1206,7 @@ export function startBatchInWorker(
|
|
|
1119
1206
|
summary:
|
|
1120
1207
|
`🔴 Engine process died unexpectedly (exit code ${code})\n` +
|
|
1121
1208
|
` Batch ${batchState.batchId} marked as failed.\n\n` +
|
|
1209
|
+
`Engine stderr tail (${stderrLogPath}):\n${stderrTail}\n\n` +
|
|
1122
1210
|
`This is a critical engine failure. The batch cannot continue.\n` +
|
|
1123
1211
|
`Available actions:\n` +
|
|
1124
1212
|
` - orch_status() to inspect state\n` +
|
|
@@ -312,8 +312,24 @@ export async function executeTaskV2(
|
|
|
312
312
|
// Reviewer telemetry is written by the worker bridge during review_step.
|
|
313
313
|
// Poll snapshot refresh independently from worker message_end cadence so
|
|
314
314
|
// the dashboard sees reviewer activity while tool calls are in-flight.
|
|
315
|
+
let reviewerSnapshotFailures = 0;
|
|
316
|
+
const reviewerRefreshFailureThreshold = 5;
|
|
315
317
|
const reviewerRefresh = setInterval(() => {
|
|
316
|
-
|
|
318
|
+
const ok = emitSnapshot(config, taskId, "running", iterationTelemetry, statusPath);
|
|
319
|
+
if (ok) {
|
|
320
|
+
reviewerSnapshotFailures = 0;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
reviewerSnapshotFailures += 1;
|
|
325
|
+
if (reviewerSnapshotFailures >= reviewerRefreshFailureThreshold) {
|
|
326
|
+
clearInterval(reviewerRefresh);
|
|
327
|
+
logExecution(
|
|
328
|
+
statusPath,
|
|
329
|
+
"Snapshot refresh disabled",
|
|
330
|
+
`Lane ${config.laneNumber}, task ${taskId}: ${reviewerSnapshotFailures} consecutive emitSnapshot failures`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
317
333
|
}, 1000);
|
|
318
334
|
|
|
319
335
|
let workerResult: AgentHostResult;
|
|
@@ -614,6 +630,8 @@ export function readReviewerTelemetrySnapshot(
|
|
|
614
630
|
* caught and logged. This function is called from setInterval callbacks
|
|
615
631
|
* and onTelemetry callbacks where an unhandled throw would trigger
|
|
616
632
|
* uncaughtException and crash the engine-worker process.
|
|
633
|
+
*
|
|
634
|
+
* @returns true when snapshot write succeeds, false when it fails.
|
|
617
635
|
*/
|
|
618
636
|
function emitSnapshot(
|
|
619
637
|
config: LaneRunnerConfig,
|
|
@@ -621,7 +639,7 @@ function emitSnapshot(
|
|
|
621
639
|
status: "running" | "idle" | "complete" | "failed",
|
|
622
640
|
telemetry: Partial<AgentHostResult>,
|
|
623
641
|
statusPath: string,
|
|
624
|
-
):
|
|
642
|
+
): boolean {
|
|
625
643
|
try {
|
|
626
644
|
// Parse progress from STATUS.md
|
|
627
645
|
let progress: RuntimeTaskProgress | null = null;
|
|
@@ -669,9 +687,11 @@ function emitSnapshot(
|
|
|
669
687
|
};
|
|
670
688
|
|
|
671
689
|
writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
|
|
690
|
+
return true;
|
|
672
691
|
} catch {
|
|
673
692
|
// Non-fatal: snapshot is telemetry, not execution-critical.
|
|
674
693
|
// Swallow to prevent uncaughtException crash in setInterval/callback contexts.
|
|
694
|
+
return false;
|
|
675
695
|
}
|
|
676
696
|
}
|
|
677
697
|
|
package/package.json
CHANGED
|
@@ -48,8 +48,7 @@ worker:
|
|
|
48
48
|
model: "" # empty = inherit from parent pi session
|
|
49
49
|
tools: "read,write,edit,bash,grep,find,ls"
|
|
50
50
|
thinking: "off"
|
|
51
|
-
# spawn_mode: "subprocess" #
|
|
52
|
-
# tmux_prefix: "task" # used only in tmux mode
|
|
51
|
+
# spawn_mode: "subprocess" # currently supported runtime mode
|
|
53
52
|
|
|
54
53
|
reviewer:
|
|
55
54
|
model: ""
|
|
@@ -63,7 +62,7 @@ context:
|
|
|
63
62
|
max_worker_iterations: 20
|
|
64
63
|
max_review_cycles: 2
|
|
65
64
|
no_progress_limit: 3
|
|
66
|
-
# max_worker_minutes: 30 #
|
|
65
|
+
# max_worker_minutes: 30 # optional wall-clock guard for long worker runs
|
|
67
66
|
|
|
68
67
|
# ── Task Creation / Discovery ─────────────────────────────────────────
|
|
69
68
|
|