taskplane 0.22.11 → 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.
@@ -456,6 +456,34 @@ function writeLaneState(state: TaskState): void {
456
456
  }
457
457
  }
458
458
 
459
+ /**
460
+ * Write a context % snapshot at worker iteration boundary (TP-094).
461
+ * Best-effort JSONL append to `.pi/context-snapshots/{batchId}/{sessionName}.jsonl`.
462
+ * Non-fatal on any failure — never blocks execution.
463
+ */
464
+ function writeContextSnapshot(state: TaskState, contextWindow: number): void {
465
+ const batchId = process.env.ORCH_BATCH_ID || "standalone";
466
+ const sessionName = isOrchestratedMode() ? `${getTmuxPrefix()}-worker` : "task-worker";
467
+ try {
468
+ const dir = join(getSidecarDir(), "context-snapshots", batchId);
469
+ mkdirSync(dir, { recursive: true });
470
+ const filePath = join(dir, `${sessionName}.jsonl`);
471
+ const snapshot = {
472
+ iteration: state.totalIterations,
473
+ contextPct: state.workerContextPct,
474
+ tokens: state.workerInputTokens + state.workerOutputTokens + state.workerCacheReadTokens + state.workerCacheWriteTokens,
475
+ contextWindow,
476
+ cost: state.workerCostUsd,
477
+ toolCalls: state.workerToolCount,
478
+ exitReason: state.workerExitDiagnostic?.classification || null,
479
+ timestamp: Date.now(),
480
+ };
481
+ appendFileSync(filePath, JSON.stringify(snapshot) + "\n");
482
+ } catch {
483
+ // Best effort — don't crash the runner
484
+ }
485
+ }
486
+
459
487
  /**
460
488
  * Append a JSON event to the conversation JSONL log file.
461
489
  * Used in orchestrated mode to capture the full worker conversation for the web dashboard.
@@ -1371,7 +1399,9 @@ interface SidecarTelemetryDelta {
1371
1399
  /** Whether any sidecar events were parsed in this tick (used for callback gating) */
1372
1400
  hadEvents: boolean;
