taskplane 0.22.13 → 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
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
31
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
|
32
32
|
import { dirname, resolve, join, basename } from "node:path";
|
|
33
33
|
import { StringDecoder } from "node:string_decoder";
|
|
34
34
|
|
|
@@ -46,6 +46,7 @@ function parseArgs(argv) {
|
|
|
46
46
|
passthrough: [],
|
|
47
47
|
help: false,
|
|
48
48
|
mailboxDir: null,
|
|
49
|
+
steeringPendingPath: null,
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
let i = 2; // skip "node" and script path
|
|
@@ -78,6 +79,9 @@ function parseArgs(argv) {
|
|
|
78
79
|
} else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
|
|
79
80
|
args.mailboxDir = argv[++i];
|
|
80
81
|
i++;
|
|
82
|
+
} else if (arg === "--steering-pending-path" && i + 1 < argv.length) {
|
|
83
|
+
args.steeringPendingPath = argv[++i];
|
|
84
|
+
i++;
|
|
81
85
|
} else if (arg === "--") {
|
|
82
86
|
args.passthrough = argv.slice(i + 1);
|
|
83
87
|
break;
|
|
@@ -108,6 +112,7 @@ Optional:
|
|
|
108
112
|
--tools <t1,t2,...> Comma-separated tool names
|
|
109
113
|
--extensions <e1,e2,...> Comma-separated extension paths
|
|
110
114
|
--mailbox-dir <path> Mailbox directory for agent steering (TP-089)
|
|
115
|
+
--steering-pending-path <p> Path to .steering-pending JSONL flag file (TP-090)
|
|
111
116
|
-h, --help Show this help
|
|
112
117
|
`
|
|
113
118
|
);
|
|
@@ -509,9 +514,10 @@ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply
|
|
|
509
514
|
*
|
|
510
515
|
* @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
|
|
511
516
|
* @param {object} proc - The spawned pi process (must have writable stdin)
|
|
517
|
+
* @param {string|null} steeringPendingPath - Path to .steering-pending JSONL flag file (TP-090, worker-only)
|
|
512
518
|
* @returns {{ delivered: number, skipped: number }} Delivery stats
|
|
513
519
|
*/
|
|
514
|
-
function checkMailboxAndSteer(mailboxDir, proc) {
|
|
520
|
+
function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
|
|
515
521
|
const stats = { delivered: 0, skipped: 0 };
|
|
516
522
|
|
|
517
523
|
// Derive expected values from path structure:
|
|
@@ -616,6 +622,17 @@ function checkMailboxAndSteer(mailboxDir, proc) {
|
|
|
616
622
|
|
|
617
623
|
stats.delivered++;
|
|
618
624
|
process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
|
|
625
|
+
|
|
626
|
+
// TP-090: Append to .steering-pending JSONL flag for task-runner STATUS.md annotation.
|
|
627
|
+
// Worker-only: steeringPendingPath is only set for worker sessions.
|
|
628
|
+
if (steeringPendingPath) {
|
|
629
|
+
try {
|
|
630
|
+
const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
|
|
631
|
+
appendFileSync(steeringPendingPath, entry, "utf-8");
|
|
632
|
+
} catch (err) {
|
|
633
|
+
process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
619
636
|
} catch (err) {
|
|
620
637
|
process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
|
|
621
638
|
stats.skipped++;
|
|
@@ -759,6 +776,29 @@ const proc = spawn("pi", piArgs, {
|
|
|
759
776
|
shell: true, // Required for Windows: resolves pi.cmd shim. Matches task-runner.ts pattern.
|
|
760
777
|
});
|
|
761
778
|
|
|
779
|
+
// ── TP-097: Write PID file for orphan cleanup ──────────────────
|
|
780
|
+
// Write both the wrapper PID and the pi child PID alongside the sidecar file.
|
|
781
|
+
// The task-runner reads this on session end to kill orphan processes.
|
|
782
|
+
// Format: JSON with wrapperPid and childPid fields.
|
|
783
|
+
const pidFilePath = args.sidecarPath + ".pid";
|
|
784
|
+
try {
|
|
785
|
+
const pidData = {
|
|
786
|
+
wrapperPid: process.pid,
|
|
787
|
+
childPid: proc.pid ?? null,
|
|
788
|
+
startedAt: Date.now(),
|
|
789
|
+
};
|
|
790
|
+
writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8");
|
|
791
|
+
process.stderr.write(`[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Clean up PID file on process exit (best-effort)
|
|
797
|
+
function cleanupPidFile() {
|
|
798
|
+
try { unlinkSync(pidFilePath); } catch { /* ignore */ }
|
|
799
|
+
}
|
|
800
|
+
process.on("exit", cleanupPidFile);
|
|
801
|
+
|
|
762
802
|
// ── Send prompt via JSONL stdin ──────────────────────────────────────
|
|
763
803
|
|
|
764
804
|
const promptCmd = { type: "prompt", message: promptContent };
|
|
@@ -852,7 +892,7 @@ function handleEvent(event) {
|
|
|
852
892
|
// Only active when --mailbox-dir is provided (backward compatible).
|
|
853
893
|
if (args.mailboxDir) {
|
|
854
894
|
try {
|
|
855
|
-
checkMailboxAndSteer(args.mailboxDir, proc);
|
|
895
|
+
checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null);
|
|
856
896
|
} catch (err) {
|
|
857
897
|
// Never crash on mailbox I/O errors
|
|
858
898
|
process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
|
|
@@ -972,6 +972,16 @@ function logExecution(statusPath: string, action: string, outcome: string): void
|
|
|
972
972
|
appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
|
|
973
973
|
}
|
|
974
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
|
+
|
|
975
985
|
function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
|
|
976
986
|
appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
|
|
977
987
|
}
|
|
@@ -1567,6 +1577,68 @@ export const _resolveContextWindow = resolveContextWindow;
|
|
|
1567
1577
|
export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
|
|
1568
1578
|
export type { SidecarTailState, SidecarTelemetryDelta };
|
|
1569
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
|
+
|
|
1570
1642
|
// ── Exit Summary & Diagnostic ────────────────────────────────────────
|
|
1571
1643
|
|
|
1572
1644
|
/**
|
|
@@ -1775,6 +1847,19 @@ function spawnAgentTmux(opts: {
|
|
|
1775
1847
|
* Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
|
|
1776
1848
|
* with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
|
|
1777
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;
|
|
1778
1863
|
}): {
|
|
1779
1864
|
promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
1780
1865
|
kill: () => void;
|
|
@@ -1792,59 +1877,55 @@ function spawnAgentTmux(opts: {
|
|
|
1792
1877
|
);
|
|
1793
1878
|
}
|
|
1794
1879
|
|
|
1795
|
-
// ──
|
|
1796
|
-
//
|
|
1797
|
-
//
|
|
1798
|
-
//
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
// role → derived from sessionName suffix (worker/reviewer)
|
|
1806
|
-
//
|
|
1807
|
-
// getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
|
|
1808
|
-
const telemetryTs = Date.now();
|
|
1809
|
-
|
|
1810
|
-
// Resolve opId: same priority chain as naming.ts resolveOperatorId()
|
|
1811
|
-
let opId = "op";
|
|
1812
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1813
|
-
if (envOpId?.trim()) {
|
|
1814
|
-
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;
|
|
1815
1890
|
} else {
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
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`);
|
|
1822
1920
|
}
|
|
1823
|
-
|
|
1824
|
-
const
|
|
1825
|
-
const repoId = "default";
|
|
1826
|
-
|
|
1827
|
-
// Extract role (worker/reviewer) from sessionName, and optional lane component
|
|
1828
|
-
// sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
|
|
1829
|
-
const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1830
|
-
const laneMatch = opts.sessionName.match(/lane-(\d+)/);
|
|
1831
|
-
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1832
|
-
|
|
1833
|
-
// Include taskId when available — sanitize to filesystem-safe characters.
|
|
1834
|
-
// Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
|
|
1835
|
-
const taskIdSegment = opts.taskId
|
|
1836
|
-
? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1837
|
-
: "";
|
|
1838
|
-
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1839
|
-
const telemetryDir = join(getSidecarDir(), "telemetry");
|
|
1921
|
+
// Ensure telemetry directory exists
|
|
1922
|
+
const telemetryDir = dirname(sidecarPath);
|
|
1840
1923
|
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1841
|
-
const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
|
|
1842
|
-
const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
|
|
1843
1924
|
|
|
1844
1925
|
// ── Write prompts to temp files ─────────────────────────────────
|
|
1845
1926
|
// Same pattern as spawnAgent() — avoids shell escaping issues with
|
|
1846
1927
|
// backticks, quotes, and special characters in markdown content.
|
|
1847
|
-
const id = `${
|
|
1928
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1848
1929
|
const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
|
|
1849
1930
|
const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
|
|
1850
1931
|
writeFileSync(sysTmpFile, opts.systemPrompt);
|
|
@@ -1898,6 +1979,10 @@ function spawnAgentTmux(opts: {
|
|
|
1898
1979
|
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
1899
1980
|
wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
|
|
1900
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
|
+
}
|
|
1901
1986
|
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1902
1987
|
// Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
|
|
1903
1988
|
wrapperArgs.push("--");
|
|
@@ -1948,10 +2033,11 @@ function spawnAgentTmux(opts: {
|
|
|
1948
2033
|
// On Windows/MSYS2, rapid sequential tmux session creation is unreliable.
|
|
1949
2034
|
// Pi process can exit with code 1 in 0 seconds on the first 3-5 attempts.
|
|
1950
2035
|
// Verify the session is alive after a brief delay, and retry if it died.
|
|
1951
|
-
|
|
2036
|
+
// TP-097: Increased from 300→500ms and 2→5 retries for reliability (#335)
|
|
2037
|
+
const SPAWN_VERIFY_DELAY_MS = 500;
|
|
1952
2038
|
const SPAWN_VERIFY_POLL_ATTEMPTS = 3;
|
|
1953
2039
|
const SPAWN_VERIFY_POLL_INTERVAL_MS = 200;
|
|
1954
|
-
const SPAWN_MAX_RETRIES =
|
|
2040
|
+
const SPAWN_MAX_RETRIES = 5;
|
|
1955
2041
|
|
|
1956
2042
|
const verifySessionAlive = (): boolean => {
|
|
1957
2043
|
for (let poll = 0; poll < SPAWN_VERIFY_POLL_ATTEMPTS; poll++) {
|
|
@@ -1973,9 +2059,18 @@ function spawnAgentTmux(opts: {
|
|
|
1973
2059
|
let spawnRetries = 0;
|
|
1974
2060
|
while (!verifySessionAlive() && spawnRetries < SPAWN_MAX_RETRIES) {
|
|
1975
2061
|
spawnRetries++;
|
|
1976
|
-
|
|
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}`);
|
|
1977
2072
|
|
|
1978
|
-
// Brief delay before retry (increases with each attempt)
|
|
2073
|
+
// Brief delay before retry (increases with each attempt: 500ms, 1000ms, 1500ms, ...)
|
|
1979
2074
|
const retryDelay = spawnRetries * 500;
|
|
1980
2075
|
sleepSyncMs(retryDelay);
|
|
1981
2076
|
|
|
@@ -2017,7 +2112,10 @@ function spawnAgentTmux(opts: {
|
|
|
2017
2112
|
// ── Poll until session ends ─────────────────────────────────────
|
|
2018
2113
|
let killed = false;
|
|
2019
2114
|
const startTime = Date.now();
|
|
2020
|
-
|
|
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();
|
|
2021
2119
|
|
|
2022
2120
|
const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
|
|
2023
2121
|
try {
|
|
@@ -2059,10 +2157,12 @@ function spawnAgentTmux(opts: {
|
|
|
2059
2157
|
};
|
|
2060
2158
|
}
|
|
2061
2159
|
|
|
2062
|
-
// Normal completion — clean up temp files
|
|
2160
|
+
// Normal completion — clean up temp files and orphan processes
|
|
2063
2161
|
const elapsed = Date.now() - startTime;
|
|
2064
2162
|
console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
|
|
2065
2163
|
cleanupTmp();
|
|
2164
|
+
// TP-097: Clean up orphan rpc-wrapper/pi processes after session ends
|
|
2165
|
+
cleanupOrphanProcesses(sidecarPath);
|
|
2066
2166
|
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
|
|
2067
2167
|
return {
|
|
2068
2168
|
output: "", // No captured output in TMUX mode
|
|
@@ -2081,6 +2181,8 @@ function spawnAgentTmux(opts: {
|
|
|
2081
2181
|
// Session may have already exited — not an error
|
|
2082
2182
|
console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
|
|
2083
2183
|
}
|
|
2184
|
+
// TP-097: Clean up orphan rpc-wrapper/pi processes on explicit kill
|
|
2185
|
+
cleanupOrphanProcesses(sidecarPath);
|
|
2084
2186
|
cleanupTmp();
|
|
2085
2187
|
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
|
|
2086
2188
|
};
|
|
@@ -2088,6 +2190,68 @@ function spawnAgentTmux(opts: {
|
|
|
2088
2190
|
return { promise, kill, sidecarPath, exitSummaryPath };
|
|
2089
2191
|
}
|
|
2090
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
|
+
|
|
2091
2255
|
// ── Display Helpers ──────────────────────────────────────────────────
|
|
2092
2256
|
|
|
2093
2257
|
function displayName(name: string): string {
|
|
@@ -2866,7 +3030,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2866
3030
|
|
|
2867
3031
|
updateStatusField(statusPath, "Status", "🟡 In Progress");
|
|
2868
3032
|
updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
|
|
2869
|
-
|
|
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
|
+
}
|
|
2870
3041
|
|
|
2871
3042
|
// ── Per-task worker loop ─────────────────────────────────────
|
|
2872
3043
|
// Spawn one worker per iteration; each worker handles ALL remaining
|
|
@@ -2883,10 +3054,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2883
3054
|
const ss = currentStatus.steps.find(s => s.number === step.number);
|
|
2884
3055
|
if (ss?.status === "complete") continue;
|
|
2885
3056
|
|
|
2886
|
-
|
|
3057
|
+
if (!foundFirstIncomplete) {
|
|
2887
3058
|
// Mark the first incomplete step as in-progress
|
|
2888
|
-
|
|
2889
|
-
|
|
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
|
+
}
|
|
2890
3065
|
foundFirstIncomplete = true;
|
|
2891
3066
|
} else {
|
|
2892
3067
|
// Ensure future steps show as not-started, not in-progress
|
|
@@ -2905,11 +3080,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
2905
3080
|
return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
|
|
2906
3081
|
}
|
|
2907
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
|
+
|
|
2908
3098
|
let noProgressCount = 0;
|
|
2909
3099
|
for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
|
|
2910
3100
|
if (state.phase === "paused") {
|
|
2911
|
-
logExecution(statusPath, "Paused", `User paused at iteration ${
|
|
2912
|
-
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");
|
|
2913
3103
|
await shutdownPersistentReviewer("task paused");
|
|
2914
3104
|
return;
|
|
2915
3105
|
}
|
|
@@ -2963,12 +3153,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
2963
3153
|
writeLaneState(state);
|
|
2964
3154
|
}
|
|
2965
3155
|
|
|
2966
|
-
await runWorker(remainingSteps, ctx);
|
|
3156
|
+
await runWorker(remainingSteps, ctx, workerStableSidecar, workerTailState);
|
|
2967
3157
|
|
|
2968
3158
|
// Write context % snapshot at iteration boundary (TP-094)
|
|
2969
3159
|
const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
|
|
2970
3160
|
writeContextSnapshot(state, snapshotContextWindow);
|
|
2971
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
|
+
}
|
|
3188
|
+
|
|
2972
3189
|
if (state.phase === "error") {
|
|
2973
3190
|
await shutdownPersistentReviewer("worker error");
|
|
2974
3191
|
return;
|
|
@@ -2982,8 +3199,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2982
3199
|
const progressDelta = afterTotalChecked - prevTotalChecked;
|
|
2983
3200
|
if (progressDelta <= 0) {
|
|
2984
3201
|
noProgressCount++;
|
|
2985
|
-
|
|
2986
|
-
|
|
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");
|
|
2987
3206
|
if (noProgressCount >= config.context.no_progress_limit) {
|
|
2988
3207
|
logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
|
|
2989
3208
|
ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
|
|
@@ -3030,11 +3249,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
3030
3249
|
// Log iteration summary with progress delta and completed steps
|
|
3031
3250
|
const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
|
|
3032
3251
|
if (newlyCompleted.length > 0) {
|
|
3033
|
-
|
|
3034
|
-
|
|
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");
|
|
3035
3256
|
} else if (progressDelta > 0) {
|
|
3036
|
-
logExecution(statusPath, `Iteration ${
|
|
3037
|
-
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");
|
|
3038
3259
|
}
|
|
3039
3260
|
|
|
3040
3261
|
// Reviews are now driven inline by the worker via the review_step
|
|
@@ -3257,25 +3478,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
3257
3478
|
|
|
3258
3479
|
// ── Worker ───────────────────────────────────────────────────────
|
|
3259
3480
|
|
|
3260
|
-
|
|
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> {
|
|
3261
3490
|
if (!state.task || !state.config) return;
|
|
3262
3491
|
|
|
3263
3492
|
const task = state.task;
|
|
3264
3493
|
const config = state.config;
|
|
3265
3494
|
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
3266
3495
|
const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
|
|
3267
|
-
const legacyWrapUpFile = join(task.taskFolder, ".wiggum-wrap-up");
|
|
3268
3496
|
|
|
3269
3497
|
const clearWrapUpSignals = () => {
|
|
3270
3498
|
if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
|
|
3271
|
-
if (existsSync(legacyWrapUpFile)) try { unlinkSync(legacyWrapUpFile); } catch {}
|
|
3272
3499
|
};
|
|
3273
3500
|
|
|
3274
3501
|
const writeWrapUpSignal = (reason: string) => {
|
|
3275
3502
|
const msg = `${reason} at ${new Date().toISOString()}`;
|
|
3276
3503
|
if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
|
|
3277
|
-
// Backward compatibility: write legacy signal too until all workers migrate.
|
|
3278
|
-
if (!existsSync(legacyWrapUpFile)) writeFileSync(legacyWrapUpFile, msg);
|
|
3279
3504
|
};
|
|
3280
3505
|
|
|
3281
3506
|
clearWrapUpSignals();
|
|
@@ -3363,8 +3588,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3363
3588
|
`5. Check for wrap-up signal files before starting the next step`,
|
|
3364
3589
|
`6. Proceed to the next incomplete step`,
|
|
3365
3590
|
``,
|
|
3366
|
-
`Wrap-up signal
|
|
3367
|
-
`Check for
|
|
3591
|
+
`Wrap-up signal file: ${wrapUpFile}`,
|
|
3592
|
+
`Check for this file after each checkpoint. If it exists, stop.`,
|
|
3368
3593
|
archiveSuppression,
|
|
3369
3594
|
contextDocsList,
|
|
3370
3595
|
].join("\n");
|
|
@@ -3414,6 +3639,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3414
3639
|
// Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
|
|
3415
3640
|
const sessionName = `${getTmuxPrefix()}-worker`;
|
|
3416
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
|
+
|
|
3417
3648
|
const spawned = spawnAgentTmux({
|
|
3418
3649
|
sessionName,
|
|
3419
3650
|
cwd: ctx.cwd,
|
|
@@ -3423,6 +3654,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3423
3654
|
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
3424
3655
|
thinking: config.worker.thinking || "off",
|
|
3425
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,
|
|
3426
3662
|
onTelemetry: (delta) => {
|
|
3427
3663
|
// Accumulate tokens and cost (same as subprocess onTokenUpdate)
|
|
3428
3664
|
state.workerInputTokens += delta.inputTokens;
|
|
@@ -148,9 +148,8 @@ export function planAbortActions(
|
|
|
148
148
|
/**
|
|
149
149
|
* Write wrap-up signal files to each lane's task folder.
|
|
150
150
|
*
|
|
151
|
-
* Writes
|
|
152
|
-
*
|
|
153
|
-
* errors per lane.
|
|
151
|
+
* Writes `.task-wrap-up` signal file to each lane's task folder.
|
|
152
|
+
* Continues on partial failure — aggregates errors per lane.
|
|
154
153
|
*
|
|
155
154
|
* @param targets - Target sessions with resolved task folders
|
|
156
155
|
* @returns Updated target results with wrapUpWritten/wrapUpError
|
|
@@ -176,7 +175,6 @@ export function writeWrapUpFiles(
|
|
|
176
175
|
|
|
177
176
|
try {
|
|
178
177
|
const primaryPath = join(target.taskFolderInWorktree, ".task-wrap-up");
|
|
179
|
-
const legacyPath = join(target.taskFolderInWorktree, ".wiggum-wrap-up");
|
|
180
178
|
|
|
181
179
|
// Ensure directory exists
|
|
182
180
|
if (!existsSync(target.taskFolderInWorktree)) {
|
|
@@ -185,7 +183,6 @@ export function writeWrapUpFiles(
|
|
|
185
183
|
}
|
|
186
184
|
|
|
187
185
|
writeFileSync(primaryPath, content, "utf-8");
|
|
188
|
-
writeFileSync(legacyPath, content, "utf-8");
|
|
189
186
|
results.push({ sessionName: target.sessionName, written: true, error: null });
|
|
190
187
|
} catch (err) {
|
|
191
188
|
results.push({
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Merge orchestration, merge agents, merge worktree
|
|
3
3
|
* @module orch/merge
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync } from "fs";
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync, readdirSync } from "fs";
|
|
6
6
|
import { readFile as fsReadFile } from "fs/promises";
|
|
7
7
|
import { execSync, spawnSync } from "child_process";
|
|
8
8
|
import { join, dirname, resolve, relative } from "path";
|
|
@@ -1841,16 +1841,48 @@ export async function mergeWave(
|
|
|
1841
1841
|
// ── Stage workspace task artifacts into merge worktree ──────────
|
|
1842
1842
|
// TP-035: Tightened artifact staging — only allowlisted task-owned files
|
|
1843
1843
|
// are staged. The allowlist is derived per-task-folder from completed lanes:
|
|
1844
|
-
//
|
|
1844
|
+
// `.DONE`, `STATUS.md`, `REVIEW_VERDICT.json`, and `.reviews/**` files.
|
|
1845
1845
|
// Files outside known task folders, worktree internals, and repo-escape
|
|
1846
1846
|
// paths are rejected. Uses resolve+relative path containment consistent
|
|
1847
1847
|
// with ensureTaskFilesCommitted() in execution.ts.
|
|
1848
1848
|
if (mergeWorkDir) {
|
|
1849
1849
|
// Build the set of allowed artifact paths (repo-root-relative) from
|
|
1850
1850
|
// the completed lanes' task folders.
|
|
1851
|
+
//
|
|
1852
|
+
// Allowlist policy:
|
|
1853
|
+
// - task marker files: .DONE, STATUS.md, REVIEW_VERDICT.json
|
|
1854
|
+
// - review outputs under task-local .reviews/**
|
|
1851
1855
|
const ALLOWED_ARTIFACT_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"];
|
|
1856
|
+
const ALLOWED_ARTIFACT_DIRS = [".reviews"];
|
|
1852
1857
|
const resolvedRepoRoot = resolve(repoRoot);
|
|
1853
1858
|
const allowedRelPaths = new Set<string>();
|
|
1859
|
+
const relPathToWorktree = new Map<string, string>();
|
|
1860
|
+
|
|
1861
|
+
const listFilesRecursively = (rootDir: string): string[] => {
|
|
1862
|
+
if (!existsSync(rootDir)) return [];
|
|
1863
|
+
const files: string[] = [];
|
|
1864
|
+
const walk = (dir: string): void => {
|
|
1865
|
+
let entries;
|
|
1866
|
+
try {
|
|
1867
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1868
|
+
} catch {
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
for (const entry of entries) {
|
|
1872
|
+
const absPath = join(dir, entry.name);
|
|
1873
|
+
if (entry.isDirectory()) {
|
|
1874
|
+
walk(absPath);
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
if (!entry.isFile()) continue;
|
|
1878
|
+
const relPath = relative(rootDir, absPath).replace(/\\/g, "/");
|
|
1879
|
+
if (!relPath || relPath.startsWith("..") || relPath.startsWith("/")) continue;
|
|
1880
|
+
files.push(relPath);
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
walk(rootDir);
|
|
1884
|
+
return files;
|
|
1885
|
+
};
|
|
1854
1886
|
|
|
1855
1887
|
for (const lane of orderedLanes) {
|
|
1856
1888
|
for (const allocTask of lane.tasks) {
|
|
@@ -1867,7 +1899,24 @@ export async function mergeWave(
|
|
|
1867
1899
|
}
|
|
1868
1900
|
|
|
1869
1901
|
for (const name of ALLOWED_ARTIFACT_NAMES) {
|
|
1870
|
-
|
|
1902
|
+
const rp = `${relFolder}/${name}`;
|
|
1903
|
+
allowedRelPaths.add(rp);
|
|
1904
|
+
relPathToWorktree.set(rp, join(lane.worktreePath, rp));
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
for (const dirName of ALLOWED_ARTIFACT_DIRS) {
|
|
1908
|
+
const laneDir = join(lane.worktreePath, relFolder, dirName);
|
|
1909
|
+
for (const relFile of listFilesRecursively(laneDir)) {
|
|
1910
|
+
const rp = `${relFolder}/${dirName}/${relFile}`;
|
|
1911
|
+
allowedRelPaths.add(rp);
|
|
1912
|
+
relPathToWorktree.set(rp, join(lane.worktreePath, rp));
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const repoDir = join(repoRoot, relFolder, dirName);
|
|
1916
|
+
for (const relFile of listFilesRecursively(repoDir)) {
|
|
1917
|
+
const rp = `${relFolder}/${dirName}/${relFile}`;
|
|
1918
|
+
allowedRelPaths.add(rp);
|
|
1919
|
+
}
|
|
1871
1920
|
}
|
|
1872
1921
|
}
|
|
1873
1922
|
}
|
|
@@ -1875,12 +1924,44 @@ export async function mergeWave(
|
|
|
1875
1924
|
if (allowedRelPaths.size > 0) {
|
|
1876
1925
|
let staged = 0;
|
|
1877
1926
|
let skipped = 0;
|
|
1927
|
+
let preserved = 0;
|
|
1878
1928
|
|
|
1879
1929
|
for (const relPath of allowedRelPaths) {
|
|
1880
|
-
const srcPath = join(repoRoot, relPath);
|
|
1881
|
-
if (!existsSync(srcPath)) continue; // File not present (e.g., no REVIEW_VERDICT.json) — skip silently
|
|
1882
|
-
|
|
1883
1930
|
const destPath = join(mergeWorkDir, relPath);
|
|
1931
|
+
|
|
1932
|
+
// TP-099: If the file already exists in mergeWorkDir (from lane merge),
|
|
1933
|
+
// do NOT overwrite it — the lane merge brought the correct worker-updated
|
|
1934
|
+
// version (e.g., STATUS.md with checked items, execution log, discoveries).
|
|
1935
|
+
// Overwriting from repoRoot would revert to the pre-execution template.
|
|
1936
|
+
if (existsSync(destPath)) {
|
|
1937
|
+
preserved++;
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// File missing from mergeWorkDir — backfill from best available source.
|
|
1942
|
+
// Primary: lane worktree (has worker-generated .DONE/STATUS/.reviews content).
|
|
1943
|
+
// Fallback: repoRoot (original task folder, with path containment check).
|
|
1944
|
+
const worktreeSrc = relPathToWorktree.get(relPath);
|
|
1945
|
+
let srcPath: string | null = null;
|
|
1946
|
+
|
|
1947
|
+
// Try lane worktree first (trusted engine-allocated path)
|
|
1948
|
+
if (worktreeSrc && existsSync(worktreeSrc)) {
|
|
1949
|
+
srcPath = worktreeSrc;
|
|
1950
|
+
} else {
|
|
1951
|
+
// Fallback to repoRoot with path containment check (TP-035 hardening)
|
|
1952
|
+
const repoRootSrc = join(repoRoot, relPath);
|
|
1953
|
+
if (existsSync(repoRootSrc)) {
|
|
1954
|
+
const resolvedSrc = resolve(repoRootSrc);
|
|
1955
|
+
const srcRelToRepo = relative(resolvedRepoRoot, resolvedSrc).replace(/\\/g, "/");
|
|
1956
|
+
if (srcRelToRepo.startsWith("..") || srcRelToRepo.startsWith("/")) {
|
|
1957
|
+
execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, { path: relPath, src: repoRootSrc });
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
srcPath = repoRootSrc;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (!srcPath) continue; // File not present anywhere — skip silently
|
|
1964
|
+
|
|
1884
1965
|
try {
|
|
1885
1966
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
1886
1967
|
copyFileSync(srcPath, destPath);
|
|
@@ -1894,13 +1975,14 @@ export async function mergeWave(
|
|
|
1894
1975
|
}
|
|
1895
1976
|
|
|
1896
1977
|
if (staged > 0) {
|
|
1897
|
-
spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json)`], { cwd: mergeWorkDir });
|
|
1978
|
+
spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json, .reviews/*)`], { cwd: mergeWorkDir });
|
|
1898
1979
|
execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
|
|
1899
1980
|
skipped,
|
|
1981
|
+
preserved,
|
|
1900
1982
|
allowedCandidates: allowedRelPaths.size,
|
|
1901
1983
|
});
|
|
1902
1984
|
} else {
|
|
1903
|
-
execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed)`);
|
|
1985
|
+
execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed, ${preserved} preserved from lane merge)`);
|
|
1904
1986
|
}
|
|
1905
1987
|
|
|
1906
1988
|
// Keep both .DONE and STATUS.md in develop's working tree:
|
package/package.json
CHANGED
|
@@ -53,12 +53,11 @@ edit STATUS.md
|
|
|
53
53
|
|
|
54
54
|
Then **check for wrap-up signal:**
|
|
55
55
|
```bash
|
|
56
|
-
if test -f "<TASK_FOLDER>/.task-wrap-up"
|
|
56
|
+
if test -f "<TASK_FOLDER>/.task-wrap-up"; then
|
|
57
57
|
echo "WRAP_UP_SIGNAL"
|
|
58
58
|
fi
|
|
59
59
|
```
|
|
60
|
-
|
|
61
|
-
If either signal exists, STOP immediately after this checkpoint.
|
|
60
|
+
If the signal exists, STOP immediately after this checkpoint.
|
|
62
61
|
|
|
63
62
|
If you do work but don't edit STATUS.md, that work is INVISIBLE to the
|
|
64
63
|
orchestrator and you will be re-spawned to do it again.
|
|
@@ -262,6 +261,19 @@ Do NOT:
|
|
|
262
261
|
- Modify docs listed in `task-runner.yaml → protected_docs` without explicit approval
|
|
263
262
|
- Expand task scope — add tech debt instead
|
|
264
263
|
|
|
264
|
+
## Steering Messages
|
|
265
|
+
|
|
266
|
+
During orchestrated runs, the supervisor may send steering messages to adjust
|
|
267
|
+
your approach. These messages appear in your conversation as user messages at
|
|
268
|
+
turn boundaries. They are also logged in the STATUS.md execution log as
|
|
269
|
+
`⚠️ Steering` entries for audit visibility.
|
|
270
|
+
|
|
271
|
+
When you receive a steering message:
|
|
272
|
+
1. **Read it carefully** — it contains course corrections from the supervisor
|
|
273
|
+
2. **Adjust your approach** as directed
|
|
274
|
+
3. **Continue working** — do not stop or restart; incorporate the guidance naturally
|
|
275
|
+
4. Steering messages are authoritative — treat them like direct instructions
|
|
276
|
+
|
|
265
277
|
## Error Handling
|
|
266
278
|
|
|
267
279
|
- If stuck on the same issue after 3 attempts, document the blocker in STATUS.md
|