taskplane 0.22.12 → 0.22.14
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 +43 -3
- 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 +474 -89
- package/extensions/taskplane/abort.ts +2 -5
- package/extensions/taskplane/cleanup.ts +30 -9
- package/extensions/taskplane/engine.ts +11 -12
- package/extensions/taskplane/execution.ts +20 -3
- package/extensions/taskplane/extension.ts +499 -8
- package/extensions/taskplane/merge.ts +90 -8
- package/extensions/taskplane/supervisor-primer.md +6 -0
- package/package.json +1 -1
- package/templates/agents/task-worker.md +15 -3
|
@@ -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.
|
|
@@ -944,6 +972,16 @@ function logExecution(statusPath: string, action: string, outcome: string): void
|
|
|
944
972
|
appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
|
|
945
973
|
}
|
|
946
974
|
|
|
975
|
+
/**
|
|
976
|
+
* TP-090: Sanitize steering message content for safe injection into a markdown table row.
|
|
977
|
+
* Collapses newlines to " / ", escapes pipe characters, and truncates to 200 chars.
|
|
978
|
+
*/
|
|
979
|
+
function sanitizeSteeringContent(content: string): string {
|
|
980
|
+
let s = content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|");
|
|
981
|
+
if (s.length > 200) s = s.slice(0, 197) + "...";
|
|
982
|
+
return s;
|
|
983
|
+
}
|
|
984
|
+
|
|
947
985
|
function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
|
|
948
986
|
appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
|
|
949
987
|
}
|
|
@@ -1371,7 +1409,9 @@ interface SidecarTelemetryDelta {
|
|
|
1371
1409
|
/** Whether any sidecar events were parsed in this tick (used for callback gating) */
|
|
1372
1410
|
hadEvents: boolean;
|
|
1373
1411
|
/** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
|
|
1374
|
-
contextUsage: {
|
|
1412
|
+
contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
|
|
1413
|
+
/** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
|
|
1414
|
+
sawStatsResponseWithoutContextUsage: boolean;
|
|
1375
1415
|
}
|
|
1376
1416
|
|
|
1377
1417
|
/**
|
|
@@ -1390,7 +1430,7 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
|
|
|
1390
1430
|
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
1391
1431
|
cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
|
|
1392
1432
|
retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
|
|
1393
|
-
hadEvents: false, contextUsage: null,
|
|
1433
|
+
hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
|
|
1394
1434
|
};
|
|
1395
1435
|
|
|
1396
1436
|
// Gracefully handle missing file (wrapper hasn't written yet)
|
|
@@ -1506,13 +1546,18 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
|
|
|
1506
1546
|
// get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
|
|
1507
1547
|
if (event.success === true && event.data?.contextUsage) {
|
|
1508
1548
|
const cu = event.data.contextUsage;
|
|
1509
|
-
|
|
1549
|
+
// pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
|
|
1550
|
+
const pctValue = cu.percent ?? cu.percentUsed;
|
|
1551
|
+
if (typeof pctValue === "number") {
|
|
1510
1552
|
delta.contextUsage = {
|
|
1511
|
-
|
|
1553
|
+
percent: pctValue,
|
|
1512
1554
|
totalTokens: cu.totalTokens || 0,
|
|
1513
1555
|
maxTokens: cu.maxTokens || 0,
|
|
1514
1556
|
};
|
|
1515
1557
|
}
|
|
1558
|
+
} else if (event.success === true && event.data && !event.data.contextUsage) {
|
|
1559
|
+
// Successful get_session_stats response but no contextUsage — older pi
|
|
1560
|
+
delta.sawStatsResponseWithoutContextUsage = true;
|
|
1516
1561
|
}
|
|
1517
1562
|
break;
|
|
1518
1563
|
}
|
|
@@ -1532,6 +1577,68 @@ export const _resolveContextWindow = resolveContextWindow;
|
|
|
1532
1577
|
export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
|
|
1533
1578
|
export type { SidecarTailState, SidecarTelemetryDelta };
|
|
1534
1579
|
|
|
1580
|
+
// ── Stable Sidecar Path Generation (TP-097) ─────────────────────────
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Generate a deterministic telemetry basename for sidecar/exit-summary files.
|
|
1584
|
+
*
|
|
1585
|
+
* The basename is stable per session (not per spawn attempt). When called
|
|
1586
|
+
* once before the iteration loop and reused, it ensures that:
|
|
1587
|
+
* - Crash recovery writes to the same sidecar file (tailing resumes)
|
|
1588
|
+
* - Exit summaries overwrite the same file (latest wins)
|
|
1589
|
+
*
|
|
1590
|
+
* Naming contract:
|
|
1591
|
+
* {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
|
|
1592
|
+
*
|
|
1593
|
+
* @param sessionName — TMUX session name (e.g., "orch-lane-1-worker")
|
|
1594
|
+
* @param taskId — Optional task ID for enrichment (e.g., "TP-097")
|
|
1595
|
+
* @returns Object with sidecarPath and exitSummaryPath
|
|
1596
|
+
*/
|
|
1597
|
+
function generateStableSidecarPaths(sessionName: string, taskId?: string): {
|
|
1598
|
+
sidecarPath: string;
|
|
1599
|
+
exitSummaryPath: string;
|
|
1600
|
+
} {
|
|
1601
|
+
// Resolve opId: same priority chain as naming.ts resolveOperatorId()
|
|
1602
|
+
let opId = "op";
|
|
1603
|
+
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1604
|
+
if (envOpId?.trim()) {
|
|
1605
|
+
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1606
|
+
} else {
|
|
1607
|
+
try {
|
|
1608
|
+
const username = userInfo().username;
|
|
1609
|
+
if (username?.trim()) {
|
|
1610
|
+
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1611
|
+
}
|
|
1612
|
+
} catch { /* userInfo() can throw on some platforms */ }
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Use ORCH_BATCH_ID if available (orchestrated mode), otherwise fallback to timestamp
|
|
1616
|
+
const batchId = process.env.ORCH_BATCH_ID || String(Date.now());
|
|
1617
|
+
const repoId = process.env.TASKPLANE_REPO_ID || "default";
|
|
1618
|
+
|
|
1619
|
+
// Extract role (worker/reviewer) from sessionName, and optional lane component
|
|
1620
|
+
const role = sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1621
|
+
const laneMatch = sessionName.match(/lane-(\d+)/);
|
|
1622
|
+
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1623
|
+
|
|
1624
|
+
// Include taskId when available — sanitize to filesystem-safe characters
|
|
1625
|
+
const taskIdSegment = taskId
|
|
1626
|
+
? `-${taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1627
|
+
: "";
|
|
1628
|
+
|
|
1629
|
+
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1630
|
+
const telemetryDir = join(getSidecarDir(), "telemetry");
|
|
1631
|
+
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1632
|
+
|
|
1633
|
+
return {
|
|
1634
|
+
sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
|
|
1635
|
+
exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** Expose for testing. */
|
|
1640
|
+
export const _generateStableSidecarPaths = generateStableSidecarPaths;
|
|
1641
|
+
|
|
1535
1642
|
// ── Exit Summary & Diagnostic ────────────────────────────────────────
|
|
1536
1643
|
|
|
1537
1644
|
/**
|
|
@@ -1656,6 +1763,25 @@ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
|
|
|
1656
1763
|
|
|
1657
1764
|
// ── TMUX Agent Spawner ───────────────────────────────────────────────
|
|
1658
1765
|
|
|
1766
|
+
/**
|
|
1767
|
+
* Synchronous sleep helper for tmux spawn stabilization checks.
|
|
1768
|
+
*
|
|
1769
|
+
* Uses Atomics.wait for cross-platform blocking delays without relying on
|
|
1770
|
+
* shell `sleep` availability (important on Windows environments).
|
|
1771
|
+
*/
|
|
1772
|
+
function sleepSyncMs(ms: number): void {
|
|
1773
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
1774
|
+
try {
|
|
1775
|
+
const arr = new Int32Array(new SharedArrayBuffer(4));
|
|
1776
|
+
Atomics.wait(arr, 0, 0, Math.floor(ms));
|
|
1777
|
+
} catch {
|
|
1778
|
+
const start = Date.now();
|
|
1779
|
+
while (Date.now() - start < ms) {
|
|
1780
|
+
// Busy-wait fallback (rare path)
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1659
1785
|
/**
|
|
1660
1786
|
* Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
|
|
1661
1787
|
* Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
|
|
@@ -1721,6 +1847,19 @@ function spawnAgentTmux(opts: {
|
|
|
1721
1847
|
* Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
|
|
1722
1848
|
* with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
|
|
1723
1849
|
onTelemetry?: (delta: SidecarTelemetryDelta) => void;
|
|
1850
|
+
/** TP-090: Path to .steering-pending JSONL flag file for STATUS.md annotation.
|
|
1851
|
+
* Only set for worker sessions (not reviewer/merger). */
|
|
1852
|
+
steeringPendingPath?: string;
|
|
1853
|
+
/** TP-097: Caller-provided sidecar path for stable identity across iterations.
|
|
1854
|
+
* When provided, spawnAgentTmux() skips internal path generation and uses this path.
|
|
1855
|
+
* The caller (runWorker) generates this ONCE before the iteration loop. */
|
|
1856
|
+
sidecarPath?: string;
|
|
1857
|
+
/** TP-097: Caller-provided exit summary path (paired with sidecarPath). */
|
|
1858
|
+
exitSummaryPath?: string;
|
|
1859
|
+
/** TP-097: Caller-provided tail state for resuming sidecar tailing across iterations.
|
|
1860
|
+
* When provided, the poll loop reuses this state instead of creating a fresh one.
|
|
1861
|
+
* This ensures tailing resumes from the last byte position after crash recovery. */
|
|
1862
|
+
tailState?: SidecarTailState;
|
|
1724
1863
|
}): {
|
|
1725
1864
|
promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
1726
1865
|
kill: () => void;
|
|
@@ -1738,59 +1877,55 @@ function spawnAgentTmux(opts: {
|
|
|
1738
1877
|
);
|
|
1739
1878
|
}
|
|
1740
1879
|
|
|
1741
|
-
// ──
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
// role → derived from sessionName suffix (worker/reviewer)
|
|
1752
|
-
//
|
|
1753
|
-
// getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
|
|
1754
|
-
const telemetryTs = Date.now();
|
|
1755
|
-
|
|
1756
|
-
// Resolve opId: same priority chain as naming.ts resolveOperatorId()
|
|
1757
|
-
let opId = "op";
|
|
1758
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1759
|
-
if (envOpId?.trim()) {
|
|
1760
|
-
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1880
|
+
// ── Resolve telemetry file paths ───────────────────────────────
|
|
1881
|
+
// TP-097: When the caller provides sidecarPath + exitSummaryPath, reuse them
|
|
1882
|
+
// for stable identity across crash recovery iterations. Otherwise, generate
|
|
1883
|
+
// paths internally (backward compatible for reviewer/quality-gate/standalone).
|
|
1884
|
+
let sidecarPath: string;
|
|
1885
|
+
let exitSummaryPath: string;
|
|
1886
|
+
if (opts.sidecarPath && opts.exitSummaryPath) {
|
|
1887
|
+
// TP-097: Caller provided stable paths (worker iteration flow)
|
|
1888
|
+
sidecarPath = opts.sidecarPath;
|
|
1889
|
+
exitSummaryPath = opts.exitSummaryPath;
|
|
1761
1890
|
} else {
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1891
|
+
// Internal generation: use unique per-spawn paths (Date.now-based batchId)
|
|
1892
|
+
// to prevent reviewer/QG sessions from replaying old telemetry on respawn.
|
|
1893
|
+
// Only the worker iteration flow uses ORCH_BATCH_ID for stable identity.
|
|
1894
|
+
const telemetryTs = Date.now();
|
|
1895
|
+
let opId = "op";
|
|
1896
|
+
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1897
|
+
if (envOpId?.trim()) {
|
|
1898
|
+
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1899
|
+
} else {
|
|
1900
|
+
try {
|
|
1901
|
+
const username = userInfo().username;
|
|
1902
|
+
if (username?.trim()) {
|
|
1903
|
+
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1904
|
+
}
|
|
1905
|
+
} catch { /* userInfo() can throw on some platforms */ }
|
|
1906
|
+
}
|
|
1907
|
+
const batchId = String(telemetryTs);
|
|
1908
|
+
const repoId = "default";
|
|
1909
|
+
const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1910
|
+
const laneMatch = opts.sessionName.match(/lane-(\d+)/);
|
|
1911
|
+
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1912
|
+
const taskIdSegment = opts.taskId
|
|
1913
|
+
? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1914
|
+
: "";
|
|
1915
|
+
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1916
|
+
const internalTelemetryDir = join(getSidecarDir(), "telemetry");
|
|
1917
|
+
if (!existsSync(internalTelemetryDir)) mkdirSync(internalTelemetryDir, { recursive: true });
|
|
1918
|
+
sidecarPath = join(internalTelemetryDir, `${telemetryBasename}.jsonl`);
|
|
1919
|
+
exitSummaryPath = join(internalTelemetryDir, `${telemetryBasename}-exit.json`);
|
|
1768
1920
|
}
|
|
1769
|
-
|
|
1770
|
-
const
|
|
1771
|
-
const repoId = "default";
|
|
1772
|
-
|
|
1773
|
-
// Extract role (worker/reviewer) from sessionName, and optional lane component
|
|
1774
|
-
// sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
|
|
1775
|
-
const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1776
|
-
const laneMatch = opts.sessionName.match(/lane-(\d+)/);
|
|
1777
|
-
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1778
|
-
|
|
1779
|
-
// Include taskId when available — sanitize to filesystem-safe characters.
|
|
1780
|
-
// Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
|
|
1781
|
-
const taskIdSegment = opts.taskId
|
|
1782
|
-
? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1783
|
-
: "";
|
|
1784
|
-
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1785
|
-
const telemetryDir = join(getSidecarDir(), "telemetry");
|
|
1921
|
+
// Ensure telemetry directory exists
|
|
1922
|
+
const telemetryDir = dirname(sidecarPath);
|
|
1786
1923
|
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1787
|
-
const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
|
|
1788
|
-
const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
|
|
1789
1924
|
|
|
1790
1925
|
// ── Write prompts to temp files ─────────────────────────────────
|
|
1791
1926
|
// Same pattern as spawnAgent() — avoids shell escaping issues with
|
|
1792
1927
|
// backticks, quotes, and special characters in markdown content.
|
|
1793
|
-
const id = `${
|
|
1928
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1794
1929
|
const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
|
|
1795
1930
|
const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
|
|
1796
1931
|
writeFileSync(sysTmpFile, opts.systemPrompt);
|
|
@@ -1844,6 +1979,10 @@ function spawnAgentTmux(opts: {
|
|
|
1844
1979
|
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
1845
1980
|
wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
|
|
1846
1981
|
}
|
|
1982
|
+
// TP-090: Pass steering-pending path to rpc-wrapper (worker-only).
|
|
1983
|
+
if (opts.steeringPendingPath) {
|
|
1984
|
+
wrapperArgs.push("--steering-pending-path", quoteArg(opts.steeringPendingPath));
|
|
1985
|
+
}
|
|
1847
1986
|
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1848
1987
|
// Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
|
|
1849
1988
|
wrapperArgs.push("--");
|
|
@@ -1890,13 +2029,93 @@ function spawnAgentTmux(opts: {
|
|
|
1890
2029
|
);
|
|
1891
2030
|
}
|
|
1892
2031
|
|
|
2032
|
+
// ── TP-095: Post-spawn verification with retry (#335) ──────────
|
|
2033
|
+
// On Windows/MSYS2, rapid sequential tmux session creation is unreliable.
|
|
2034
|
+
// Pi process can exit with code 1 in 0 seconds on the first 3-5 attempts.
|
|
2035
|
+
// Verify the session is alive after a brief delay, and retry if it died.
|
|
2036
|
+
// TP-097: Increased from 300→500ms and 2→5 retries for reliability (#335)
|
|
2037
|
+
const SPAWN_VERIFY_DELAY_MS = 500;
|
|
2038
|
+
const SPAWN_VERIFY_POLL_ATTEMPTS = 3;
|
|
2039
|
+
const SPAWN_VERIFY_POLL_INTERVAL_MS = 200;
|
|
2040
|
+
const SPAWN_MAX_RETRIES = 5;
|
|
2041
|
+
|
|
2042
|
+
const verifySessionAlive = (): boolean => {
|
|
2043
|
+
for (let poll = 0; poll < SPAWN_VERIFY_POLL_ATTEMPTS; poll++) {
|
|
2044
|
+
const check = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
|
|
2045
|
+
if (check.status === 0) return true;
|
|
2046
|
+
if (poll < SPAWN_VERIFY_POLL_ATTEMPTS - 1) {
|
|
2047
|
+
sleepSyncMs(SPAWN_VERIFY_POLL_INTERVAL_MS);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return false;
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
// Wait briefly for session to stabilize, then verify
|
|
2054
|
+
sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
|
|
2055
|
+
|
|
2056
|
+
// Derive the stderr log path for diagnostic messages (mirrors execution.ts convention)
|
|
2057
|
+
const stderrLogHint = `${sidecarPath.replace(/\.jsonl$/, "-stderr.log")}`;
|
|
2058
|
+
|
|
2059
|
+
let spawnRetries = 0;
|
|
2060
|
+
while (!verifySessionAlive() && spawnRetries < SPAWN_MAX_RETRIES) {
|
|
2061
|
+
spawnRetries++;
|
|
2062
|
+
// TP-097: Log stderr from the failed session for diagnostics
|
|
2063
|
+
let failedStderr = "";
|
|
2064
|
+
try {
|
|
2065
|
+
if (existsSync(stderrLogHint)) {
|
|
2066
|
+
const raw = readFileSync(stderrLogHint, "utf-8");
|
|
2067
|
+
// Take last 500 chars to capture the most recent error
|
|
2068
|
+
failedStderr = raw.length > 500 ? "..." + raw.slice(-500) : raw;
|
|
2069
|
+
}
|
|
2070
|
+
} catch { /* best effort */ }
|
|
2071
|
+
console.error(`[task-runner] tmux: session '${opts.sessionName}' died on startup — retrying (${spawnRetries}/${SPAWN_MAX_RETRIES}).${failedStderr ? ` Last stderr: ${failedStderr.trim().slice(0, 200)}` : ""} Stderr log: ${stderrLogHint}`);
|
|
2072
|
+
|
|
2073
|
+
// Brief delay before retry (increases with each attempt: 500ms, 1000ms, 1500ms, ...)
|
|
2074
|
+
const retryDelay = spawnRetries * 500;
|
|
2075
|
+
sleepSyncMs(retryDelay);
|
|
2076
|
+
|
|
2077
|
+
// Kill any remnant and re-create
|
|
2078
|
+
spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
|
|
2079
|
+
|
|
2080
|
+
const retryResult = spawnSync("tmux", [
|
|
2081
|
+
"new-session", "-d",
|
|
2082
|
+
"-s", opts.sessionName,
|
|
2083
|
+
wrappedCommand,
|
|
2084
|
+
]);
|
|
2085
|
+
|
|
2086
|
+
if (retryResult.status !== 0) {
|
|
2087
|
+
const retryStderr = retryResult.stderr?.toString().trim() || "unknown error";
|
|
2088
|
+
console.error(`[task-runner] tmux: retry ${spawnRetries} session creation failed: ${retryStderr}`);
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// Wait for the retried session to stabilize
|
|
2093
|
+
sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (spawnRetries > 0) {
|
|
2097
|
+
const finalAlive = verifySessionAlive();
|
|
2098
|
+
if (!finalAlive) {
|
|
2099
|
+
cleanupTmp();
|
|
2100
|
+
console.error(`[task-runner] tmux: session '${opts.sessionName}' failed after ${SPAWN_MAX_RETRIES} retries. Stderr log: ${stderrLogHint}`);
|
|
2101
|
+
throw new Error(
|
|
2102
|
+
`TMUX session '${opts.sessionName}' died on startup after ${SPAWN_MAX_RETRIES} retries. ` +
|
|
2103
|
+
`Stderr log: ${stderrLogHint}`
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
console.error(`[task-runner] tmux: session '${opts.sessionName}' alive after ${spawnRetries} retry(ies)`);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1893
2109
|
console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
|
|
1894
2110
|
|
|
1895
2111
|
|
|
1896
2112
|
// ── Poll until session ends ─────────────────────────────────────
|
|
1897
2113
|
let killed = false;
|
|
1898
2114
|
const startTime = Date.now();
|
|
1899
|
-
|
|
2115
|
+
// TP-097: Reuse caller-provided tailState for cross-iteration tailing resume.
|
|
2116
|
+
// When the caller (runWorker) passes tailState, byte offset is preserved
|
|
2117
|
+
// across iterations so tailing resumes from the last position after crash recovery.
|
|
2118
|
+
const tailState = opts.tailState ?? createSidecarTailState();
|
|
1900
2119
|
|
|
1901
2120
|
const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
|
|
1902
2121
|
try {
|
|
@@ -1938,10 +2157,12 @@ function spawnAgentTmux(opts: {
|
|
|
1938
2157
|
};
|
|
1939
2158
|
}
|
|
1940
2159
|
|
|
1941
|
-
// Normal completion — clean up temp files
|
|
2160
|
+
// Normal completion — clean up temp files and orphan processes
|
|
1942
2161
|
const elapsed = Date.now() - startTime;
|
|
1943
2162
|
console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
|
|
1944
2163
|
cleanupTmp();
|
|
2164
|
+
// TP-097: Clean up orphan rpc-wrapper/pi processes after session ends
|
|
2165
|
+
cleanupOrphanProcesses(sidecarPath);
|
|
1945
2166
|
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
|
|
1946
2167
|
return {
|
|
1947
2168
|
output: "", // No captured output in TMUX mode
|
|
@@ -1960,6 +2181,8 @@ function spawnAgentTmux(opts: {
|
|
|
1960
2181
|
// Session may have already exited — not an error
|
|
1961
2182
|
console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
|
|
1962
2183
|
}
|
|
2184
|
+
// TP-097: Clean up orphan rpc-wrapper/pi processes on explicit kill
|
|
2185
|
+
cleanupOrphanProcesses(sidecarPath);
|
|
1963
2186
|
cleanupTmp();
|
|
1964
2187
|
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
|
|
1965
2188
|
};
|
|
@@ -1967,6 +2190,68 @@ function spawnAgentTmux(opts: {
|
|
|
1967
2190
|
return { promise, kill, sidecarPath, exitSummaryPath };
|
|
1968
2191
|
}
|
|
1969
2192
|
|
|
2193
|
+
// ── Orphan Process Cleanup (TP-097) ───────────────────────────────────
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* Read the PID file written by rpc-wrapper.mjs and kill orphan processes.
|
|
2197
|
+
*
|
|
2198
|
+
* The PID file is at `{sidecarPath}.pid` and contains JSON with wrapperPid
|
|
2199
|
+
* and childPid fields. After a tmux session ends, the rpc-wrapper child
|
|
2200
|
+
* process may still be alive (e.g., if the tmux session was killed externally
|
|
2201
|
+
* or the wrapper didn't get a clean shutdown signal).
|
|
2202
|
+
*
|
|
2203
|
+
* Best-effort: failures are logged but never throw.
|
|
2204
|
+
*
|
|
2205
|
+
* @param sidecarPath - Path to the sidecar JSONL file (PID file is at sidecarPath + ".pid")
|
|
2206
|
+
*/
|
|
2207
|
+
function cleanupOrphanProcesses(sidecarPath: string): void {
|
|
2208
|
+
const pidFilePath = sidecarPath + ".pid";
|
|
2209
|
+
try {
|
|
2210
|
+
if (!existsSync(pidFilePath)) return;
|
|
2211
|
+
|
|
2212
|
+
const raw = readFileSync(pidFilePath, "utf-8").trim();
|
|
2213
|
+
if (!raw) return;
|
|
2214
|
+
|
|
2215
|
+
const pidData = JSON.parse(raw);
|
|
2216
|
+
const pidsToCheck = new Set<number>();
|
|
2217
|
+
if (typeof pidData.childPid === "number" && pidData.childPid > 0) {
|
|
2218
|
+
pidsToCheck.add(pidData.childPid);
|
|
2219
|
+
}
|
|
2220
|
+
if (typeof pidData.wrapperPid === "number" && pidData.wrapperPid > 0) {
|
|
2221
|
+
pidsToCheck.add(pidData.wrapperPid);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Safety: never kill ourselves or PID 1 (init)
|
|
2225
|
+
const selfPid = process.pid;
|
|
2226
|
+
pidsToCheck.delete(selfPid);
|
|
2227
|
+
pidsToCheck.delete(1);
|
|
2228
|
+
|
|
2229
|
+
for (const pid of pidsToCheck) {
|
|
2230
|
+
try {
|
|
2231
|
+
// Check if process is still alive (signal 0 = no-op, just check existence)
|
|
2232
|
+
process.kill(pid, 0);
|
|
2233
|
+
// Process is alive — send SIGTERM
|
|
2234
|
+
console.error(`[task-runner] TP-097: killing orphan process PID ${pid}`);
|
|
2235
|
+
try {
|
|
2236
|
+
process.kill(pid, "SIGTERM");
|
|
2237
|
+
} catch (killErr: any) {
|
|
2238
|
+
console.error(`[task-runner] TP-097: failed to kill PID ${pid}: ${killErr?.message}`);
|
|
2239
|
+
}
|
|
2240
|
+
} catch {
|
|
2241
|
+
// Process already dead — expected path
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Clean up the PID file
|
|
2246
|
+
try { unlinkSync(pidFilePath); } catch {}
|
|
2247
|
+
} catch (err: any) {
|
|
2248
|
+
console.error(`[task-runner] TP-097: orphan cleanup error: ${err?.message}`);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/** Expose for testing. */
|
|
2253
|
+
export const _cleanupOrphanProcesses = cleanupOrphanProcesses;
|
|
2254
|
+
|
|
1970
2255
|
// ── Display Helpers ──────────────────────────────────────────────────
|
|
1971
2256
|
|
|
1972
2257
|
function displayName(name: string): string {
|
|
@@ -2461,11 +2746,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2461
2746
|
state.reviewerLastTool = delta.lastTool;
|
|
2462
2747
|
}
|
|
2463
2748
|
|
|
2464
|
-
// Context % —
|
|
2749
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2465
2750
|
if (delta.contextUsage) {
|
|
2466
|
-
state.reviewerContextPct = delta.contextUsage.
|
|
2467
|
-
} else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
|
|
2468
|
-
state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
|
|
2751
|
+
state.reviewerContextPct = delta.contextUsage.percent;
|
|
2469
2752
|
}
|
|
2470
2753
|
|
|
2471
2754
|
writeLaneState(state);
|
|
@@ -2668,11 +2951,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2668
2951
|
state.reviewerCostUsd += delta.cost;
|
|
2669
2952
|
state.reviewerToolCount += delta.toolCalls;
|
|
2670
2953
|
if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
|
|
2671
|
-
// Context % —
|
|
2954
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2672
2955
|
if (delta.contextUsage) {
|
|
2673
|
-
state.reviewerContextPct = delta.contextUsage.
|
|
2674
|
-
} else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
|
|
2675
|
-
state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
|
|
2956
|
+
state.reviewerContextPct = delta.contextUsage.percent;
|
|
2676
2957
|
}
|
|
2677
2958
|
writeLaneState(state);
|
|
2678
2959
|
updateWidgets();
|
|
@@ -2749,7 +3030,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2749
3030
|
|
|
2750
3031
|
updateStatusField(statusPath, "Status", "🟡 In Progress");
|
|
2751
3032
|
updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
|
|
2752
|
-
|
|
3033
|
+
|
|
3034
|
+
// TP-098: Distinguish first start from restart/resume to prevent
|
|
3035
|
+
// duplicate "Task started" entries in the execution log (#348).
|
|
3036
|
+
if (state.totalIterations === 0) {
|
|
3037
|
+
logExecution(statusPath, "Task started", "Extension-driven execution");
|
|
3038
|
+
} else {
|
|
3039
|
+
logExecution(statusPath, "Task resumed", `Resuming from iteration ${state.totalIterations}`);
|
|
3040
|
+
}
|
|
2753
3041
|
|
|
2754
3042
|
// ── Per-task worker loop ─────────────────────────────────────
|
|
2755
3043
|
// Spawn one worker per iteration; each worker handles ALL remaining
|
|
@@ -2766,10 +3054,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2766
3054
|
const ss = currentStatus.steps.find(s => s.number === step.number);
|
|
2767
3055
|
if (ss?.status === "complete") continue;
|
|
2768
3056
|
|
|
2769
|
-
|
|
3057
|
+
if (!foundFirstIncomplete) {
|
|
2770
3058
|
// Mark the first incomplete step as in-progress
|
|
2771
|
-
|
|
2772
|
-
|
|
3059
|
+
// TP-098: Only log "Step N started" if the step was not already
|
|
3060
|
+
// in-progress, preventing duplicate entries on restart (#348).
|
|
3061
|
+
if (ss?.status !== "in-progress") {
|
|
3062
|
+
updateStepStatus(statusPath, step.number, "in-progress");
|
|
3063
|
+
logExecution(statusPath, `Step ${step.number} started`, step.name);
|
|
3064
|
+
}
|
|
2773
3065
|
foundFirstIncomplete = true;
|
|
2774
3066
|
} else {
|
|
2775
3067
|
// Ensure future steps show as not-started, not in-progress
|
|
@@ -2788,11 +3080,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
2788
3080
|
return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
|
|
2789
3081
|
}
|
|
2790
3082
|
|
|
3083
|
+
// ── TP-097: Generate stable sidecar paths ONCE before the iteration loop ──
|
|
3084
|
+
// These paths are reused across all worker iterations so that:
|
|
3085
|
+
// 1. After crash recovery, the new worker writes to the SAME sidecar file
|
|
3086
|
+
// 2. tailState preserves byte offset, so tailing resumes from last position
|
|
3087
|
+
// 3. Exit summary overwrites the same file (latest iteration wins)
|
|
3088
|
+
const spawnMode = getSpawnMode(config);
|
|
3089
|
+
let workerStableSidecar: { sidecarPath: string; exitSummaryPath: string } | null = null;
|
|
3090
|
+
let workerTailState: SidecarTailState | null = null;
|
|
3091
|
+
if (spawnMode === "tmux") {
|
|
3092
|
+
const sessionName = `${getTmuxPrefix()}-worker`;
|
|
3093
|
+
workerStableSidecar = generateStableSidecarPaths(sessionName, task.taskId);
|
|
3094
|
+
workerTailState = createSidecarTailState();
|
|
3095
|
+
console.error(`[task-runner] TP-097: stable sidecar path: ${workerStableSidecar.sidecarPath}`);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
2791
3098
|
let noProgressCount = 0;
|
|
2792
3099
|
for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
|
|
2793
3100
|
if (state.phase === "paused") {
|
|
2794
|
-
logExecution(statusPath, "Paused", `User paused at iteration ${
|
|
2795
|
-
ctx.ui.notify(`Task paused at iteration ${
|
|
3101
|
+
logExecution(statusPath, "Paused", `User paused at iteration ${state.totalIterations}`);
|
|
3102
|
+
ctx.ui.notify(`Task paused at iteration ${state.totalIterations}`, "info");
|
|
2796
3103
|
await shutdownPersistentReviewer("task paused");
|
|
2797
3104
|
return;
|
|
2798
3105
|
}
|
|
@@ -2823,7 +3130,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
2823
3130
|
if (isStepComplete(ss)) completedBefore.add(ss.number);
|
|
2824
3131
|
}
|
|
2825
3132
|
|
|
2826
|
-
|
|
3133
|
+
// ── TP-095: Reset stale lane-state fields before new worker spawn (#333) ──
|
|
3134
|
+
// When a worker crashes and restarts, the lane-state JSON retains stale
|
|
3135
|
+
// values (workerStatus: "done", phase: "error", workerExitDiagnostic from
|
|
3136
|
+
// the crash). Reset STATUS fields BEFORE the new worker spawns so the
|
|
3137
|
+
// dashboard immediately reflects the new running state.
|
|
3138
|
+
// IMPORTANT: Do NOT reset telemetry counters (tokens, cost) here — they
|
|
3139
|
+
// accumulate across worker iterations via += in onTelemetry (#334).
|
|
3140
|
+
if (state.totalIterations > 1) {
|
|
3141
|
+
state.phase = "running";
|
|
3142
|
+
state.workerStatus = "idle"; // Will be set to "running" by runWorker()
|
|
3143
|
+
state.workerExitDiagnostic = null;
|
|
3144
|
+
state.workerElapsed = 0;
|
|
3145
|
+
state.workerContextPct = 0;
|
|
3146
|
+
state.workerLastTool = "";
|
|
3147
|
+
state.workerRetryActive = false;
|
|
3148
|
+
state.workerRetryCount = 0;
|
|
3149
|
+
state.workerLastRetryError = "";
|
|
3150
|
+
// Note: workerToolCount, workerInputTokens, workerOutputTokens,
|
|
3151
|
+
// workerCacheReadTokens, workerCacheWriteTokens, workerCostUsd
|
|
3152
|
+
// are intentionally NOT reset — they persist across iterations.
|
|
3153
|
+
writeLaneState(state);
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
await runWorker(remainingSteps, ctx, workerStableSidecar, workerTailState);
|
|
3157
|
+
|
|
3158
|
+
// Write context % snapshot at iteration boundary (TP-094)
|
|
3159
|
+
const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
|
|
3160
|
+
writeContextSnapshot(state, snapshotContextWindow);
|
|
3161
|
+
|
|
3162
|
+
// ── TP-090: Annotate STATUS.md with delivered steering messages ──
|
|
3163
|
+
// Check for .steering-pending JSONL flag written by rpc-wrapper.
|
|
3164
|
+
// Must happen BEFORE the error-return so messages are not dropped.
|
|
3165
|
+
const steeringFlagPath = join(task.taskFolder, ".steering-pending");
|
|
3166
|
+
try {
|
|
3167
|
+
if (existsSync(steeringFlagPath)) {
|
|
3168
|
+
const raw = readFileSync(steeringFlagPath, "utf-8");
|
|
3169
|
+
const lines = raw.split("\n").filter(l => l.trim());
|
|
3170
|
+
for (const line of lines) {
|
|
3171
|
+
try {
|
|
3172
|
+
const entry = JSON.parse(line) as { ts: number; content: string; id: string };
|
|
3173
|
+
const sanitized = sanitizeSteeringContent(entry.content);
|
|
3174
|
+
// Use the delivered message timestamp, not current time
|
|
3175
|
+
const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
|
|
3176
|
+
appendTableRow(statusPath, "Execution Log", `| ${ts} | \u26a0\ufe0f Steering | ${sanitized} |`);
|
|
3177
|
+
console.error(`[task-runner] steering message annotated: ${entry.id}`);
|
|
3178
|
+
} catch {
|
|
3179
|
+
// Skip malformed JSONL lines
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
unlinkSync(steeringFlagPath);
|
|
3183
|
+
}
|
|
3184
|
+
} catch (err: any) {
|
|
3185
|
+
// Non-fatal: steering annotation is supplementary
|
|
3186
|
+
console.error(`[task-runner] steering-pending annotation error: ${err?.message || err}`);
|
|
3187
|
+
}
|
|
2827
3188
|
|
|
2828
3189
|
if (state.phase === "error") {
|
|
2829
3190
|
await shutdownPersistentReviewer("worker error");
|
|
@@ -2838,8 +3199,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2838
3199
|
const progressDelta = afterTotalChecked - prevTotalChecked;
|
|
2839
3200
|
if (progressDelta <= 0) {
|
|
2840
3201
|
noProgressCount++;
|
|
2841
|
-
|
|
2842
|
-
|
|
3202
|
+
// TP-098: Use state.totalIterations (global) instead of iter+1
|
|
3203
|
+
// (loop-local) to avoid label collision across restarts (#348).
|
|
3204
|
+
logExecution(statusPath, "No progress", `Iteration ${state.totalIterations}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
|
|
3205
|
+
ctx.ui.notify(`⚠️ No progress in iteration ${state.totalIterations} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
|
|
2843
3206
|
if (noProgressCount >= config.context.no_progress_limit) {
|
|
2844
3207
|
logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
|
|
2845
3208
|
ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
|
|
@@ -2886,11 +3249,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2886
3249
|
// Log iteration summary with progress delta and completed steps
|
|
2887
3250
|
const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
|
|
2888
3251
|
if (newlyCompleted.length > 0) {
|
|
2889
|
-
|
|
2890
|
-
|
|
3252
|
+
// TP-098: Use state.totalIterations (global) instead of iter+1
|
|
3253
|
+
// (loop-local) to avoid label collision across restarts (#348).
|
|
3254
|
+
logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
|
|
3255
|
+
ctx.ui.notify(`Iteration ${state.totalIterations}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
|
|
2891
3256
|
} else if (progressDelta > 0) {
|
|
2892
|
-
logExecution(statusPath, `Iteration ${
|
|
2893
|
-
ctx.ui.notify(`Iteration ${
|
|
3257
|
+
logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
|
|
3258
|
+
ctx.ui.notify(`Iteration ${state.totalIterations}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
|
|
2894
3259
|
}
|
|
2895
3260
|
|
|
2896
3261
|
// Reviews are now driven inline by the worker via the review_step
|
|
@@ -3113,25 +3478,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
3113
3478
|
|
|
3114
3479
|
// ── Worker ───────────────────────────────────────────────────────
|
|
3115
3480
|
|
|
3116
|
-
|
|
3481
|
+
/** Pre-generated sidecar paths for stable identity across iterations (TP-097). */
|
|
3482
|
+
type StableSidecarPaths = { sidecarPath: string; exitSummaryPath: string };
|
|
3483
|
+
|
|
3484
|
+
async function runWorker(
|
|
3485
|
+
remainingSteps: StepInfo[],
|
|
3486
|
+
ctx: ExtensionContext,
|
|
3487
|
+
stableSidecar?: StableSidecarPaths | null,
|
|
3488
|
+
sharedTailState?: SidecarTailState | null,
|
|
3489
|
+
): Promise<void> {
|
|
3117
3490
|
if (!state.task || !state.config) return;
|
|
3118
3491
|
|
|
3119
3492
|
const task = state.task;
|
|
3120
3493
|
const config = state.config;
|
|
3121
3494
|
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
3122
3495
|
const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
|
|
3123
|
-
const legacyWrapUpFile = join(task.taskFolder, ".wiggum-wrap-up");
|
|
3124
3496
|
|
|
3125
3497
|
const clearWrapUpSignals = () => {
|
|
3126
3498
|
if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
|
|
3127
|
-
if (existsSync(legacyWrapUpFile)) try { unlinkSync(legacyWrapUpFile); } catch {}
|
|
3128
3499
|
};
|
|
3129
3500
|
|
|
3130
3501
|
const writeWrapUpSignal = (reason: string) => {
|
|
3131
3502
|
const msg = `${reason} at ${new Date().toISOString()}`;
|
|
3132
3503
|
if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
|
|
3133
|
-
// Backward compatibility: write legacy signal too until all workers migrate.
|
|
3134
|
-
if (!existsSync(legacyWrapUpFile)) writeFileSync(legacyWrapUpFile, msg);
|
|
3135
3504
|
};
|
|
3136
3505
|
|
|
3137
3506
|
clearWrapUpSignals();
|
|
@@ -3219,8 +3588,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3219
3588
|
`5. Check for wrap-up signal files before starting the next step`,
|
|
3220
3589
|
`6. Proceed to the next incomplete step`,
|
|
3221
3590
|
``,
|
|
3222
|
-
`Wrap-up signal
|
|
3223
|
-
`Check for
|
|
3591
|
+
`Wrap-up signal file: ${wrapUpFile}`,
|
|
3592
|
+
`Check for this file after each checkpoint. If it exists, stop.`,
|
|
3224
3593
|
archiveSuppression,
|
|
3225
3594
|
contextDocsList,
|
|
3226
3595
|
].join("\n");
|
|
@@ -3229,7 +3598,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3229
3598
|
state.workerElapsed = 0;
|
|
3230
3599
|
state.workerContextPct = 0;
|
|
3231
3600
|
state.workerLastTool = "";
|
|
3232
|
-
|
|
3601
|
+
// TP-095: Don't reset workerToolCount — accumulate across iterations (#334).
|
|
3602
|
+
// Previous behavior zeroed the counter on each iteration, losing totals
|
|
3603
|
+
// when a worker crashed and restarted. Token/cost counters already
|
|
3604
|
+
// accumulate via += in onTelemetry and were never reset here.
|
|
3233
3605
|
state.workerRetryActive = false;
|
|
3234
3606
|
state.workerRetryCount = 0;
|
|
3235
3607
|
state.workerLastRetryError = "";
|
|
@@ -3257,6 +3629,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3257
3629
|
const warnPct = config.context.warn_percent;
|
|
3258
3630
|
const killPct = config.context.kill_percent;
|
|
3259
3631
|
console.error(`[task-runner] worker context window: ${contextWindow} (${contextWindowSource})`);
|
|
3632
|
+
// One-shot warning when pi doesn't provide authoritative contextUsage (TP-094)
|
|
3633
|
+
let warnedNoContextUsage = false;
|
|
3260
3634
|
|
|
3261
3635
|
if (spawnMode === "tmux") {
|
|
3262
3636
|
// ── TMUX mode ────────────────────────────────────────
|
|
@@ -3265,6 +3639,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3265
3639
|
// Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
|
|
3266
3640
|
const sessionName = `${getTmuxPrefix()}-worker`;
|
|
3267
3641
|
|
|
3642
|
+
// TP-090: Construct .steering-pending path for worker-only STATUS.md annotation.
|
|
3643
|
+
// Only set when running under orchestrator (mailbox is orch-only).
|
|
3644
|
+
const steeringPendingPath = isOrchestratedMode()
|
|
3645
|
+
? join(task.taskFolder, ".steering-pending")
|
|
3646
|
+
: undefined;
|
|
3647
|
+
|
|
3268
3648
|
const spawned = spawnAgentTmux({
|
|
3269
3649
|
sessionName,
|
|
3270
3650
|
cwd: ctx.cwd,
|
|
@@ -3274,6 +3654,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3274
3654
|
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
3275
3655
|
thinking: config.worker.thinking || "off",
|
|
3276
3656
|
taskId: task.taskId,
|
|
3657
|
+
steeringPendingPath,
|
|
3658
|
+
// TP-097: Pass stable sidecar paths and shared tailState for cross-iteration identity
|
|
3659
|
+
sidecarPath: stableSidecar?.sidecarPath,
|
|
3660
|
+
exitSummaryPath: stableSidecar?.exitSummaryPath,
|
|
3661
|
+
tailState: sharedTailState ?? undefined,
|
|
3277
3662
|
onTelemetry: (delta) => {
|
|
3278
3663
|
// Accumulate tokens and cost (same as subprocess onTokenUpdate)
|
|
3279
3664
|
state.workerInputTokens += delta.inputTokens;
|
|
@@ -3295,14 +3680,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3295
3680
|
state.workerLastRetryError = delta.lastRetryError;
|
|
3296
3681
|
}
|
|
3297
3682
|
|
|
3298
|
-
// Context % —
|
|
3299
|
-
//
|
|
3300
|
-
{
|
|
3301
|
-
const pct = delta.contextUsage
|
|
3302
|
-
? delta.contextUsage.percentUsed
|
|
3303
|
-
: (delta.latestTotalTokens > 0 && contextWindow > 0)
|
|
3304
|
-
? (delta.latestTotalTokens / contextWindow) * 100
|
|
3305
|
-
: 0;
|
|
3683
|
+
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
3684
|
+
// Manual token-based fallback removed: avoids false thresholds on older pi.
|
|
3685
|
+
if (delta.contextUsage) {
|
|
3686
|
+
const pct = delta.contextUsage.percent;
|
|
3306
3687
|
if (pct > 0) {
|
|
3307
3688
|
state.workerContextPct = pct;
|
|
3308
3689
|
if (pct >= warnPct) {
|
|
@@ -3314,6 +3695,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3314
3695
|
spawned.kill();
|
|
3315
3696
|
}
|
|
3316
3697
|
}
|
|
3698
|
+
} else if (delta.sawStatsResponseWithoutContextUsage && !warnedNoContextUsage) {
|
|
3699
|
+
// One-shot warning: pi responded to get_session_stats but omitted contextUsage (older pi)
|
|
3700
|
+
warnedNoContextUsage = true;
|
|
3701
|
+
console.error(`[task-runner] warning: pi did not provide contextUsage — context pressure thresholds disabled`);
|
|
3317
3702
|
}
|
|
3318
3703
|
|
|
3319
3704
|
updateWidgets();
|