1373
1401
  /** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
1374
- contextUsage: { percentUsed: number; totalTokens: number; maxTokens: number } | null;
1402
+ contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
1403
+ /** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
1404
+ sawStatsResponseWithoutContextUsage: boolean;
1375
1405
  }
1376
1406
 
1377
1407
  /**
@@ -1390,7 +1420,7 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1390
1420
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
1391
1421
  cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
1392
1422
  retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
1393
- hadEvents: false, contextUsage: null,
1423
+ hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
1394
1424
  };
1395
1425
 
1396
1426
  // Gracefully handle missing file (wrapper hasn't written yet)
@@ -1506,13 +1536,18 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1506
1536
  // get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
1507
1537
  if (event.success === true && event.data?.contextUsage) {
1508
1538
  const cu = event.data.contextUsage;
1509
- if (typeof cu.percentUsed === "number") {
1539
+ // pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
1540
+ const pctValue = cu.percent ?? cu.percentUsed;
1541
+ if (typeof pctValue === "number") {
1510
1542
  delta.contextUsage = {
1511
- percentUsed: cu.percentUsed,
1543
+ percent: pctValue,
1512
1544
  totalTokens: cu.totalTokens || 0,
1513
1545
  maxTokens: cu.maxTokens || 0,
1514
1546
  };
1515
1547
  }
1548
+ } else if (event.success === true && event.data && !event.data.contextUsage) {
1549
+ // Successful get_session_stats response but no contextUsage — older pi
1550
+ delta.sawStatsResponseWithoutContextUsage = true;
1516
1551
  }
1517
1552
  break;
1518
1553
  }
@@ -1656,6 +1691,25 @@ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
1656
1691
 
1657
1692
  // ── TMUX Agent Spawner ───────────────────────────────────────────────
1658
1693
 
1694
+ /**
1695
+ * Synchronous sleep helper for tmux spawn stabilization checks.
1696
+ *
1697
+ * Uses Atomics.wait for cross-platform blocking delays without relying on
1698
+ * shell `sleep` availability (important on Windows environments).
1699
+ */
1700
+ function sleepSyncMs(ms: number): void {
1701
+ if (!Number.isFinite(ms) || ms <= 0) return;
1702
+ try {
1703
+ const arr = new Int32Array(new SharedArrayBuffer(4));
1704
+ Atomics.wait(arr, 0, 0, Math.floor(ms));
1705
+ } catch {
1706
+ const start = Date.now();
1707
+ while (Date.now() - start < ms) {
1708
+ // Busy-wait fallback (rare path)
1709
+ }
1710
+ }
1711
+ }
1712
+
1659
1713
  /**
1660
1714
  * Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
1661
1715
  * Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
@@ -1835,6 +1889,15 @@ function spawnAgentTmux(opts: {
1835
1889
  if (opts.extensions && opts.extensions.length > 0) {
1836
1890
  wrapperArgs.push("--extensions", quoteArg(opts.extensions.join(",")));
1837
1891
  }
1892
+ // TP-089: Agent mailbox steering — construct mailbox dir when in orchestrator mode.
1893
+ // ORCH_BATCH_ID is set by execution.ts for all lane spawns (including retries).
1894
+ // getSidecarDir() returns the .pi/ directory path (already includes .pi/).
1895
+ const orchBatchId = process.env.ORCH_BATCH_ID;
1896
+ if (orchBatchId) {
1897
+ const mailboxDir = join(getSidecarDir(), "mailbox", orchBatchId, opts.sessionName);
1898
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
1899
+ wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
1900
+ }
1838
1901
  // Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
1839
1902
  // Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
1840
1903
  wrapperArgs.push("--");
@@ -1881,6 +1944,73 @@ function spawnAgentTmux(opts: {
1881
1944
  );
1882
1945
  }
1883
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
+
1884
2014
  console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
1885
2015
 
1886
2016
 
@@ -2452,11 +2582,9 @@ export default function (pi: ExtensionAPI) {
2452
2582
  state.reviewerLastTool = delta.lastTool;
2453
2583
  }
2454
2584
 
2455
- // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2585
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
2456
2586
  if (delta.contextUsage) {
2457
- state.reviewerContextPct = delta.contextUsage.percentUsed;
2458
- } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2459
- state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2587
+ state.reviewerContextPct = delta.contextUsage.percent;
2460
2588
  }
2461
2589
 
2462
2590
  writeLaneState(state);
@@ -2659,11 +2787,9 @@ export default function (pi: ExtensionAPI) {
2659
2787
  state.reviewerCostUsd += delta.cost;
2660
2788
  state.reviewerToolCount += delta.toolCalls;
2661
2789
  if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
2662
- // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2790
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
2663
2791
  if (delta.contextUsage) {
2664
- state.reviewerContextPct = delta.contextUsage.percentUsed;
2665
- } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2666
- state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2792
+ state.reviewerContextPct = delta.contextUsage.percent;
2667
2793
  }
2668
2794
  writeLaneState(state);
2669
2795
  updateWidgets();
@@ -2814,8 +2940,35 @@ export default function (pi: ExtensionAPI) {
2814
2940
  if (isStepComplete(ss)) completedBefore.add(ss.number);
2815
2941
  }
2816
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
+
2817
2966
  await runWorker(remainingSteps, ctx);
2818
2967
 
2968
+ // Write context % snapshot at iteration boundary (TP-094)
2969
+ const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
2970
+ writeContextSnapshot(state, snapshotContextWindow);
2971
+
2819
2972
  if (state.phase === "error") {
2820
2973
  await shutdownPersistentReviewer("worker error");
2821
2974
  return;
@@ -3220,7 +3373,10 @@ export default function (pi: ExtensionAPI) {
3220
3373
  state.workerElapsed = 0;
3221
3374
  state.workerContextPct = 0;
3222
3375
  state.workerLastTool = "";
3223
- state.workerToolCount = 0;
3376
+ // TP-095: Don't reset workerToolCount accumulate across iterations (#334).
3377
+ // Previous behavior zeroed the counter on each iteration, losing totals
3378
+ // when a worker crashed and restarted. Token/cost counters already
3379
+ // accumulate via += in onTelemetry and were never reset here.
3224
3380
  state.workerRetryActive = false;
3225
3381
  state.workerRetryCount = 0;
3226
3382
  state.workerLastRetryError = "";
@@ -3248,6 +3404,8 @@ export default function (pi: ExtensionAPI) {
3248
3404
  const warnPct = config.context.warn_percent;
3249
3405
  const killPct = config.context.kill_percent;
3250
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;
3251
3409
 
3252
3410
  if (spawnMode === "tmux") {
3253
3411
  // ── TMUX mode ────────────────────────────────────────
@@ -3286,14 +3444,10 @@ export default function (pi: ExtensionAPI) {
3286
3444
  state.workerLastRetryError = delta.lastRetryError;
3287
3445
  }
3288
3446
 
3289
- // Context % — prefer authoritative contextUsage from pi ≥ 0.63.0,
3290
- // fall back to manual calculation from totalTokens + cacheRead.
3291
- {
3292
- const pct = delta.contextUsage
3293
- ? delta.contextUsage.percentUsed
3294
- : (delta.latestTotalTokens > 0 && contextWindow > 0)
3295
- ? (delta.latestTotalTokens / contextWindow) * 100
3296
- : 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;
3297
3451
  if (pct > 0) {
3298
3452
  state.workerContextPct = pct;
3299
3453
  if (pct >= warnPct) {
@@ -3305,6 +3459,10 @@ export default function (pi: ExtensionAPI) {
3305
3459
  spawned.kill();
3306
3460
  }
3307
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`);
3308
3466
  }
3309
3467
 
3310
3468
  updateWidgets();
@@ -19,8 +19,9 @@
19
19
  * @module orch/cleanup
20
20
  * @since TP-065
21
21
  */
