taskplane 0.24.1 → 0.24.3

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.
@@ -101,6 +101,11 @@ function mergeV2LaneSnapshot(legacyLs, v2snap) {
101
101
  return base;
102
102
  }
103
103
 
104
+ function isReviewerActiveForTask(ls, task) {
105
+ if (!ls || !task) return false;
106
+ return !!(ls.reviewerStatus === "running" && task.status === "running" && (!ls.taskId || ls.taskId === task.taskId));
107
+ }
108
+
104
109
  /** Build a compact token summary string from lane state sidecar data.
105
110
  * Display: ↑total_input ↓output (cost)
106
111
  * Anthropic splits input into: uncached `input` + `cacheRead`.
@@ -612,10 +617,10 @@ function renderLanesTasks(batch, tmuxSessions) {
612
617
 
613
618
  // Worker stats from lane state sidecar + telemetry badges
614
619
  let workerHtml = "";
615
- // Reviewer sub-row should only appear under the task currently being reviewed,
616
- // not all tasks in the lane. The lane-state sidecar is per-lane (shared by all
617
- // tasks in the lane), so check that the sidecar's current taskId matches this task.
618
- const reviewerActive = ls && ls.reviewerStatus === "running" && ls.taskId === task.taskId;
620
+ // Reviewer sub-row should only appear under the active running task in this lane.
621
+ // Runtime V2 snapshots provide taskId; during early startup it can be briefly unset,
622
+ // so allow a task-status fallback while still avoiding duplicate rows.
623
+ const reviewerActive = isReviewerActiveForTask(ls, task);
619
624
  const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel, reviewerActive) : "";
620
625
  if (ls && ls.workerStatus === "running" && task.status === "running") {
621
626
  const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
@@ -997,6 +997,43 @@ function computeBatchTotalCost(laneStates, telemetry) {
997
997
  return totalCost;
998
998
  }
999
999
 
1000
+ function synthesizeLaneStateFromSnapshot(key, snap, fallbackBatchId) {
1001
+ const w = snap.worker || {};
1002
+ const r = snap.reviewer || null;
1003
+ const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1004
+ const reviewerStatusMap = { running: "running", spawning: "running", wrapping_up: "running", exited: "done", crashed: "done", killed: "done", timed_out: "done" };
1005
+
1006
+ return {
1007
+ prefix: key,
1008
+ taskId: snap.taskId || null,
1009
+ phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1010
+ workerStatus: statusMap[w.status] || w.status || "idle",
1011
+ workerElapsed: w.elapsedMs || 0,
1012
+ workerContextPct: w.contextPct || 0,
1013
+ workerLastTool: w.lastTool || "",
1014
+ workerToolCount: w.toolCalls || 0,
1015
+ workerInputTokens: w.inputTokens || 0,
1016
+ workerOutputTokens: w.outputTokens || 0,
1017
+ workerCacheReadTokens: w.cacheReadTokens || 0,
1018
+ workerCacheWriteTokens: w.cacheWriteTokens || 0,
1019
+ workerCostUsd: w.costUsd || 0,
1020
+ reviewerStatus: r ? (reviewerStatusMap[r.status] || r.status || "running") : "idle",
1021
+ reviewerElapsed: r?.elapsedMs || 0,
1022
+ reviewerContextPct: r?.contextPct || 0,
1023
+ reviewerLastTool: r?.lastTool || "",
1024
+ reviewerToolCount: r?.toolCalls || 0,
1025
+ reviewerCostUsd: r?.costUsd || 0,
1026
+ reviewerInputTokens: r?.inputTokens || 0,
1027
+ reviewerOutputTokens: r?.outputTokens || 0,
1028
+ reviewerCacheReadTokens: r?.cacheReadTokens || 0,
1029
+ reviewerCacheWriteTokens: r?.cacheWriteTokens || 0,
1030
+ reviewerType: r?.reviewType || "",
1031
+ reviewerStep: r?.reviewStep || 0,
1032
+ batchId: snap.batchId || fallbackBatchId,
1033
+ timestamp: snap.updatedAt || Date.now(),
1034
+ };
1035
+ }
1036
+
1000
1037
  /** Build full dashboard state object for the frontend. */
