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.
- package/bin/rpc-wrapper.mjs +183 -2
- package/dashboard/public/app.js +108 -55
- package/dashboard/public/style.css +18 -0
- package/dashboard/server.cjs +29 -3
- package/extensions/task-runner.ts +179 -21
- package/extensions/taskplane/cleanup.ts +76 -7
- package/extensions/taskplane/diagnostics.ts +1 -1
- package/extensions/taskplane/engine-worker.ts +1 -1
- package/extensions/taskplane/engine.ts +14 -13
- package/extensions/taskplane/execution.ts +20 -3
- package/extensions/taskplane/extension.ts +616 -11
- package/extensions/taskplane/mailbox.ts +341 -0
- package/extensions/taskplane/merge.ts +11 -2
- package/extensions/taskplane/supervisor-primer.md +9 -3
- package/extensions/taskplane/supervisor.ts +2 -2
- package/extensions/taskplane/types.ts +95 -1
- package/extensions/taskplane/verification.ts +14 -9
- package/package.json +1 -1
- package/templates/agents/supervisor.md +1 -1
- package/templates/agents/task-worker.md +8 -14
|
@@ -456,6 +456,34 @@ function writeLaneState(state: TaskState): void {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Write a context % snapshot at worker iteration boundary (TP-094).
|
|
461
|
+
* Best-effort JSONL append to `.pi/context-snapshots/{batchId}/{sessionName}.jsonl`.
|
|
462
|
+
* Non-fatal on any failure — never blocks execution.
|
|
463
|
+
*/
|
|
464
|
+
function writeContextSnapshot(state: TaskState, contextWindow: number): void {
|
|
465
|
+
const batchId = process.env.ORCH_BATCH_ID || "standalone";
|
|
466
|
+
const sessionName = isOrchestratedMode() ? `${getTmuxPrefix()}-worker` : "task-worker";
|
|
467
|
+
try {
|
|
468
|
+
const dir = join(getSidecarDir(), "context-snapshots", batchId);
|
|
469
|
+
mkdirSync(dir, { recursive: true });
|
|
470
|
+
const filePath = join(dir, `${sessionName}.jsonl`);
|
|
471
|
+
const snapshot = {
|
|
472
|
+
iteration: state.totalIterations,
|
|
473
|
+
contextPct: state.workerContextPct,
|
|
474
|
+
tokens: state.workerInputTokens + state.workerOutputTokens + state.workerCacheReadTokens + state.workerCacheWriteTokens,
|
|
475
|
+
contextWindow,
|
|
476
|
+
cost: state.workerCostUsd,
|
|
477
|
+
toolCalls: state.workerToolCount,
|
|
478
|
+
exitReason: state.workerExitDiagnostic?.classification || null,
|
|
479
|
+
timestamp: Date.now(),
|
|
480
|
+
};
|
|
481
|
+
appendFileSync(filePath, JSON.stringify(snapshot) + "\n");
|
|
482
|
+
} catch {
|
|
483
|
+
// Best effort — don't crash the runner
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
459
487
|
/**
|
|
460
488
|
* Append a JSON event to the conversation JSONL log file.
|
|
461
489
|
* Used in orchestrated mode to capture the full worker conversation for the web dashboard.
|
|
@@ -1371,7 +1399,9 @@ interface SidecarTelemetryDelta {
|
|
|
1371
1399
|
/** Whether any sidecar events were parsed in this tick (used for callback gating) */
|
|
1372
1400
|
hadEvents: boolean;
|
|
1373
1401
|
/** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
|
|
1374
|
-
contextUsage: {
|
|
1402
|
+
contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
|
|
1403
|
+
/** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
|
|
1404
|
+
sawStatsResponseWithoutContextUsage: boolean;
|
|
1375
1405
|
}
|
|
1376
1406
|
|
|
1377
1407
|
/**
|
|
@@ -1390,7 +1420,7 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
|
|
|
1390
1420
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
1391
1421
|
cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
|
|
1392
1422
|
retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
|
|
1393
|
-
hadEvents: false, contextUsage: null,
|
|
1423
|
+
hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
|
|
1394
1424
|
};
|
|
1395
1425
|
|
|
1396
1426
|
// Gracefully handle missing file (wrapper hasn't written yet)
|
|
@@ -1506,13 +1536,18 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
|
|
|
1506
1536
|
// get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
|
|
1507
1537
|
if (event.success === true && event.data?.contextUsage) {
|
|
1508
1538
|
const cu = event.data.contextUsage;
|
|
1509
|
-
|
|
1539
|
+
// pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
|
|
1540
|
+
const pctValue = cu.percent ?? cu.percentUsed;
|
|
1541
|
+
if (typeof pctValue === "number") {
|
|
1510
1542
|
delta.contextUsage = {
|
|
1511
|
-
|
|
1543
|
+
percent: pctValue,
|
|
1512
1544
|
totalTokens: cu.totalTokens || 0,
|
|
1513
1545
|
maxTokens: cu.maxTokens || 0,
|
|
1514
1546
|
};
|
|
1515
1547
|
}
|
|
1548
|
+
} else if (event.success === true && event.data && !event.data.contextUsage) {
|
|
1549
|
+
// Successful get_session_stats response but no contextUsage — older pi
|
|
1550
|
+
delta.sawStatsResponseWithoutContextUsage = true;
|
|
1516
1551
|
}
|
|
1517
1552
|
break;
|
|
1518
1553
|
}
|
|
@@ -1656,6 +1691,25 @@ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
|
|
|
1656
1691
|
|
|
1657
1692
|
// ── TMUX Agent Spawner ───────────────────────────────────────────────
|
|
1658
1693
|
|
|
1694
|
+
/**
|
|
1695
|
+
* Synchronous sleep helper for tmux spawn stabilization checks.
|
|
1696
|
+
*
|
|
1697
|
+
* Uses Atomics.wait for cross-platform blocking delays without relying on
|
|
1698
|
+
* shell `sleep` availability (important on Windows environments).
|
|
1699
|
+
*/
|
|
1700
|
+
function sleepSyncMs(ms: number): void {
|
|
1701
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
1702
|
+
try {
|
|
1703
|
+
const arr = new Int32Array(new SharedArrayBuffer(4));
|
|
1704
|
+
Atomics.wait(arr, 0, 0, Math.floor(ms));
|
|
1705
|
+
} catch {
|
|
1706
|
+
const start = Date.now();
|
|
1707
|
+
while (Date.now() - start < ms) {
|
|
1708
|
+
// Busy-wait fallback (rare path)
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1659
1713
|
/**
|
|
1660
1714
|
* Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
|
|
1661
1715
|
* Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
|
|
@@ -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 % —
|
|
2585
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2456
2586
|
if (delta.contextUsage) {
|
|
2457
|
-
state.reviewerContextPct = delta.contextUsage.
|
|
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 % —
|
|
2790
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2663
2791
|
if (delta.contextUsage) {
|
|
2664
|
-
state.reviewerContextPct = delta.contextUsage.
|
|
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
|
-
|
|
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 % —
|
|
3290
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
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}`;
|