22
- import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync } from "fs";
22
+ import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync, rmSync } from "fs";
23
23
  import { join } from "path";
24
+ import { MAILBOX_DIR_NAME } from "./types.ts";
24
25
 
25
26
  // ── Layer 1: Post-Integrate Cleanup ─────────────────────────────────
26
27
 
@@ -34,6 +35,10 @@ export interface PostIntegrateCleanupResult {
34
35
  mergeFilesDeleted: number;
35
36
  /** Number of lane prompt files deleted */
36
37
  promptFilesDeleted: number;
38
+ /** Number of mailbox batch directories deleted (0 or 1) */
39
+ mailboxDirsDeleted: number;
40
+ /** Number of context-snapshot batch directories deleted (0 or 1) */
41
+ snapshotDirsDeleted: number;
37
42
  /** Warnings from non-fatal cleanup failures */
38
43
  warnings: string[];
39
44
  }
@@ -57,6 +62,8 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
57
62
  telemetryFilesDeleted: 0,
58
63
  mergeFilesDeleted: 0,
59
64
  promptFilesDeleted: 0,
65
+ mailboxDirsDeleted: 0,
66
+ snapshotDirsDeleted: 0,
60
67
  warnings: [],
61
68
  };
62
69
 
@@ -119,6 +126,28 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
119
126
  }
120
127
  }
121
128
 
129
+ // ── Mailbox directory (.pi/mailbox/{batchId}/) ───────────
130
+ const mailboxBatchDir = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
131
+ if (existsSync(mailboxBatchDir)) {
132
+ try {
133
+ rmSync(mailboxBatchDir, { recursive: true, force: true });
134
+ result.mailboxDirsDeleted = 1;
135
+ } catch (err: unknown) {
136
+ result.warnings.push(`Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`);
137
+ }
138
+ }
139
+
140
+ // ── Context snapshots directory (.pi/context-snapshots/{batchId}/) ──────
141
+ const snapshotBatchDir = join(stateRoot, ".pi", "context-snapshots", batchId);
142
+ if (existsSync(snapshotBatchDir)) {
143
+ try {
144
+ rmSync(snapshotBatchDir, { recursive: true, force: true });
145
+ result.snapshotDirsDeleted = 1;
146
+ } catch (err: unknown) {
147
+ result.warnings.push(`Failed to delete context-snapshots directory ${snapshotBatchDir}: ${(err as Error).message}`);
148
+ }
149
+ }
150
+
122
151
  return result;