1001
1038
  function buildDashboardState() {
1002
1039
  const state = loadBatchState();
@@ -1046,26 +1083,7 @@ function buildDashboardState() {
1046
1083
  const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
1047
1084
  const key = laneRec ? (laneRec.laneSessionId) : `lane-${laneNum}`;
1048
1085
  if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
1049
- const w = snap.worker || {};
1050
- const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1051
- laneStates[key] = {
1052
- prefix: key,
1053
- taskId: snap.taskId || null,
1054
- phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1055
- workerStatus: statusMap[w.status] || w.status || "idle",
1056
- workerElapsed: w.elapsedMs || 0,
1057
- workerContextPct: w.contextPct || 0,
1058
- workerLastTool: w.lastTool || "",
1059
- workerToolCount: w.toolCalls || 0,
1060
- workerInputTokens: w.inputTokens || 0,
1061
- workerOutputTokens: w.outputTokens || 0,
1062
- workerCacheReadTokens: w.cacheReadTokens || 0,
1063
- workerCacheWriteTokens: w.cacheWriteTokens || 0,
1064
- workerCostUsd: w.costUsd || 0,
1065
- reviewerStatus: "idle",
1066
- batchId: snap.batchId || state.batchId,
1067
- timestamp: snap.updatedAt || Date.now(),
1068
- };
1086
+ laneStates[key] = synthesizeLaneStateFromSnapshot(key, snap, state.batchId);
1069
1087
  }
1070
1088
  }
1071
1089
  }
@@ -24,7 +24,7 @@
24
24
 
25
25
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
26
26
  import { Type } from "@mariozechner/pi-ai";
27
- import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from "fs";
27
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
28
28
  import { join, dirname } from "path";
29
29
  import { spawn as nodeSpawn } from "child_process";
30
30
  import { randomBytes } from "crypto";
@@ -216,11 +216,44 @@ export default function (pi: ExtensionAPI) {
216
216
  return basePrompt;
217
217
  }
218
218
 
219
+ function reviewerStatePath(taskFolder: string): string {
220
+ return join(taskFolder, ".reviewer-state.json");
221
+ }
222
+
223
+ function writeReviewerState(taskFolder: string, state: {
224
+ status: "running" | "done" | "error";
225
+ elapsedMs: number;
226
+ toolCalls: number;
227
+ contextPct: number;
228
+ costUsd: number;
229
+ lastTool: string;
230
+ inputTokens: number;
231
+ outputTokens: number;
232
+ cacheReadTokens: number;
233
+ cacheWriteTokens: number;
234
+ updatedAt: number;
235
+ reviewType?: string;
236
+ reviewStep?: number;
237
+ }): void {
238
+ const filePath = reviewerStatePath(taskFolder);
239
+ const tmpPath = filePath + ".tmp";
240
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
241
+ renameSync(tmpPath, filePath);
242
+ }
243
+
244
+ function removeReviewerState(taskFolder: string): void {
245
+ const filePath = reviewerStatePath(taskFolder);
246
+ if (!existsSync(filePath)) return;
247
+ try { unlinkSync(filePath); } catch { /* best effort */ }
248
+ }
249
+
219
250
  /**
220
251
  * Spawn a reviewer Pi subprocess and wait for it to complete.
221
252
  * Returns the process exit code.
222
253
  */