123
152
  }
124
153
 
@@ -127,13 +156,15 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
127
156
  */
128
157
  export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string {
129
158
  const parts: string[] = [];
130
- const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted;
159
+ const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted + result.mailboxDirsDeleted + result.snapshotDirsDeleted;
131
160
 
132
161
  if (totalDeleted > 0) {
133
162
  const segments: string[] = [];
134
163
  if (result.telemetryFilesDeleted > 0) segments.push(`${result.telemetryFilesDeleted} telemetry`);
135
164
  if (result.mergeFilesDeleted > 0) segments.push(`${result.mergeFilesDeleted} merge`);
136
165
  if (result.promptFilesDeleted > 0) segments.push(`${result.promptFilesDeleted} prompt`);
166
+ if (result.mailboxDirsDeleted > 0) segments.push(`${result.mailboxDirsDeleted} mailbox`);
167
+ if (result.snapshotDirsDeleted > 0) segments.push(`${result.snapshotDirsDeleted} snapshots`);
137
168
  parts.push(`🧹 Cleaned up ${totalDeleted} artifact file(s): ${segments.join(", ")}`);
138
169
  }
139
170
 
@@ -155,6 +186,8 @@ export const STALE_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
155
186
  export interface PreflightSweepResult {
156
187
  /** Number of stale files deleted */
157
188
  staleFilesDeleted: number;
189
+ /** Number of stale mailbox batch directories deleted */
190
+ staleDirsDeleted: number;
158
191
  /** Whether the sweep was skipped (e.g., active batch) */
159
192
  skipped: boolean;
160
193
  /** Reason for skipping (if skipped) */
@@ -198,6 +231,7 @@ export function sweepStaleArtifacts(
198
231
  ): PreflightSweepResult {
199
232
  const result: PreflightSweepResult = {
200
233
  staleFilesDeleted: 0,
234
+ staleDirsDeleted: 0,
201
235
  skipped: false,
202
236
  warnings: [],
203
237
  };
@@ -255,6 +289,35 @@ export function sweepStaleArtifacts(
255
289
  (name.startsWith("merge-request-") && name.endsWith(".txt")),
256
290
  );
257
291
 
292
+ // Sweep stale batch directories under a parent (mailbox, context-snapshots)
293
+ const sweepBatchDirs = (parentDir: string, label: string): void => {
294
+ if (!existsSync(parentDir)) return;
295
+ try {
296
+ const entries = readdirSync(parentDir);
297
+ for (const entry of entries) {
298
+ const entryPath = join(parentDir, entry);
299
+ try {
300
+ const stat = statSync(entryPath);
301
+ if (!stat.isDirectory()) continue;
302
+ if (stat.mtimeMs < cutoff) {
303
+ rmSync(entryPath, { recursive: true, force: true });
304
+ result.staleDirsDeleted++;
305
+ }
306
+ } catch (err: unknown) {
307
+ result.warnings.push(`Failed to process ${label} dir ${entry}: ${(err as Error).message}`);
308
+ }
309
+ }
310
+ } catch (err: unknown) {
311
+ result.warnings.push(`Failed to read ${label} directory ${parentDir}: ${(err as Error).message}`);
312
+ }
313
+ };
314
+
315
+ // Sweep stale mailbox batch directories (.pi/mailbox/{batchId}/)
316
+ sweepBatchDirs(join(stateRoot, ".pi", MAILBOX_DIR_NAME), "mailbox");
317
+
318
+ // Sweep stale context-snapshot batch directories (.pi/context-snapshots/{batchId}/)
319
+ sweepBatchDirs(join(stateRoot, ".pi", "context-snapshots"), "context-snapshots");
320
+
258
321
  return result;
259
322
  }
260
323
 
@@ -265,12 +328,15 @@ export function formatPreflightSweep(result: PreflightSweepResult): string {
265
328
  if (result.skipped) {
266
329
  return `ℹ️ Preflight sweep skipped: ${result.skipReason}`;
267
330
  }
268
- if (result.staleFilesDeleted === 0 && result.warnings.length === 0) {
331
+ if (result.staleFilesDeleted === 0 && result.staleDirsDeleted === 0 && result.warnings.length === 0) {
269
332
  return ""; // Nothing to report
270
333
  }
271
334
  const parts: string[] = [];
272
- if (result.staleFilesDeleted > 0) {
273
- parts.push(`🧹 Preflight cleanup: removed ${result.staleFilesDeleted} stale artifact(s) (>7 days old)`);
335
+ if (result.staleFilesDeleted > 0 || result.staleDirsDeleted > 0) {
336
+ const segments: string[] = [];
337
+ if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
338
+ if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
339
+ parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>7 days old)`);
274
340
  }
275
341
  for (const warning of result.warnings) {
276
342
  parts.push(` ⚠️ ${warning}`);
@@ -396,8 +462,11 @@ export function formatPreflightCleanup(result: PreflightCleanupResult): string {
396
462
  const parts: string[] = [];
397
463
 
398
464
  // Layer 2: age-based sweep
399
- if (!result.sweep.skipped && result.sweep.staleFilesDeleted > 0) {
400
- parts.push(`removed ${result.sweep.staleFilesDeleted} stale artifact(s) (>7 days old)`);
465
+ if (!result.sweep.skipped && (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0)) {
466
+ const segments: string[] = [];
467
+ if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
468
+ if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
469
+ parts.push(`removed ${segments.join(" and ")} (>7 days old)`);
401
470
  }
402
471
 
403
472
  // Layer 3: log rotation
@@ -139,7 +139,7 @@ export interface ExitSummary {
139
139
  compactions: number;
140
140
  /** Wall-clock duration of the session in seconds (always written, even on crash) */
141
141
  durationSec: number;
142
- /** Last tool call description (e.g., "bash: npx vitest run"), null if no tools were called */
142
+ /** Last tool call description (e.g., "bash: node --test tests/*.test.ts"), null if no tools were called */
143
143
  lastToolCall: string | null;
144
144
  /** Error message if the session ended with an error, null on clean exit */
145
145
  error: string | null;
@@ -85,7 +85,7 @@ export interface SerializedWorkspaceConfig {
85
85
  * workerData shape passed from the main thread.
86
86
  */
87
87
  export interface EngineWorkerData {
88
- /** Sentinel flag — distinguishes engine worker from vitest threads */
88
+ /** Sentinel flag — distinguishes engine worker from test-runner worker threads */
89
89
  engineWorker: true;
90
90
  /** "execute" for new batch, "resume" for resume */
91
91
  mode: "execute" | "resume";
@@ -228,6 +228,7 @@ async function attemptWorkerCrashRetry(
228
228
  retryPauseSignal,
229
229
  wsRoot,
230
230
  isWsMode,
231
+ { ORCH_BATCH_ID: batchState.batchId }, // TP-089: ensure mailbox works for retries
231
232
  );
232
233
 
233
234
  const retryOutcome = retryResult.tasks[0];
@@ -484,7 +485,8 @@ async function attemptModelFallbackRetry(
484
485
  const retryPauseSignal = { paused: false };
485
486
  // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
486
487
  // the task-runner to use the session model instead of configured model.
487
- const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1" };
488
+ // TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
489
+ const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId };
488
490
  const retryResult = await executeLane(
489
491
  retryLane,
490
492
  orchConfig,
@@ -1086,18 +1088,6 @@ export async function executeOrchBatch(
1086
1088
  continue;
1087
1089
  }
1088
1090
 
1089
- onNotify(
1090
- ORCH_MESSAGES.orchWaveStart(waveIdx + 1, rawWaves.length, waveTasks.length, Math.min(waveTasks.length, orchConfig.orchestrator.max_lanes)),
1091
- "info",
1092
- );
1093
-
1094
- // TP-040: Emit wave_start event
1095
- emitEvent(stateRoot, {
1096
- ...buildEngineEventBase("wave_start", batchState.batchId, waveIdx, batchState.phase),
1097
- taskIds: waveTasks,
1098
- laneCount: Math.min(waveTasks.length, orchConfig.orchestrator.max_lanes),
1099
- }, onEngineEvent);
1100
-
1101
1091
  const handleWaveMonitorUpdate: MonitorUpdateCallback = (monitorState) => {
1102
1092
  const changed = syncTaskOutcomesFromMonitor(monitorState, allTaskOutcomes);
1103
1093
  if (changed) {
@@ -1110,6 +1100,17 @@ export async function executeOrchBatch(
1110
1100
  const onLanesAllocatedCb = (lanes: AllocatedLane[]) => {
1111
1101
  latestAllocatedLanes = lanes;
1112
1102
  batchState.currentLanes = lanes;
1103
+
1104
+ // Emit wave_start with actual lane count (post-affinity grouping)
1105
+ onNotify(
1106
+ ORCH_MESSAGES.orchWaveStart(waveIdx + 1, rawWaves.length, waveTasks.length, lanes.length),
1107
+ "info",
1108
+ );
1109
+ emitEvent(stateRoot, {
1110
+ ...buildEngineEventBase("wave_start", batchState.batchId, waveIdx, batchState.phase),
1111
+ taskIds: waveTasks,
1112
+ laneCount: lanes.length,
1113
+ }, onEngineEvent);
1113
1114
  // TP-029: Track repos from newly allocated lanes for cleanup coverage
1114
1115
  for (const lane of lanes) {
1115
1116
  const laneRepoRoot = resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig);
@@ -594,9 +594,26 @@ export function buildTmuxSpawnArgs(
594
594
  piCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`;
595
595
  }
596
596
 
597
- // NOTE: Do not redirect lane output here. Shell redirection has proven
598
- // fragile across Windows + tmux environments and can prevent session spawn.
599
- // Diagnostics use tmux pane capture + STATUS tail in pollUntilTaskComplete().
597
+ // TP-095: Capture lane session stderr to a log file (#339).
598
+ // When the lane session (rpc-wrapper pi task-runner) dies, stderr is
599
+ // lost to tmux scrollback. Redirect stderr to a persistent log file
600
+ // co-located with telemetry so the supervisor can diagnose lane deaths.
601
+ //
602
+ // We append stderr to a file using `2>>`. This captures all stderr output
603
+ // from rpc-wrapper (which includes pi stderr forwarding, progress display,
604
+ // and crash diagnostics). The tmux pane loses live stderr visibility, but
605
+ // the dashboard provides live monitoring and the file preserves everything
606
+ // for post-mortem analysis.
607
+ //
608
+ // Appended to piCommand (not the tmux shell wrapper) to target the
609
+ // node/rpc-wrapper process specifically. This avoids the fragile shell
610
+ // redirection issues that previously caused spawn failures on Windows.
611
+ if (sidecarPath) {
612
+ // Derive stderr log path from sidecar path:
613
+ // .pi/telemetry/{basename}.jsonl → .pi/telemetry/{basename}-stderr.log
614
+ const stderrLogPath = sidecarPath.replace(/\.jsonl$/, "-stderr.log");
615
+ piCommand = `${piCommand} 2>> ${shellQuote(stderrLogPath)}`;
616
+ }
600
617
 
601
618
  const tmuxWorktreePath = toTmuxPath(worktreePath);
602
619
  const wrappedCommand = `cd ${shellQuote(tmuxWorktreePath)} && ${piCommand}`;