223
- function spawnReviewer(prompt: string, systemPrompt: string, cwd: string): Promise<number> {
254
+ function spawnReviewer(prompt: string, systemPrompt: string, cwd: string, taskFolder: string, reviewType?: string, reviewStep?: number): Promise<number> {
255
+ // Pre-clean stale reviewer state from prior interrupted review
256
+ removeReviewerState(taskFolder);
224
257
  return new Promise((resolve) => {
225
258
  const cliPath = resolvePiCli();
226
259
  const args = [
@@ -235,23 +268,124 @@ export default function (pi: ExtensionAPI) {
235
268
  env: { ...process.env },
236
269
  });
237
270
 
238
- // Send prompt and close stdin after delay
271
+ const startedAt = Date.now();
272
+ let inputTokens = 0;
273
+ let outputTokens = 0;
274
+ let cacheReadTokens = 0;
275
+ let cacheWriteTokens = 0;
276
+ let costUsd = 0;
277
+ let toolCalls = 0;
278
+ let lastTool = "";
279
+ let contextPct = 0;
280
+ let stdoutBuf = "";
281
+ let finalized = false;
282
+
283
+ const emitState = (status: "running" | "done" | "error") => {
284
+ try {
285
+ writeReviewerState(taskFolder, {
286
+ status,
287
+ elapsedMs: Date.now() - startedAt,
288
+ toolCalls,
289
+ contextPct,
290
+ costUsd,
291
+ lastTool,
292
+ inputTokens,
293
+ outputTokens,
294
+ cacheReadTokens,
295
+ cacheWriteTokens,
296
+ updatedAt: Date.now(),
297
+ reviewType,
298
+ reviewStep,
299
+ });
300
+ } catch { /* best effort */ }
301
+ };
302
+
303
+ // Write initial "running" state immediately so dashboard shows
304
+ // the reviewer sub-row before the first message_end arrives.
305
+ emitState("running");
306
+
307
+ const closeStdin = () => {
308
+ setTimeout(() => {
309
+ try { proc.stdin?.end(); } catch { /* ignore */ }
310
+ }, 100);
311
+ };
312
+
313
+ const finalize = (code: number) => {
314
+ if (finalized) return;
315
+ finalized = true;
316
+ emitState(code === 0 ? "done" : "error");
317
+ resolve(code);
318
+ };
319
+
320
+ const handleEvent = (event: any) => {
321
+ if (!event || typeof event.type !== "string") return;
322
+ switch (event.type) {
323
+ case "message_end": {
324
+ const usage = event.message?.usage;
325
+ if (usage) {
326
+ inputTokens += usage.input || 0;
327
+ outputTokens += usage.output || 0;
328
+ cacheReadTokens += usage.cacheRead || 0;
329
+ cacheWriteTokens += usage.cacheWrite || 0;
330
+ if (usage.cost) {
331
+ costUsd += typeof usage.cost === "object"
332
+ ? (usage.cost.total || 0)
333
+ : (typeof usage.cost === "number" ? usage.cost : 0);
334
+ }
335
+ }
336
+ emitState("running");
337
+ break;
338
+ }
339
+ case "tool_execution_start": {
340
+ toolCalls++;
341
+ const toolName = event.toolName || "tool";
342
+ const argPreview = typeof event.args === "string"
343
+ ? event.args.slice(0, 80)
344
+ : (event.args && typeof Object.values(event.args)[0] === "string"
345
+ ? String(Object.values(event.args)[0]).slice(0, 80)
346
+ : "");
347
+ lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
348
+ emitState("running");
349
+ break;
350
+ }
351
+ case "response": {
352
+ const pct = event.success === true ? event.data?.contextUsage?.percent : undefined;
353
+ if (typeof pct === "number" && Number.isFinite(pct)) {
354
+ contextPct = pct;
355
+ }
356
+ break;
357
+ }
358
+ case "agent_end": {
359
+ closeStdin();
360
+ break;
361
+ }
362
+ }
363
+ };
364
+
365
+ // Send prompt immediately
239
366
  proc.stdin?.write(JSON.stringify({ type: "prompt", message: prompt }) + "\n");
240
367
 
241
- // Watch for agent_end event to close stdin
242
- let buf = "";
243
- proc.stdout?.on("data", (chunk: Buffer) => {
244
- buf += chunk.toString();
245
- if (buf.includes('"agent_end"')) {
246
- setTimeout(() => { try { proc.stdin?.end(); } catch {} }, 100);
368
+ proc.stdout?.on("data", (chunk: Buffer | string) => {
369
+ stdoutBuf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
370
+ let idx = -1;
371
+ while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
372
+ let line = stdoutBuf.slice(0, idx);
373
+ stdoutBuf = stdoutBuf.slice(idx + 1);
374
+ if (line.endsWith("\r")) line = line.slice(0, -1);
375
+ if (!line.trim()) continue;
376
+ let event: any;
377
+ try { event = JSON.parse(line); } catch { continue; }
378
+ handleEvent(event);
247
379
  }
248
380
  });
249
381
 
250
- proc.on("close", (code) => resolve(code ?? 1));
251
- proc.on("error", () => resolve(1));
382
+ proc.on("close", (code) => finalize(code ?? 1));
383
+ proc.on("error", () => finalize(1));
252
384
 
253
385
  // Timeout: 10 minutes
254
- setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, 10 * 60 * 1000);
386
+ setTimeout(() => {
387
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
388
+ }, 10 * 60 * 1000);
255
389
  });
256
390
  }
257
391
 
@@ -367,7 +501,7 @@ export default function (pi: ExtensionAPI) {
367
501
 
368
502
  try {
369
503
  const systemPrompt = loadReviewerPrompt();
370
- const exitCode = await spawnReviewer(reviewPrompt, systemPrompt, cwd);
504
+ const exitCode = await spawnReviewer(reviewPrompt, systemPrompt, cwd, taskFolder, reviewType, stepNum);
371
505
 
372
506
  // Update review counter in STATUS.md
373
507
  try {
@@ -395,6 +529,8 @@ export default function (pi: ExtensionAPI) {
395
529
  writeFileSync(statusPath, status.trimEnd() + "\n" + logEntry);
396
530
  } catch { /* best effort */ }
397
531
 
532
+ removeReviewerState(taskFolder);
533
+
398
534
  const reviewFile = `.reviews/R${num}-${reviewType}-step${stepNum}.md`;
399
535
  if (verdict === "APPROVE") {
400
536
  return { content: [{ type: "text" as const, text: `APPROVE` }], details: undefined };
@@ -408,9 +544,11 @@ export default function (pi: ExtensionAPI) {
408
544
  return { content: [{ type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` }], details: undefined };
409
545
  }
410
546
  } else {
547
+ removeReviewerState(taskFolder);
411
548
  return { content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer exited (code ${exitCode}) but produced no output.` }], details: undefined };
412
549
  }
413
550
  } catch (err) {
551
+ removeReviewerState(taskFolder);
414
552
  return {
415
553
  content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer failed: ${err instanceof Error ? err.message : String(err)}` }],
416
554
  details: undefined,
@@ -610,7 +610,7 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
610
610
  onTaskFailure: "skip-dependents",
611
611
  onMergeFailure: "pause",
612
612
  stallTimeout: 30,
613
- maxWorkerMinutes: 30,
613
+ maxWorkerMinutes: 120,
614
614
  abortGracePeriod: 60,
615
615
  },
616
616
  monitoring: {
@@ -2214,7 +2214,7 @@ export async function executeLaneV2(
2214
2214
  projectName: config.project?.name || "project",
2215
2215
  maxIterations: 20,
2216
2216
  noProgressLimit: 3,
2217
- maxWorkerMinutes: config.failure?.maxWorkerMinutes || 30,
2217
+ maxWorkerMinutes: config.failure?.maxWorkerMinutes || 120,
2218
2218
  warnPercent: 85,
2219
2219
  killPercent: 95,
2220
2220
  onSupervisorAlert,
@@ -283,27 +283,43 @@ export async function executeTaskV2(
283
283
 
284
284
  // Context pressure: write wrap-up signal before kill
285
285
  let workerKillReason: "context" | "timer" | null = null;
286
+ let iterationTelemetry: Partial<AgentHostResult> = {};
286
287
 
287
288
  const spawned = spawnAgent(hostOpts, undefined, (telemetry) => {
288
- // Context pressure check
289
- if (telemetry.contextUsage) {
290
- const pct = telemetry.contextUsage.percent;
291
- if (pct >= config.warnPercent) {
292
- const msg = `Wrap up (context ${Math.round(pct)}%)`;
293
- if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
294
- }
295
- if (pct >= config.killPercent) {
296
- workerKillReason = "context";
297
- spawned.kill();
289
+ try {
290
+ // Context pressure check
291
+ if (telemetry.contextUsage) {
292
+ const pct = telemetry.contextUsage.percent;
293
+ if (pct >= config.warnPercent) {
294
+ const msg = `Wrap up (context ${Math.round(pct)}%)`;
295
+ if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
296
+ }
297
+ if (pct >= config.killPercent) {
298
+ workerKillReason = "context";
299
+ spawned.kill();
300
+ }
298
301
  }
299
- }
300
302
 
301
- lastTelemetry = telemetry;
302
- // Emit lane snapshot
303
- emitSnapshot(config, taskId, "running", telemetry, statusPath);
303
+ iterationTelemetry = telemetry;
304
+ lastTelemetry = telemetry;
305
+ // Emit lane snapshot
306
+ emitSnapshot(config, taskId, "running", telemetry, statusPath);
307
+ } catch { /* non-fatal: telemetry callback must never crash the engine */ }
304
308
  });
305
309
 
306
- const workerResult = await spawned.promise;
310
+ // Reviewer telemetry is written by the worker bridge during review_step.
311
+ // Poll snapshot refresh independently from worker message_end cadence so
312
+ // the dashboard sees reviewer activity while tool calls are in-flight.
313
+ const reviewerRefresh = setInterval(() => {
314
+ try { emitSnapshot(config, taskId, "running", iterationTelemetry, statusPath); } catch { /* non-fatal */ }
315
+ }, 1000);
316
+
317
+ let workerResult: AgentHostResult;
318
+ try {
319
+ workerResult = await spawned.promise;
320
+ } finally {
321
+ clearInterval(reviewerRefresh);
322
+ }
307
323
 
308
324
  // TP-115: Update lastTelemetry with definitive final values from AgentHostResult
309
325
  lastTelemetry = workerResult;
@@ -538,6 +554,65 @@ function makeResult(
538
554
  return result;
539
555
  }
540
556
 
557
+ /** Max age for reviewer state file before it's considered stale (2 minutes). */
558
+ const REVIEWER_STATE_STALE_MS = 120_000;
559
+
560
+ export function readReviewerTelemetrySnapshot(
561
+ config: LaneRunnerConfig,
562
+ statusPath: string,
563
+ ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
564
+ const reviewerPath = join(dirname(statusPath), ".reviewer-state.json");
565
+ if (!existsSync(reviewerPath)) return null;
566
+
567
+ try {
568
+ const raw = readFileSync(reviewerPath, "utf-8");
569
+ const parsed = JSON.parse(raw) as Partial<{
570
+ status: string;
571
+ elapsedMs: number;
572
+ toolCalls: number;
573
+ contextPct: number;
574
+ costUsd: number;
575
+ lastTool: string;
576
+ inputTokens: number;
577
+ outputTokens: number;
578
+ cacheReadTokens: number;
579
+ cacheWriteTokens: number;
580
+ updatedAt: number;
581
+ reviewType: string;
582
+ reviewStep: number;
583
+ }>;
584
+
585
+ if (parsed.status !== "running") return null;
586
+
587
+ // Stale guard: if updatedAt is present and older than threshold, ignore
588
+ if (parsed.updatedAt && (Date.now() - parsed.updatedAt) > REVIEWER_STATE_STALE_MS) return null;
589
+
590
+ return {
591
+ agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
592
+ status: "running",
593
+ elapsedMs: Number.isFinite(parsed.elapsedMs) ? Number(parsed.elapsedMs) : 0,
594
+ toolCalls: Number.isFinite(parsed.toolCalls) ? Number(parsed.toolCalls) : 0,
595
+ contextPct: Number.isFinite(parsed.contextPct) ? Number(parsed.contextPct) : 0,
596
+ costUsd: Number.isFinite(parsed.costUsd) ? Number(parsed.costUsd) : 0,
597
+ lastTool: typeof parsed.lastTool === "string" ? parsed.lastTool : "",
598
+ inputTokens: Number.isFinite(parsed.inputTokens) ? Number(parsed.inputTokens) : 0,
599
+ outputTokens: Number.isFinite(parsed.outputTokens) ? Number(parsed.outputTokens) : 0,
600
+ cacheReadTokens: Number.isFinite(parsed.cacheReadTokens) ? Number(parsed.cacheReadTokens) : 0,
601
+ cacheWriteTokens: Number.isFinite(parsed.cacheWriteTokens) ? Number(parsed.cacheWriteTokens) : 0,
602
+ reviewType: typeof parsed.reviewType === "string" ? parsed.reviewType : undefined,
603
+ reviewStep: Number.isFinite(parsed.reviewStep) ? Number(parsed.reviewStep) : undefined,
604
+ };
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Emit a lane snapshot to disk. NON-THROWING by contract — all errors are
612
+ * caught and logged. This function is called from setInterval callbacks
613
+ * and onTelemetry callbacks where an unhandled throw would trigger
614
+ * uncaughtException and crash the engine-worker process.
615
+ */
541
616
  function emitSnapshot(
542
617
  config: LaneRunnerConfig,
543
618
  taskId: string,
@@ -545,49 +620,56 @@ function emitSnapshot(
545
620
  telemetry: Partial<AgentHostResult>,
546
621
  statusPath: string,
547
622
  ): void {
548
- // Parse progress from STATUS.md
549
- let progress: RuntimeTaskProgress | null = null;
550
623
  try {
551
- const content = readFileSync(statusPath, "utf-8");
552
- const parsed = parseStatusMd(content);
553
- const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
554
- const checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
555
- const total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
556
- progress = {
557
- currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
558
- checked,
559
- total,
560
- iteration: parsed.iteration,
561
- reviews: parsed.reviewCounter,
624
+ // Parse progress from STATUS.md
625
+ let progress: RuntimeTaskProgress | null = null;
626
+ try {
627
+ const content = readFileSync(statusPath, "utf-8");
628
+ const parsed = parseStatusMd(content);
629
+ const currentStepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
630
+ const checked = parsed.steps.reduce((sum, s) => sum + s.totalChecked, 0);
631
+ const total = parsed.steps.reduce((sum, s) => sum + s.totalItems, 0);
632
+ progress = {
633
+ currentStep: currentStepMatch?.[1]?.trim() || "Unknown",
634
+ checked,
635
+ total,
636
+ iteration: parsed.iteration,
637
+ reviews: parsed.reviewCounter,
638
+ };
639
+ } catch { /* best effort */ }
640
+
641
+ const reviewerSnapshot = readReviewerTelemetrySnapshot(config, statusPath);
642
+
643
+ const snapshot: RuntimeLaneSnapshot = {
644
+ batchId: config.batchId,
645
+ laneNumber: config.laneNumber,
646
+ laneId: `lane-${config.laneNumber}`,
647
+ repoId: config.repoId,
648
+ taskId,
649
+ segmentId: null,
650
+ status,
651
+ worker: {
652
+ agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"),
653
+ status: mapLaneSnapshotStatusToWorkerStatus(status),
654
+ elapsedMs: telemetry.durationMs ?? 0,
655
+ toolCalls: telemetry.toolCalls ?? 0,
656
+ contextPct: telemetry.contextUsage?.percent ?? 0,
657
+ costUsd: telemetry.costUsd ?? 0,
658
+ lastTool: telemetry.lastTool ?? "",
659
+ inputTokens: telemetry.inputTokens ?? 0,
660
+ outputTokens: telemetry.outputTokens ?? 0,
661
+ cacheReadTokens: telemetry.cacheReadTokens ?? 0,
662
+ cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
663
+ },
664
+ reviewer: reviewerSnapshot,
665
+ progress,
666
+ updatedAt: Date.now(),
562
667
  };
563
- } catch { /* best effort */ }
564
-
565
- const snapshot: RuntimeLaneSnapshot = {
566
- batchId: config.batchId,
567
- laneNumber: config.laneNumber,
568
- laneId: `lane-${config.laneNumber}`,
569
- repoId: config.repoId,
570
- taskId,
571
- segmentId: null,
572
- status,
573
- worker: {
574
- agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"),
575
- status: mapLaneSnapshotStatusToWorkerStatus(status),
576
- elapsedMs: telemetry.durationMs ?? 0,
577
- toolCalls: telemetry.toolCalls ?? 0,
578
- contextPct: telemetry.contextUsage?.percent ?? 0,
579
- costUsd: telemetry.costUsd ?? 0,
580
- lastTool: telemetry.lastTool ?? "",
581
- inputTokens: telemetry.inputTokens ?? 0,
582
- outputTokens: telemetry.outputTokens ?? 0,
583
- cacheReadTokens: telemetry.cacheReadTokens ?? 0,
584
- cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
585
- },
586
- reviewer: null,
587
- progress,
588
- updatedAt: Date.now(),
589
- };
590
668
 
591
- writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
669
+ writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
670
+ } catch {
671
+ // Non-fatal: snapshot is telemetry, not execution-critical.
672
+ // Swallow to prevent uncaughtException crash in setInterval/callback contexts.
673
+ }
592
674
  }
593
675
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",