diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..122b5fb7 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,22 @@ +# .git-blame-ignore-revs +# +# This file lists commits that should be ignored by `git blame` so the +# history doesn't bottom out on bulk reformatting passes. Each entry is +# the full 40-character SHA of a commit whose changes are purely +# mechanical (e.g., a one-shot formatter migration). +# +# To opt in once per developer: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# Without that config, `git blame` still works — it just shows the format +# commit as the author of every line it touched, instead of the underlying +# author. The config is recommended, not required. +# +# See `docs/maintainers/development-setup.md` for setup instructions. + +# TP-193: Code-quality formatter adoption — single-pass `biome format +# --write .` across the entire taskplane codebase. Applies the rules +# locked in `biome.json` (tab indent, double quotes, trailing commas, +# semicolons, lineWidth 100, lf endings, arrow parens). 161 files +# touched; no logic changes. +f1d4533985e4853733d8f571920af8e2ac4a6cee diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..cafef18d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,39 @@ +# Enforce LF line endings in the working tree for all platforms. +# +# Without this file, Windows developers with `core.autocrlf=true` (the default) +# get CRLF in their working tree, which conflicts with Biome's +# `formatter.lineEnding: "lf"` config and causes `npm run format:check` to +# report spurious errors (the index stores LF correctly; only the local +# working tree is wrong). +# +# `* text=auto eol=lf` tells Git: detect text files automatically, and force +# LF in the working tree regardless of the platform's autocrlf setting. +# +# This file is the canonical override for the platform-default behavior. +# Added in TP-193 fold (post-merge format-pass cleanup). + +* text=auto eol=lf + +# Explicit overrides for binary file types (defensive — autodetection usually +# handles these correctly, but explicit declaration prevents corner cases). +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.zip binary +*.tar binary +*.gz binary +*.tgz binary + +# Source files: explicit LF (redundant given `* text=auto eol=lf` but improves +# discoverability when grepping `.gitattributes` for a specific extension). +*.ts text eol=lf +*.tsx text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e3a3cb6..330cf859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal +- **Code-quality formatter adoption (TP-193):** Third of four sequenced + packets implementing the code-quality-gates spec + ([`docs/specifications/taskplane/code-quality-gates.md`](docs/specifications/taskplane/code-quality-gates.md) + section 6.3). Enabled the Biome formatter and applied it once across + the entire codebase in a single mechanical commit. **Formatter rules** + pinned in `biome.json` per spec section 6.3.1: `indentStyle: "tab"`, + `indentWidth: 1`, `lineWidth: 100`, `lineEnding: "lf"`, + `quoteStyle: "double"`, `trailingCommas: "all"`, `semicolons: "always"`, + `arrowParentheses: "always"`. **Format pass** touched 161 files + (every TS/MJS file in scope) with cosmetic-only changes — line + wrapping, trailing-comma insertions, single-param arrow parens, and a + small number of quote-style switches where Biome's smart-quote rule + picked the alternative quote when the primary was inside the string. + No semantic changes. **Test resilience prep** preceded the format + pass in a separate commit: introduced `expect().toContainNormalized()` + (whitespace + bracket-padding + trailing-comma normalized substring + match) and updated 22 distinct source-grep test assertions across + ~20 test files to use the helper or pre-normalize source before + matching; bumped fixed-size source-slice windows in retry-matrix, + spawn-failure-visibility, supervisor-recovery-flows, and tier0-watchdog + tests so vertically-rewrapped multi-arg calls don't push expected + needles outside the inspected window. **`tmux-reference-audit.mjs`** + was extended to skip strict-mode functional-usage detection inside + test files, because Biome's quote-style switch unmasked literal + assertion strings like `"execSync('tmux list-sessions"` that would + otherwise flag the audit. **`.git-blame-ignore-revs`** added at the + repo root listing the format-adoption commit SHA so `git blame` + doesn't bottom out on the bulk reformat; per-developer one-time + setup (`git config blame.ignoreRevsFile .git-blame-ignore-revs`) + documented in `docs/maintainers/development-setup.md`. After the + pass: `npm run format:check` exits 0; `npm run lint` exits 0 + (TP-192 cleanup preserved); test suite unchanged at **3624 passing / + 1 skipped / 0 failed**. The `format:check` gate flip is TP-194's scope. - **Code-quality lint cleanup (TP-192):** Second of four sequenced packets implementing the code-quality-gates spec ([`docs/specifications/taskplane/code-quality-gates.md`](docs/specifications/taskplane/code-quality-gates.md) diff --git a/bin/gitignore-patterns.mjs b/bin/gitignore-patterns.mjs index cf1131d2..506670c4 100644 --- a/bin/gitignore-patterns.mjs +++ b/bin/gitignore-patterns.mjs @@ -7,8 +7,10 @@ // ─── Constants ────────────────────────────────────────────────────────────── -export const TASKPLANE_GITIGNORE_HEADER = "# Taskplane runtime artifacts (machine-specific, do not commit)"; -export const TASKPLANE_GITIGNORE_NPM_HEADER = "# Pi project-local packages (if using pi install -l)"; +export const TASKPLANE_GITIGNORE_HEADER = + "# Taskplane runtime artifacts (machine-specific, do not commit)"; +export const TASKPLANE_GITIGNORE_NPM_HEADER = + "# Pi project-local packages (if using pi install -l)"; /** * Required gitignore entries for Taskplane projects. @@ -30,14 +32,15 @@ export const TASKPLANE_GITIGNORE_ENTRIES = [ ".taskplane-tasks/", ]; -export const TASKPLANE_GITIGNORE_NPM_ENTRIES = [ - ".pi/npm/", -]; +export const TASKPLANE_GITIGNORE_NPM_ENTRIES = [".pi/npm/"]; /** * All patterns that should be gitignored, used for tracked-artifact detection. */ -export const ALL_GITIGNORE_PATTERNS = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES]; +export const ALL_GITIGNORE_PATTERNS = [ + ...TASKPLANE_GITIGNORE_ENTRIES, + ...TASKPLANE_GITIGNORE_NPM_ENTRIES, +]; // ─── Pattern Matching ─────────────────────────────────────────────────────── @@ -74,6 +77,6 @@ export function patternToRegex(pattern) { * @returns {boolean} True if the file matches any pattern */ export function matchesAnyGitignorePattern(filePath, patterns = ALL_GITIGNORE_PATTERNS) { - const regexes = patterns.map(p => patternToRegex(p)); - return regexes.some(regex => regex.test(filePath)); + const regexes = patterns.map((p) => patternToRegex(p)); + return regexes.some((regex) => regex.test(filePath)); } diff --git a/bin/rpc-wrapper.mjs b/bin/rpc-wrapper.mjs index 108d58dd..9cc7cbab 100644 --- a/bin/rpc-wrapper.mjs +++ b/bin/rpc-wrapper.mjs @@ -28,7 +28,15 @@ */ import { spawn } from "node:child_process"; -import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs"; +import { + readFileSync, + writeFileSync, + appendFileSync, + mkdirSync, + readdirSync, + renameSync, + unlinkSync, +} from "node:fs"; import { dirname, resolve, join, basename } from "node:path"; import { StringDecoder } from "node:string_decoder"; @@ -71,10 +79,16 @@ function parseArgs(argv) { args.promptFile = argv[++i]; i++; } else if (arg === "--tools" && i + 1 < argv.length) { - args.tools = argv[++i].split(",").map((t) => t.trim()).filter(Boolean); + args.tools = argv[++i] + .split(",") + .map((t) => t.trim()) + .filter(Boolean); i++; } else if (arg === "--extensions" && i + 1 < argv.length) { - args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean); + args.extensions = argv[++i] + .split(",") + .map((e) => e.trim()) + .filter(Boolean); i++; } else if (arg === "--mailbox-dir" && i + 1 < argv.length) { args.mailboxDir = argv[++i]; @@ -114,7 +128,7 @@ Optional: --mailbox-dir Mailbox directory for agent steering (TP-089) --steering-pending-path

Path to .steering-pending JSONL flag file (TP-090) -h, --help Show this help -` +`, ); } @@ -177,9 +191,9 @@ function redactValue(val) { if (val === null || val === undefined) return val; if (typeof val === "string") { - return redactString(val.length > MAX_TOOL_ARG_LENGTH - ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]" - : val); + return redactString( + val.length > MAX_TOOL_ARG_LENGTH ? val.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]" : val, + ); } if (Array.isArray(val)) { @@ -237,7 +251,7 @@ function redactSummary(summary) { redacted.lastToolCall = redactString( redacted.lastToolCall.length > MAX_TOOL_ARG_LENGTH ? redacted.lastToolCall.slice(0, MAX_TOOL_ARG_LENGTH) + "…[truncated]" - : redacted.lastToolCall + : redacted.lastToolCall, ); } @@ -276,7 +290,8 @@ function writeSidecarEvent(sidecarPath, event) { function displayProgress(state) { const parts = []; if (state.currentTool) parts.push(`tool: ${state.currentTool}`); - const totalTokens = state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite; + const totalTokens = + state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite; if (totalTokens > 0) parts.push(`tokens: ${totalTokens.toLocaleString()}`); if (state.cost > 0) parts.push(`cost: $${state.cost.toFixed(4)}`); if (state.toolCalls > 0) parts.push(`tools: ${state.toolCalls}`); @@ -363,7 +378,12 @@ function applyEvent(state, event) { state.tokens.cacheRead += usage.cacheRead || 0; state.tokens.cacheWrite += usage.cacheWrite || 0; if (usage.cost) { - state.cost += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0); + state.cost += + typeof usage.cost === "object" + ? usage.cost.total || 0 + : typeof usage.cost === "number" + ? usage.cost + : 0; } } break; @@ -453,16 +473,20 @@ function applyEvent(state, event) { function buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime) { const durationSec = Math.round((Date.now() - startTime) / 1000); const finalError = errorOverride || state.error || null; - const normalizedExitCode = (typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode >= 0) - ? exitCode - : (exitCode === null || exitCode === undefined ? null : 1); + const normalizedExitCode = + typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode >= 0 + ? exitCode + : exitCode === null || exitCode === undefined + ? null + : 1; const rawSummary = { exitCode: normalizedExitCode, exitSignal: exitSignal || null, - tokens: (state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite) > 0 - ? { ...state.tokens } - : null, + tokens: + state.tokens.input + state.tokens.output + state.tokens.cacheRead + state.tokens.cacheWrite > 0 + ? { ...state.tokens } + : null, cost: state.cost > 0 ? state.cost : null, toolCalls: state.toolCalls, retries: state.retries, @@ -537,7 +561,7 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) { } // Filter: only *.msg.json files (excludes .msg.json.tmp temp files) - const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); + const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); if (msgFiles.length === 0) return stats; // Read and validate all messages @@ -572,14 +596,18 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) { // Validate batchId (derived from path, not message content) if (msg.batchId !== expectedBatchId) { - process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`); + process.stderr.write( + `\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`, + ); stats.skipped++; continue; } // Validate to (no misdelivery) if (msg.to !== expectedSessionName) { - process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`); + process.stderr.write( + `\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`, + ); stats.skipped++; continue; } @@ -609,7 +637,11 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) { // Move to ack/ (delivery proof) const ackDir = join(mailboxDir, "ack"); - try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ } + try { + mkdirSync(ackDir, { recursive: true }); + } catch { + /* exists */ + } try { renameSync(join(inboxDir, filename), join(ackDir, filename)); } catch (err) { @@ -626,10 +658,13 @@ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) { // Worker-only: steeringPendingPath is only set for worker sessions. if (steeringPendingPath) { try { - const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n"; + const entry = + JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n"; appendFileSync(steeringPendingPath, entry, "utf-8"); } catch (err) { - process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`); + process.stderr.write( + `\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`, + ); } } } catch (err) { @@ -656,8 +691,10 @@ function isValidMailboxMessageShape(obj) { typeof obj.batchId === "string" && typeof obj.from === "string" && typeof obj.to === "string" && - typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) && - typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) && + typeof obj.timestamp === "number" && + Number.isFinite(obj.timestamp) && + typeof obj.type === "string" && + MAILBOX_MESSAGE_TYPES.has(obj.type) && typeof obj.content === "string" ); } @@ -689,398 +726,414 @@ export { // import.meta.url ends with the script name; process.argv[1] is the entry point. // On Windows with shell:true, argv[1] may differ, so also check for --help being // processed as a signal that we're the entry point. -const _isMain = process.argv[1] && +const _isMain = + process.argv[1] && (import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")) || - import.meta.url.endsWith("/" + process.argv[1].replace(/\\/g, "/").split("/").pop()) || - process.argv[1].endsWith("rpc-wrapper.mjs")); + import.meta.url.endsWith("/" + process.argv[1].replace(/\\/g, "/").split("/").pop()) || + process.argv[1].endsWith("rpc-wrapper.mjs")); if (_isMain) { _main(); } function _main() { + const args = parseArgs(process.argv); -const args = parseArgs(process.argv); - -if (args.help) { - printUsage(); - process.exit(0); -} - -// Validate required args -if (!args.sidecarPath) { - process.stderr.write("[rpc-wrapper] ERROR: --sidecar-path is required\n"); - process.exit(1); -} -if (!args.exitSummaryPath) { - process.stderr.write("[rpc-wrapper] ERROR: --exit-summary-path is required\n"); - process.exit(1); -} -if (!args.promptFile) { - process.stderr.write("[rpc-wrapper] ERROR: --prompt-file is required\n"); - process.exit(1); -} + if (args.help) { + printUsage(); + process.exit(0); + } -// Read prompt content -let promptContent; -try { - promptContent = readFileSync(resolve(args.promptFile), "utf-8"); -} catch (err) { - process.stderr.write(`[rpc-wrapper] ERROR: Cannot read prompt file: ${err.message}\n`); - process.exit(1); -} + // Validate required args + if (!args.sidecarPath) { + process.stderr.write("[rpc-wrapper] ERROR: --sidecar-path is required\n"); + process.exit(1); + } + if (!args.exitSummaryPath) { + process.stderr.write("[rpc-wrapper] ERROR: --exit-summary-path is required\n"); + process.exit(1); + } + if (!args.promptFile) { + process.stderr.write("[rpc-wrapper] ERROR: --prompt-file is required\n"); + process.exit(1); + } -// Read system prompt content (optional) -let systemPromptContent = null; -if (args.systemPromptFile) { + // Read prompt content + let promptContent; try { - systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8"); + promptContent = readFileSync(resolve(args.promptFile), "utf-8"); } catch (err) { - process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`); + process.stderr.write(`[rpc-wrapper] ERROR: Cannot read prompt file: ${err.message}\n`); + process.exit(1); } -} - -// Ensure output directories exist -mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true }); -mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true }); -// ── Session State ──────────────────────────────────────────────────── + // Read system prompt content (optional) + let systemPromptContent = null; + if (args.systemPromptFile) { + try { + systemPromptContent = readFileSync(resolve(args.systemPromptFile), "utf-8"); + } catch (err) { + process.stderr.write(`[rpc-wrapper] WARNING: Cannot read system prompt file: ${err.message}\n`); + } + } -const startTime = Date.now(); -const state = createSessionState(); + // Ensure output directories exist + mkdirSync(dirname(resolve(args.sidecarPath)), { recursive: true }); + mkdirSync(dirname(resolve(args.exitSummaryPath)), { recursive: true }); -// ── Build pi spawn args ────────────────────────────────────────────── + // ── Session State ──────────────────────────────────────────────────── -const piArgs = ["--mode", "rpc", "--no-session"]; + const startTime = Date.now(); + const state = createSessionState(); -if (args.model) { - piArgs.push("--model", args.model); -} -if (systemPromptContent) { - piArgs.push("--system-prompt", systemPromptContent); -} -if (args.tools.length > 0) { - piArgs.push("--tools", args.tools.join(",")); -} -for (const ext of args.extensions) { - piArgs.push("-e", ext); -} -piArgs.push(...args.passthrough); - -// ── Spawn pi process ───────────────────────────────────────────────── - -// ── System prompt: file-based passthrough to avoid command line limits ──── -// Windows CreateProcess has a ~32K command line limit. Orchestrated worker -// system prompts routinely exceed this (PROMPT.md + context docs + steps). -// When the system prompt is large, write it to a temp file and use shell -// expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash shells -// used by lane sessions without hitting the Win32 limit. -// -// For small system prompts (< 8K), pass inline for simplicity. -const SYSTEM_PROMPT_FILE_THRESHOLD = 8192; -let systemPromptTempFile = null; - -if (systemPromptContent && systemPromptContent.length >= SYSTEM_PROMPT_FILE_THRESHOLD) { - // Remove --system-prompt from piArgs (was added above) and use file instead - const sysIdx = piArgs.indexOf("--system-prompt"); - if (sysIdx >= 0) piArgs.splice(sysIdx, 2); - // Write to temp file and use --append-system-prompt with @file syntax. - // Pi's --append-system-prompt accepts @filepath to read from a file. - // We use --system-prompt "" (empty base) + --append-system-prompt @file - // to effectively set the system prompt from a file. - systemPromptTempFile = join(tmpdir(), `pi-rpc-sysprompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`); - writeFileSync(systemPromptTempFile, systemPromptContent, "utf-8"); - piArgs.push("--system-prompt", ""); - piArgs.push("--append-system-prompt", `@${systemPromptTempFile}`); - process.stderr.write(`[rpc-wrapper] system prompt written to file (${systemPromptContent.length} chars): ${systemPromptTempFile}\n`); -} + // ── Build pi spawn args ────────────────────────────────────────────── -const proc = spawn("pi", piArgs, { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env }, - shell: true, -}); - -// ── TP-097: Write PID file for orphan cleanup ────────────────── -// Write both the wrapper PID and the pi child PID alongside the sidecar file. -// The task-runner reads this on session end to kill orphan processes. -// Format: JSON with wrapperPid and childPid fields. -const pidFilePath = args.sidecarPath + ".pid"; -try { - const pidData = { - wrapperPid: process.pid, - childPid: proc.pid ?? null, - startedAt: Date.now(), - }; - writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8"); - process.stderr.write(`[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`); -} catch (err) { - process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`); -} + const piArgs = ["--mode", "rpc", "--no-session"]; -// Clean up PID file on process exit (best-effort) -function cleanupPidFile() { - try { unlinkSync(pidFilePath); } catch { /* ignore */ } - if (systemPromptTempFile) { - try { unlinkSync(systemPromptTempFile); } catch { /* ignore */ } + if (args.model) { + piArgs.push("--model", args.model); + } + if (systemPromptContent) { + piArgs.push("--system-prompt", systemPromptContent); + } + if (args.tools.length > 0) { + piArgs.push("--tools", args.tools.join(",")); + } + for (const ext of args.extensions) { + piArgs.push("-e", ext); + } + piArgs.push(...args.passthrough); + + // ── Spawn pi process ───────────────────────────────────────────────── + + // ── System prompt: file-based passthrough to avoid command line limits ──── + // Windows CreateProcess has a ~32K command line limit. Orchestrated worker + // system prompts routinely exceed this (PROMPT.md + context docs + steps). + // When the system prompt is large, write it to a temp file and use shell + // expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash shells + // used by lane sessions without hitting the Win32 limit. + // + // For small system prompts (< 8K), pass inline for simplicity. + const SYSTEM_PROMPT_FILE_THRESHOLD = 8192; + let systemPromptTempFile = null; + + if (systemPromptContent && systemPromptContent.length >= SYSTEM_PROMPT_FILE_THRESHOLD) { + // Remove --system-prompt from piArgs (was added above) and use file instead + const sysIdx = piArgs.indexOf("--system-prompt"); + if (sysIdx >= 0) piArgs.splice(sysIdx, 2); + // Write to temp file and use --append-system-prompt with @file syntax. + // Pi's --append-system-prompt accepts @filepath to read from a file. + // We use --system-prompt "" (empty base) + --append-system-prompt @file + // to effectively set the system prompt from a file. + systemPromptTempFile = join( + tmpdir(), + `pi-rpc-sysprompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`, + ); + writeFileSync(systemPromptTempFile, systemPromptContent, "utf-8"); + piArgs.push("--system-prompt", ""); + piArgs.push("--append-system-prompt", `@${systemPromptTempFile}`); + process.stderr.write( + `[rpc-wrapper] system prompt written to file (${systemPromptContent.length} chars): ${systemPromptTempFile}\n`, + ); } -} -process.on("exit", cleanupPidFile); -// ── Send prompt via JSONL stdin ────────────────────────────────────── + const proc = spawn("pi", piArgs, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + shell: true, + }); -const promptCmd = { type: "prompt", message: promptContent }; -proc.stdin.write(JSON.stringify(promptCmd) + "\n"); + // ── TP-097: Write PID file for orphan cleanup ────────────────── + // Write both the wrapper PID and the pi child PID alongside the sidecar file. + // The task-runner reads this on session end to kill orphan processes. + // Format: JSON with wrapperPid and childPid fields. + const pidFilePath = args.sidecarPath + ".pid"; + try { + const pidData = { + wrapperPid: process.pid, + childPid: proc.pid ?? null, + startedAt: Date.now(), + }; + writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8"); + process.stderr.write( + `[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`, + ); + } catch (err) { + process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`); + } -// ── Agent Mailbox Steering Setup (TP-089) ──────────────────────────── -// When mailbox-dir is provided, set steering mode to "all" so queued -// steering messages are delivered together at the next turn boundary. -// Must be sent after prompt but before any agent processing begins. -if (args.mailboxDir) { - proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n"); - process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`); -} + // Clean up PID file on process exit (best-effort) + function cleanupPidFile() { + try { + unlinkSync(pidFilePath); + } catch { + /* ignore */ + } + if (systemPromptTempFile) { + try { + unlinkSync(systemPromptTempFile); + } catch { + /* ignore */ + } + } + } + process.on("exit", cleanupPidFile); -// ── Stdin Lifecycle ────────────────────────────────────────────────── + // ── Send prompt via JSONL stdin ────────────────────────────────────── -/** - * Close the child process stdin at a deterministic terminal point. - * RPC mode waits for more commands while stdin is open — without closing it, - * the pi process can hang indefinitely after `agent_end` or a terminal error. - * - * Called from: agent_end handler, terminal response error handler. - * Safe to call multiple times (checks destroyed flag). - */ -function closeStdin() { - try { - if (proc.stdin && !proc.stdin.destroyed) { - proc.stdin.end(); - } - } catch { - // stdin may already be closed — ignore + const promptCmd = { type: "prompt", message: promptContent }; + proc.stdin.write(JSON.stringify(promptCmd) + "\n"); + + // ── Agent Mailbox Steering Setup (TP-089) ──────────────────────────── + // When mailbox-dir is provided, set steering mode to "all" so queued + // steering messages are delivered together at the next turn boundary. + // Must be sent after prompt but before any agent processing begins. + if (args.mailboxDir) { + proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n"); + process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`); } -} -/** - * Query pi for authoritative session stats including contextUsage. - * Available in pi ≥ 0.63.0 (RPC get_session_stats exposes contextUsage). - * Safe to call on older versions — the command is ignored or returns - * without the field, and state.contextUsage stays null. - */ -function querySessionStats() { - try { - if (proc.stdin && !proc.stdin.destroyed) { - proc.stdin.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); + // ── Stdin Lifecycle ────────────────────────────────────────────────── + + /** + * Close the child process stdin at a deterministic terminal point. + * RPC mode waits for more commands while stdin is open — without closing it, + * the pi process can hang indefinitely after `agent_end` or a terminal error. + * + * Called from: agent_end handler, terminal response error handler. + * Safe to call multiple times (checks destroyed flag). + */ + function closeStdin() { + try { + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.end(); + } + } catch { + // stdin may already be closed — ignore } - } catch { - // stdin may be closed — ignore } -} -// ── Route RPC events ───────────────────────────────────────────────── - -// Event types worth persisting to the sidecar JSONL. -// Streaming deltas (content_block_delta, content_block_start/stop, message_start, -// input_json_delta, etc.) are omitted — they're high-volume, large, and not used -// by the dashboard or telemetry consumers. A single merge agent can produce 42MB+ -// of sidecar data from streaming deltas alone. -const SIDECAR_EVENT_TYPES = new Set([ - "agent_start", - "agent_end", - "message_end", - "tool_execution_start", - "tool_execution_end", - "tool_execution_update", - "auto_retry_start", - "auto_retry_end", - "auto_compaction_start", - "response", -]); - -function handleEvent(event) { - if (!event || !event.type) return; - - // Write only telemetry-relevant events to sidecar (redacted) - if (SIDECAR_EVENT_TYPES.has(event.type)) { - writeSidecarEvent(args.sidecarPath, event); + /** + * Query pi for authoritative session stats including contextUsage. + * Available in pi ≥ 0.63.0 (RPC get_session_stats exposes contextUsage). + * Safe to call on older versions — the command is ignored or returns + * without the field, and state.contextUsage stays null. + */ + function querySessionStats() { + try { + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); + } + } catch { + // stdin may be closed — ignore + } } - // Delegate state mutation to the extracted (testable) accumulator - applyEvent(state, event); + // ── Route RPC events ───────────────────────────────────────────────── + + // Event types worth persisting to the sidecar JSONL. + // Streaming deltas (content_block_delta, content_block_start/stop, message_start, + // input_json_delta, etc.) are omitted — they're high-volume, large, and not used + // by the dashboard or telemetry consumers. A single merge agent can produce 42MB+ + // of sidecar data from streaming deltas alone. + const SIDECAR_EVENT_TYPES = new Set([ + "agent_start", + "agent_end", + "message_end", + "tool_execution_start", + "tool_execution_end", + "tool_execution_update", + "auto_retry_start", + "auto_retry_end", + "auto_compaction_start", + "response", + ]); + + function handleEvent(event) { + if (!event || !event.type) return; + + // Write only telemetry-relevant events to sidecar (redacted) + if (SIDECAR_EVENT_TYPES.has(event.type)) { + writeSidecarEvent(args.sidecarPath, event); + } - // Side effects that depend on the event type (IO, stdin lifecycle, display) - switch (event.type) { - case "message_end": - displayProgress(state); - // Query pi for authoritative context usage (pi ≥ 0.63.0). - // Falls back gracefully: older pi versions ignore the command - // or return a response without contextUsage — state.contextUsage stays null. - querySessionStats(); - // Check mailbox for pending steering messages (TP-089). - // Only active when --mailbox-dir is provided (backward compatible). - if (args.mailboxDir) { - try { - checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null); - } catch (err) { - // Never crash on mailbox I/O errors - process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`); + // Delegate state mutation to the extracted (testable) accumulator + applyEvent(state, event); + + // Side effects that depend on the event type (IO, stdin lifecycle, display) + switch (event.type) { + case "message_end": + displayProgress(state); + // Query pi for authoritative context usage (pi ≥ 0.63.0). + // Falls back gracefully: older pi versions ignore the command + // or return a response without contextUsage — state.contextUsage stays null. + querySessionStats(); + // Check mailbox for pending steering messages (TP-089). + // Only active when --mailbox-dir is provided (backward compatible). + if (args.mailboxDir) { + try { + checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null); + } catch (err) { + // Never crash on mailbox I/O errors + process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`); + } } - } - break; - - case "tool_execution_start": - displayProgress(state); - break; + break; - case "agent_end": - // Close stdin so pi process can exit cleanly. - // RPC mode waits for more commands while stdin is open; - // without this, the process can hang indefinitely. - closeStdin(); - break; + case "tool_execution_start": + displayProgress(state); + break; - case "response": - // Terminal error response — close stdin to let pi exit - if (event.success === false && event.error) { + case "agent_end": + // Close stdin so pi process can exit cleanly. + // RPC mode waits for more commands while stdin is open; + // without this, the process can hang indefinitely. closeStdin(); - } - break; + break; - default: - break; - } -} + case "response": + // Terminal error response — close stdin to let pi exit + if (event.success === false && event.error) { + closeStdin(); + } + break; -// Read RPC events from stdout using JSONL line-buffering -attachJsonlReader(proc.stdout, (line) => { - try { - const event = JSON.parse(line); - handleEvent(event); - } catch { - // Malformed JSON line — log to stderr but don't crash - process.stderr.write(`\n[rpc-wrapper] malformed JSONL: ${line.slice(0, 200)}\n`); - } -}); - -// Forward stderr from pi to our stderr -// Capture pi stderr for diagnostics — last 2KB preserved in exit summary. -// This is critical for diagnosing startup crashes (pi exits code 1 with 0 tokens). -let piStderrBuffer = ""; -const PI_STDERR_MAX = 2048; -proc.stderr?.setEncoding("utf-8"); -proc.stderr?.on("data", (chunk) => { - process.stderr.write(chunk); - piStderrBuffer += chunk; - if (piStderrBuffer.length > PI_STDERR_MAX * 2) { - piStderrBuffer = piStderrBuffer.slice(-PI_STDERR_MAX); + default: + break; + } } -}); - -// ── Single-Write Exit Summary Finalization ─────────────────────────── -/** - * Single-write guard: ensures exit summary is written exactly once - * across all termination paths (close, error, signal handlers). - * - * Uses the extracted createSingleWriteGuard + buildExitSummary for testability. - * The first handler to call writeExitSummary() wins; subsequent calls are no-ops. - */ -const writeExitSummary = createSingleWriteGuard((summary) => { - try { - writeFileSync(resolve(args.exitSummaryPath), JSON.stringify(summary, null, 2) + "\n", "utf-8"); - process.stderr.write(`\n[rpc-wrapper] exit summary written to ${args.exitSummaryPath}\n`); - } catch (err) { - process.stderr.write(`\n[rpc-wrapper] FATAL: failed to write exit summary: ${err.message}\n`); - } -}); - -// ── Process Lifecycle Handlers ─────────────────────────────────────── - -// Primary handler: process close event (most authoritative source of exit info) -proc.on("close", (code, signal) => { - // Newline after progress display - process.stderr.write("\n"); - - if (!state.agentEnded && code !== 0) { - // Process crashed without agent_end — capture what we have - const stderrTail = piStderrBuffer.trim().slice(-PI_STDERR_MAX); - const crashError = state.error || `pi process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}${stderrTail ? `\npi stderr: ${stderrTail}` : ""}`; - writeExitSummary(state, code, signal, crashError, startTime); - } else { - writeExitSummary(state, code, signal, null, startTime); - } -}); + // Read RPC events from stdout using JSONL line-buffering + attachJsonlReader(proc.stdout, (line) => { + try { + const event = JSON.parse(line); + handleEvent(event); + } catch { + // Malformed JSON line — log to stderr but don't crash + process.stderr.write(`\n[rpc-wrapper] malformed JSONL: ${line.slice(0, 200)}\n`); + } + }); -// Fallback handler: spawn error (e.g., pi binary not found) -proc.on("error", (err) => { - writeExitSummary(state, null, null, `spawn error: ${err.message}`, startTime); -}); + // Forward stderr from pi to our stderr + // Capture pi stderr for diagnostics — last 2KB preserved in exit summary. + // This is critical for diagnosing startup crashes (pi exits code 1 with 0 tokens). + let piStderrBuffer = ""; + const PI_STDERR_MAX = 2048; + proc.stderr?.setEncoding("utf-8"); + proc.stderr?.on("data", (chunk) => { + process.stderr.write(chunk); + piStderrBuffer += chunk; + if (piStderrBuffer.length > PI_STDERR_MAX * 2) { + piStderrBuffer = piStderrBuffer.slice(-PI_STDERR_MAX); + } + }); -// ── Signal Forwarding ──────────────────────────────────────────────── + // ── Single-Write Exit Summary Finalization ─────────────────────────── -/** - * Forward SIGTERM/SIGINT to the pi process via RPC abort command. - * This allows graceful shutdown of the agent before the process exits. - * - * On Windows, SIGTERM/SIGINT behavior differs — we handle both and - * attempt graceful abort first, then hard kill after a timeout. - */ -let signalForwarded = false; + /** + * Single-write guard: ensures exit summary is written exactly once + * across all termination paths (close, error, signal handlers). + * + * Uses the extracted createSingleWriteGuard + buildExitSummary for testability. + * The first handler to call writeExitSummary() wins; subsequent calls are no-ops. + */ + const writeExitSummary = createSingleWriteGuard((summary) => { + try { + writeFileSync(resolve(args.exitSummaryPath), JSON.stringify(summary, null, 2) + "\n", "utf-8"); + process.stderr.write(`\n[rpc-wrapper] exit summary written to ${args.exitSummaryPath}\n`); + } catch (err) { + process.stderr.write(`\n[rpc-wrapper] FATAL: failed to write exit summary: ${err.message}\n`); + } + }); -function forwardSignal(signal) { - if (signalForwarded) return; - signalForwarded = true; + // ── Process Lifecycle Handlers ─────────────────────────────────────── - process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`); + // Primary handler: process close event (most authoritative source of exit info) + proc.on("close", (code, signal) => { + // Newline after progress display + process.stderr.write("\n"); - // Try graceful abort via RPC - try { - if (proc.stdin && !proc.stdin.destroyed) { - proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n"); + if (!state.agentEnded && code !== 0) { + // Process crashed without agent_end — capture what we have + const stderrTail = piStderrBuffer.trim().slice(-PI_STDERR_MAX); + const crashError = + state.error || + `pi process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}${stderrTail ? `\npi stderr: ${stderrTail}` : ""}`; + writeExitSummary(state, code, signal, crashError, startTime); + } else { + writeExitSummary(state, code, signal, null, startTime); } - } catch { - // stdin may already be closed - } + }); - // Give pi 5 seconds to shut down gracefully, then hard kill - const killTimer = setTimeout(() => { + // Fallback handler: spawn error (e.g., pi binary not found) + proc.on("error", (err) => { + writeExitSummary(state, null, null, `spawn error: ${err.message}`, startTime); + }); + + // ── Signal Forwarding ──────────────────────────────────────────────── + + /** + * Forward SIGTERM/SIGINT to the pi process via RPC abort command. + * This allows graceful shutdown of the agent before the process exits. + * + * On Windows, SIGTERM/SIGINT behavior differs — we handle both and + * attempt graceful abort first, then hard kill after a timeout. + */ + let signalForwarded = false; + + function forwardSignal(signal) { + if (signalForwarded) return; + signalForwarded = true; + + process.stderr.write(`\n[rpc-wrapper] received ${signal}, sending abort to pi...\n`); + + // Try graceful abort via RPC try { - proc.kill("SIGTERM"); + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.write(JSON.stringify({ type: "abort" }) + "\n"); + } } catch { - // Process may already be dead + // stdin may already be closed } - }, 5000); - // Don't let the timer keep the process alive - if (killTimer.unref) killTimer.unref(); -} - -process.on("SIGTERM", () => forwardSignal("SIGTERM")); -process.on("SIGINT", () => forwardSignal("SIGINT")); + // Give pi 5 seconds to shut down gracefully, then hard kill + const killTimer = setTimeout(() => { + try { + proc.kill("SIGTERM"); + } catch { + // Process may already be dead + } + }, 5000); -// ── Uncaught Exception / Unhandled Rejection Handler ───────────────── + // Don't let the timer keep the process alive + if (killTimer.unref) killTimer.unref(); + } -process.on("uncaughtException", (err) => { - process.stderr.write(`\n[rpc-wrapper] uncaught exception: ${err.message}\n`); - writeExitSummary(state, null, null, `wrapper uncaught exception: ${err.message}`, startTime); - process.exit(1); -}); + process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.on("SIGINT", () => forwardSignal("SIGINT")); -process.on("unhandledRejection", (reason) => { - const msg = reason instanceof Error ? reason.message : String(reason); - process.stderr.write(`\n[rpc-wrapper] unhandled rejection: ${msg}\n`); - writeExitSummary(state, null, null, `wrapper unhandled rejection: ${msg}`, startTime); - process.exit(1); -}); + // ── Uncaught Exception / Unhandled Rejection Handler ───────────────── -// ── Exit Code Forwarding ───────────────────────────────────────────── + process.on("uncaughtException", (err) => { + process.stderr.write(`\n[rpc-wrapper] uncaught exception: ${err.message}\n`); + writeExitSummary(state, null, null, `wrapper uncaught exception: ${err.message}`, startTime); + process.exit(1); + }); -// Forward the pi process exit code as our own (normalized: null/negative/non-finite → 1) -proc.on("close", (code) => { - // Use setImmediate to let other close handlers run first - setImmediate(() => { - process.exitCode = (typeof code === "number" && Number.isFinite(code) && code >= 0) ? code : 1; + process.on("unhandledRejection", (reason) => { + const msg = reason instanceof Error ? reason.message : String(reason); + process.stderr.write(`\n[rpc-wrapper] unhandled rejection: ${msg}\n`); + writeExitSummary(state, null, null, `wrapper unhandled rejection: ${msg}`, startTime); + process.exit(1); }); -}); + // ── Exit Code Forwarding ───────────────────────────────────────────── + + // Forward the pi process exit code as our own (normalized: null/negative/non-finite → 1) + proc.on("close", (code) => { + // Use setImmediate to let other close handlers run first + setImmediate(() => { + process.exitCode = typeof code === "number" && Number.isFinite(code) && code >= 0 ? code : 1; + }); + }); } // end _main() diff --git a/bin/taskplane.mjs b/bin/taskplane.mjs index 37468c64..0b0c7302 100644 --- a/bin/taskplane.mjs +++ b/bin/taskplane.mjs @@ -17,7 +17,7 @@ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10); if (nodeMajor < MIN_NODE_MAJOR) { console.error( `\x1b[31m❌ Taskplane requires Node.js >= ${MIN_NODE_MAJOR}.0.0 (found ${process.versions.node}).\x1b[0m\n` + - ` Upgrade: https://nodejs.org/\n` + ` Upgrade: https://nodejs.org/\n`, ); process.exit(1); } @@ -173,7 +173,12 @@ export function parsePiListModelsOutput(rawOutput) { if (!/^[a-z0-9][a-z0-9._-]*$/i.test(provider)) continue; if (!/^[^\s]+$/.test(id)) continue; - const thinkingToken = thinkingCol >= 0 ? String(parts[thinkingCol] ?? "").trim().toLowerCase() : ""; + const thinkingToken = + thinkingCol >= 0 + ? String(parts[thinkingCol] ?? "") + .trim() + .toLowerCase() + : ""; const supportsThinking = (() => { if (!thinkingToken) return undefined; if (["yes", "true", "on", "supported"].includes(thinkingToken)) return true; @@ -198,8 +203,8 @@ export function parsePiListModelsOutput(rawOutput) { }); } - return [...parsed.values()].sort((a, b) => - a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id) + return [...parsed.values()].sort( + (a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id), ); } @@ -349,7 +354,9 @@ function createBootstrapGlobalPreferencesForCli() { } function normalizeThinkingMode(value) { - const cleaned = String(value ?? "").trim().toLowerCase(); + const cleaned = String(value ?? "") + .trim() + .toLowerCase(); if (!cleaned || cleaned === "inherit") return ""; if (cleaned === "on") return "high"; if (PI_THINKING_LEVELS.includes(cleaned)) return cleaned; @@ -365,12 +372,17 @@ function sanitizeInitAgentConfig(raw) { const defaults = createInheritInitAgentConfig(); if (!raw || typeof raw !== "object" || Array.isArray(raw)) return defaults; - if (typeof raw.workerModel === "string") defaults.workerModel = normalizeModelValue(raw.workerModel); - if (typeof raw.reviewerModel === "string") defaults.reviewerModel = normalizeModelValue(raw.reviewerModel); + if (typeof raw.workerModel === "string") + defaults.workerModel = normalizeModelValue(raw.workerModel); + if (typeof raw.reviewerModel === "string") + defaults.reviewerModel = normalizeModelValue(raw.reviewerModel); if (typeof raw.mergeModel === "string") defaults.mergeModel = normalizeModelValue(raw.mergeModel); - if (raw.workerThinking !== undefined) defaults.workerThinking = normalizeThinkingMode(raw.workerThinking); - if (raw.reviewerThinking !== undefined) defaults.reviewerThinking = normalizeThinkingMode(raw.reviewerThinking); - if (raw.mergeThinking !== undefined) defaults.mergeThinking = normalizeThinkingMode(raw.mergeThinking); + if (raw.workerThinking !== undefined) + defaults.workerThinking = normalizeThinkingMode(raw.workerThinking); + if (raw.reviewerThinking !== undefined) + defaults.reviewerThinking = normalizeThinkingMode(raw.reviewerThinking); + if (raw.mergeThinking !== undefined) + defaults.mergeThinking = normalizeThinkingMode(raw.mergeThinking); return defaults; } @@ -380,7 +392,13 @@ function resolveGlobalPreferencesPathForCli() { if (agentDir) { return path.join(agentDir, GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME); } - return path.join(homedir(), ".pi", "agent", GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME); + return path.join( + homedir(), + ".pi", + "agent", + GLOBAL_PREFERENCES_SUBDIR, + GLOBAL_PREFERENCES_FILENAME, + ); } function writeGlobalPreferencesForCli(rawPrefs, prefsPath = resolveGlobalPreferencesPathForCli()) { @@ -452,7 +470,11 @@ function readGlobalPreferencesForCli() { function loadInitAgentDefaultsFromPreferences() { const { prefsPath, raw, wasBootstrapped } = readGlobalPreferencesForCli(); const defaults = sanitizeInitAgentConfig(raw.initAgentDefaults); - const hasDefaults = !!(raw.initAgentDefaults && typeof raw.initAgentDefaults === "object" && !Array.isArray(raw.initAgentDefaults)); + const hasDefaults = !!( + raw.initAgentDefaults && + typeof raw.initAgentDefaults === "object" && + !Array.isArray(raw.initAgentDefaults) + ); return { defaults, hasDefaults, prefsPath, wasBootstrapped }; } @@ -478,10 +500,13 @@ function findModelInDiscovery(models, modelRef) { if (!ref) return null; const provider = ref.provider.toLowerCase(); const id = ref.id.toLowerCase(); - return models.find((model) => - String(model?.provider ?? "").toLowerCase() === provider - && String(model?.id ?? "").toLowerCase() === id, - ) || null; + return ( + models.find( + (model) => + String(model?.provider ?? "").toLowerCase() === provider && + String(model?.id ?? "").toLowerCase() === id, + ) || null + ); } function allValuesEqual(values) { @@ -507,16 +532,24 @@ const INIT_AGENT_ROLES = [ { key: "merge", label: "Merger", modelKey: "mergeModel", thinkingKey: "mergeThinking" }, ]; -async function promptMenuChoice({ title, question, options, defaultIndex = 0, askImpl = ask, logImpl = console.log }) { +async function promptMenuChoice({ + title, + question, + options, + defaultIndex = 0, + askImpl = ask, + logImpl = console.log, +}) { while (true) { if (title) logImpl(`\n ${title}`); for (let i = 0; i < options.length; i++) { logImpl(` ${i + 1}. ${options[i].label}`); } - const resolvedDefault = Number.isInteger(defaultIndex) && defaultIndex >= 0 && defaultIndex < options.length - ? defaultIndex - : 0; + const resolvedDefault = + Number.isInteger(defaultIndex) && defaultIndex >= 0 && defaultIndex < options.length + ? defaultIndex + : 0; const answer = String(await askImpl(question, String(resolvedDefault + 1))).trim(); const asNum = Number.parseInt(answer, 10); if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= options.length) { @@ -536,13 +569,14 @@ async function promptMenuChoice({ title, question, options, defaultIndex = 0, as } } -async function promptModelForRole(roleLabel, models, { - askImpl = ask, - logImpl = console.log, - currentModel = "", - preferDifferentProviderFrom = "", -} = {}) { - const providers = [...new Set(models.map((model) => model.provider))].sort((a, b) => a.localeCompare(b)); +async function promptModelForRole( + roleLabel, + models, + { askImpl = ask, logImpl = console.log, currentModel = "", preferDifferentProviderFrom = "" } = {}, +) { + const providers = [...new Set(models.map((model) => model.provider))].sort((a, b) => + a.localeCompare(b), + ); const currentRef = splitModelReference(currentModel); while (true) { @@ -559,13 +593,17 @@ async function promptModelForRole(roleLabel, models, { ]; const providerDefaultIndex = (() => { if (currentRef) { - return Math.max(0, providerOptions.findIndex((option) => option.value === currentRef.provider)); + return Math.max( + 0, + providerOptions.findIndex((option) => option.value === currentRef.provider), + ); } if (preferDifferentProviderFrom) { - const preferred = providerOptions.findIndex((option) => - typeof option.value === "string" - && option.value !== "inherit" - && option.value !== preferDifferentProviderFrom + const preferred = providerOptions.findIndex( + (option) => + typeof option.value === "string" && + option.value !== "inherit" && + option.value !== preferDifferentProviderFrom, ); if (preferred >= 0) return preferred; } @@ -617,13 +655,16 @@ async function promptModelForRole(roleLabel, models, { } } -async function promptThinkingForRole(roleLabel, { - askImpl = ask, - logImpl = console.log, - currentThinking = "", - currentModel = "", - availableModels = [], -} = {}) { +async function promptThinkingForRole( + roleLabel, + { + askImpl = ask, + logImpl = console.log, + currentThinking = "", + currentModel = "", + availableModels = [], + } = {}, +) { const thinkingOptions = [ { value: "", label: "inherit (use current session thinking)", aliases: ["inherit"] }, { value: "off", label: "off" }, @@ -636,13 +677,20 @@ async function promptThinkingForRole(roleLabel, { const selectedModel = findModelInDiscovery(availableModels, currentModel); if (selectedModel?.supportsThinking === false) { - logImpl(` ${INFO} ${roleLabel} model does not advertise thinking support (pi says thinking=no).`); - logImpl(` ${c.dim}You can still set a thinking level; unsupported models ignore it at runtime.${c.reset}`); + logImpl( + ` ${INFO} ${roleLabel} model does not advertise thinking support (pi says thinking=no).`, + ); + logImpl( + ` ${c.dim}You can still set a thinking level; unsupported models ignore it at runtime.${c.reset}`, + ); } const normalized = normalizeThinkingMode(currentThinking); const preferredDefault = normalized || "high"; - const defaultIndex = Math.max(0, thinkingOptions.findIndex((option) => option.value === preferredDefault)); + const defaultIndex = Math.max( + 0, + thinkingOptions.findIndex((option) => option.value === preferredDefault), + ); return promptMenuChoice({ title: `${roleLabel}: choose thinking mode`, @@ -700,18 +748,28 @@ export async function collectInitAgentConfig({ const shouldPersistFromInit = shouldGuideCrossProvider; logImpl(`\n${c.bold}Agent model setup${c.reset}`); - logImpl(` ${c.dim}Choose models for worker/reviewer/merger (inherit is always option #1).${c.reset}`); + logImpl( + ` ${c.dim}Choose models for worker/reviewer/merger (inherit is always option #1).${c.reset}`, + ); if (canGuideCrossProvider) { - logImpl(` ${INFO} ${c.bold}First-run recommendation:${c.reset} choose reviewer/merger on a different provider than worker/session.`); - logImpl(` ${c.dim}Cross-provider review catches blind spots that same-model review can miss.${c.reset}`); + logImpl( + ` ${INFO} ${c.bold}First-run recommendation:${c.reset} choose reviewer/merger on a different provider than worker/session.`, + ); + logImpl( + ` ${c.dim}Cross-provider review catches blind spots that same-model review can miss.${c.reset}`, + ); } else if (shouldGuideCrossProvider) { logImpl(` ${INFO} Cross-provider guidance skipped: only one provider is currently available.`); - logImpl(` ${c.dim}Add another provider later to enable cross-provider reviewer/merger defaults.${c.reset}`); + logImpl( + ` ${c.dim}Add another provider later to enable cross-provider reviewer/merger defaults.${c.reset}`, + ); } const modelDefaults = INIT_AGENT_ROLES.map((role) => initAgentConfig[role.modelKey] || ""); - const thinkingDefaults = INIT_AGENT_ROLES.map((role) => normalizeThinkingMode(initAgentConfig[role.thinkingKey])); + const thinkingDefaults = INIT_AGENT_ROLES.map((role) => + normalizeThinkingMode(initAgentConfig[role.thinkingKey]), + ); const sameModelDefaults = allValuesEqual(modelDefaults); const sameThinkingDefaults = allValuesEqual(thinkingDefaults); let useSameModel = false; @@ -752,9 +810,8 @@ export async function collectInitAgentConfig({ let workerProviderHint = splitModelReference(initAgentConfig.workerModel)?.provider || ""; for (const role of INIT_AGENT_ROLES) { - const preferDifferentProviderFrom = canGuideCrossProvider && role.key !== "worker" - ? workerProviderHint - : ""; + const preferDifferentProviderFrom = + canGuideCrossProvider && role.key !== "worker" ? workerProviderHint : ""; initAgentConfig[role.modelKey] = await promptModelForRole(role.label, discovery.models, { askImpl, logImpl, @@ -762,7 +819,8 @@ export async function collectInitAgentConfig({ preferDifferentProviderFrom, }); if (role.key === "worker") { - workerProviderHint = splitModelReference(initAgentConfig[role.modelKey])?.provider || workerProviderHint; + workerProviderHint = + splitModelReference(initAgentConfig[role.modelKey])?.provider || workerProviderHint; } initAgentConfig[role.thinkingKey] = await promptThinkingForRole(role.label, { askImpl, @@ -824,9 +882,7 @@ export function generateProjectConfig(vars, _initAgentConfig = null) { function generateWorkspaceYaml(repoNames, defaultRepo, tasksRoot) { const normalizedTasksRoot = fwdSlash(tasksRoot); - const reposBlock = repoNames - .map((name) => ` ${name}:\n path: "${name}"`) - .join("\n"); + const reposBlock = repoNames.map((name) => ` ${name}:\n path: "${name}"`).join("\n"); return `repos:\n${reposBlock}\nrouting:\n tasks_root: "${normalizedTasksRoot}"\n default_repo: "${defaultRepo}"\n task_packet_repo: "${defaultRepo}"\n`; } @@ -876,7 +932,9 @@ async function autoCommitTaskFiles(projectRoot, tasksRoot) { } catch (err) { // Git commit failed — warn but don't block init console.log(`\n ${WARN} Could not auto-commit task files to git.`); - console.log(` ${c.dim}Run manually before using /orch: git add ${tasksRoot} && git commit -m "add taskplane tasks"${c.reset}`); + console.log( + ` ${c.dim}Run manually before using /orch: git add ${tasksRoot} && git commit -m "add taskplane tasks"${c.reset}`, + ); } } @@ -899,7 +957,9 @@ function discoverTaskAreaMetadata(projectRoot, configRoot = projectRoot, configP } return { paths: [...paths], contexts: [...contexts], areaRepoIds }; } - } catch { /* fall through to YAML */ } + } catch { + /* fall through to YAML */ + } } const runnerPath = path.join(configRoot, configPrefix, "task-runner.yaml"); @@ -978,7 +1038,8 @@ function pruneEmptyDir(dirPath) { function listExampleTaskTemplates() { const tasksTemplatesDir = path.join(TEMPLATES_DIR, "tasks"); try { - return fs.readdirSync(tasksTemplatesDir, { withFileTypes: true }) + return fs + .readdirSync(tasksTemplatesDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && /^EXAMPLE-\d+/i.test(entry.name)) .map((entry) => entry.name) .sort(); @@ -997,7 +1058,12 @@ function resolveProjectConfigJsonPath(projectRoot) { try { const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8")); if (pointer?.config_repo && pointer?.config_path) { - const pointedPath = path.resolve(projectRoot, pointer.config_repo, pointer.config_path, "taskplane-config.json"); + const pointedPath = path.resolve( + projectRoot, + pointer.config_repo, + pointer.config_path, + "taskplane-config.json", + ); if (fs.existsSync(pointedPath)) return pointedPath; } } catch { @@ -1024,7 +1090,9 @@ function cmdConfig(args) { console.log(`\n${c.bold}Taskplane Config${c.reset}\n`); console.log(` ${c.cyan}taskplane config --save-as-defaults${c.reset}`); console.log(` Save worker/reviewer/merger model + thinking settings from this project`); - console.log(` to ${c.cyan}${resolveGlobalPreferencesPathForCli()}${c.reset} for future ${c.cyan}taskplane init${c.reset} runs.\n`); + console.log( + ` to ${c.cyan}${resolveGlobalPreferencesPathForCli()}${c.reset} for future ${c.cyan}taskplane init${c.reset} runs.\n`, + ); return; } @@ -1047,16 +1115,23 @@ function cmdConfig(args) { console.log(`\n${OK} ${c.bold}Saved init defaults.${c.reset}`); console.log(` Source: ${c.cyan}${configPath}${c.reset}`); console.log(` Target: ${c.cyan}${prefsPath}${c.reset}`); - console.log(` worker: ${saved.workerModel || "inherit"} (${saved.workerThinking || "inherit"})`); - console.log(` reviewer: ${saved.reviewerModel || "inherit"} (${saved.reviewerThinking || "inherit"})`); - console.log(` merger: ${saved.mergeModel || "inherit"} (${saved.mergeThinking || "inherit"})\n`); + console.log( + ` worker: ${saved.workerModel || "inherit"} (${saved.workerThinking || "inherit"})`, + ); + console.log( + ` reviewer: ${saved.reviewerModel || "inherit"} (${saved.reviewerThinking || "inherit"})`, + ); + console.log( + ` merger: ${saved.mergeModel || "inherit"} (${saved.mergeThinking || "inherit"})\n`, + ); } async function cmdUninstall(args) { const projectRoot = process.cwd(); const dryRun = args.includes("--dry-run"); const yes = args.includes("--yes") || args.includes("-y"); - const removePackage = args.includes("--package") || args.includes("--all") || args.includes("--package-only"); + const removePackage = + args.includes("--package") || args.includes("--all") || args.includes("--package-only"); const packageOnly = args.includes("--package-only"); const removeProject = !packageOnly; const removeTasks = removeProject && (args.includes("--remove-tasks") || args.includes("--all")); @@ -1083,22 +1158,18 @@ async function cmdUninstall(args) { ".pi/orch-abort-signal", ]; - const sidecarPrefixes = [ - "lane-state-", - "worker-conversation-", - "merge-result-", - "merge-request-", - ]; + const sidecarPrefixes = ["lane-state-", "worker-conversation-", "merge-result-", "merge-request-"]; const filesToDelete = managedFiles - .map(rel => ({ rel, abs: path.join(projectRoot, rel) })) + .map((rel) => ({ rel, abs: path.join(projectRoot, rel) })) .filter(({ abs }) => fs.existsSync(abs)); const piDir = path.join(projectRoot, ".pi"); const sidecarsToDelete = fs.existsSync(piDir) - ? fs.readdirSync(piDir) - .filter(name => sidecarPrefixes.some(prefix => name.startsWith(prefix))) - .map(name => ({ rel: path.join(".pi", name), abs: path.join(piDir, name) })) + ? fs + .readdirSync(piDir) + .filter((name) => sidecarPrefixes.some((prefix) => name.startsWith(prefix))) + .map((name) => ({ rel: path.join(".pi", name), abs: path.join(piDir, name) })) : []; let taskDirsToDelete = []; @@ -1106,27 +1177,34 @@ async function cmdUninstall(args) { const areaPaths = discoverTaskAreaPaths(projectRoot); const rootPrefix = path.resolve(projectRoot) + path.sep; taskDirsToDelete = areaPaths - .map(rel => ({ rel, abs: path.resolve(projectRoot, rel) })) + .map((rel) => ({ rel, abs: path.resolve(projectRoot, rel) })) .filter(({ abs }) => abs.startsWith(rootPrefix) && fs.existsSync(abs)); } const inferredInstallType = inferTaskplaneInstallScope(); const packageScope = local ? "local" : global ? "global" : inferredInstallType; - const piRemoveCmd = packageScope === "local" - ? "pi remove -l npm:taskplane" - : "pi remove npm:taskplane"; + const piRemoveCmd = + packageScope === "local" ? "pi remove -l npm:taskplane" : "pi remove npm:taskplane"; if (!removeProject && !removePackage) { console.log(` ${WARN} Nothing to do. Use one of:`); - console.log(` ${c.cyan}taskplane uninstall${c.reset} # remove project-scaffolded files`); - console.log(` ${c.cyan}taskplane uninstall --package${c.reset} # remove installed package via pi`); + console.log( + ` ${c.cyan}taskplane uninstall${c.reset} # remove project-scaffolded files`, + ); + console.log( + ` ${c.cyan}taskplane uninstall --package${c.reset} # remove installed package via pi`, + ); console.log(); return; } if (removeProject) { console.log(`${c.bold}Project cleanup:${c.reset}`); - if (filesToDelete.length === 0 && sidecarsToDelete.length === 0 && taskDirsToDelete.length === 0) { + if ( + filesToDelete.length === 0 && + sidecarsToDelete.length === 0 && + taskDirsToDelete.length === 0 + ) { console.log(` ${c.dim}No Taskplane-managed project files found.${c.reset}`); } for (const f of filesToDelete) console.log(` - remove ${f.rel}`); @@ -1136,7 +1214,9 @@ async function cmdUninstall(args) { console.log(` ${c.dim}No task area directories found in config.${c.reset}`); } if (!removeTasks) { - console.log(` ${c.dim}Task directories are preserved by default (use --remove-tasks to delete them).${c.reset}`); + console.log( + ` ${c.dim}Task directories are preserved by default (use --remove-tasks to delete them).${c.reset}`, + ); } console.log(); } @@ -1144,7 +1224,9 @@ async function cmdUninstall(args) { if (removePackage) { console.log(`${c.bold}Package cleanup:${c.reset}`); console.log(` - run ${piRemoveCmd}`); - console.log(` ${c.dim}(removes extensions, skills, and dashboard files from this install scope)${c.reset}`); + console.log( + ` ${c.dim}(removes extensions, skills, and dashboard files from this install scope)${c.reset}`, + ); console.log(); } @@ -1160,7 +1242,10 @@ async function cmdUninstall(args) { return; } if (removeTasks) { - const taskConfirm = await confirm("This will delete task area directories recursively. Continue?", false); + const taskConfirm = await confirm( + "This will delete task area directories recursively. Continue?", + false, + ); if (!taskConfirm) { console.log(" Aborted."); return; @@ -1246,7 +1331,7 @@ function ensureGitignoreEntries(projectRoot, { dryRun = false, prefix = "" } = { const gitignorePath = path.join(projectRoot, ".gitignore"); const fileExists = fs.existsSync(gitignorePath); const existingContent = fileExists ? fs.readFileSync(gitignorePath, "utf-8") : ""; - const existingLines = new Set(existingContent.split(/\r?\n/).map(l => l.trim())); + const existingLines = new Set(existingContent.split(/\r?\n/).map((l) => l.trim())); const allEntries = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES]; const added = []; @@ -1267,15 +1352,13 @@ function ensureGitignoreEntries(projectRoot, { dryRun = false, prefix = "" } = { if (!dryRun) { // Build the block of new entries with headers - const runtimeAdded = added.filter(e => !e.endsWith("npm/")); - const npmAdded = added.filter(e => e.endsWith("npm/")); + const runtimeAdded = added.filter((e) => !e.endsWith("npm/")); + const npmAdded = added.filter((e) => e.endsWith("npm/")); const newLines = []; if (runtimeAdded.length > 0) { // Only add header if it's not already present - const headerToCheck = prefix - ? TASKPLANE_GITIGNORE_HEADER - : TASKPLANE_GITIGNORE_HEADER; + const headerToCheck = prefix ? TASKPLANE_GITIGNORE_HEADER : TASKPLANE_GITIGNORE_HEADER; if (!existingLines.has(headerToCheck)) { newLines.push(TASKPLANE_GITIGNORE_HEADER); } @@ -1321,16 +1404,17 @@ function ensureGitignoreEntries(projectRoot, { dryRun = false, prefix = "" } = { * @param {boolean} options.interactive - If false, skip prompt and don't untrack * @param {string} options.prefix - Path prefix for workspace-scoped scanning (e.g., ".taskplane/") */ -async function detectAndOfferUntrackArtifacts(projectRoot, { dryRun = false, interactive = true, prefix = "" } = {}) { +async function detectAndOfferUntrackArtifacts( + projectRoot, + { dryRun = false, interactive = true, prefix = "" } = {}, +) { // Only run in a git repo if (!isInsideGitRepo(projectRoot)) return { found: [], untracked: false }; // Get list of tracked files under the relevant directories // For workspace mode (prefix=".taskplane/"), scan .taskplane/.pi/ and .taskplane/.worktrees/ // For repo mode (no prefix), scan .pi/ and .worktrees/ - const scanDirs = prefix - ? [`${prefix}.pi/`, `${prefix}.worktrees/`] - : [".pi/", ".worktrees/"]; + const scanDirs = prefix ? [`${prefix}.pi/`, `${prefix}.worktrees/`] : [".pi/", ".worktrees/"]; let trackedFiles; try { @@ -1338,7 +1422,9 @@ async function detectAndOfferUntrackArtifacts(projectRoot, { dryRun = false, int cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"], timeout: 10000, - }).toString().trim(); + }) + .toString() + .trim(); trackedFiles = raw ? raw.split(/\r?\n/) : []; } catch { return { found: [], untracked: false }; @@ -1348,13 +1434,13 @@ async function detectAndOfferUntrackArtifacts(projectRoot, { dryRun = false, int // Build regex patterns for matching (with prefix if workspace-scoped) const prefixedPatterns = prefix - ? ALL_GITIGNORE_PATTERNS.map(p => `${prefix}${p}`) + ? ALL_GITIGNORE_PATTERNS.map((p) => `${prefix}${p}`) : ALL_GITIGNORE_PATTERNS; - const patterns = prefixedPatterns.map(p => patternToRegex(p)); + const patterns = prefixedPatterns.map((p) => patternToRegex(p)); // Find tracked files that match runtime artifact patterns - const matchedFiles = trackedFiles.filter(file => { - return patterns.some(regex => regex.test(file)); + const matchedFiles = trackedFiles.filter((file) => { + return patterns.some((regex) => regex.test(file)); }); if (matchedFiles.length === 0) return { found: [], untracked: false }; @@ -1434,7 +1520,9 @@ function isGitRepoRoot(dir) { cwd: dir, stdio: ["pipe", "pipe", "pipe"], timeout: 5000, - }).toString().trim(); + }) + .toString() + .trim(); // Normalize paths for comparison (handles Windows path separators // and 8.3 short name mismatches on Windows) const normalizedToplevel = path.resolve(toplevel); @@ -1442,7 +1530,9 @@ function isGitRepoRoot(dir) { // On Windows, fs.realpathSync.native resolves 8.3 short names to // long names, matching what git returns. Without this, paths like // C:\Users\HENRYL~1\... won't match C:\Users\HenryLach\... - try { normalizedDir = fs.realpathSync.native(normalizedDir); } catch {} + try { + normalizedDir = fs.realpathSync.native(normalizedDir); + } catch {} return normalizedToplevel === normalizedDir; } catch { return false; @@ -1548,9 +1638,7 @@ function detectInitMode(dir) { mode: "workspace", subRepos, alreadyInitialized: existingConfigRepo !== null, - existingConfigPath: existingConfigRepo - ? path.join(dir, existingConfigRepo, ".taskplane") - : null, + existingConfigPath: existingConfigRepo ? path.join(dir, existingConfigRepo, ".taskplane") : null, }; } @@ -1593,7 +1681,11 @@ async function cmdInit(args) { if (path.isAbsolute(tasksRootRaw)) { die("--tasks-root must be relative to the project root (absolute paths are not allowed)."); } - tasksRootOverride = tasksRootRaw.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, ""); + tasksRootOverride = tasksRootRaw + .trim() + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); if (!tasksRootOverride || tasksRootOverride === ".") { die("--tasks-root must not be empty."); } @@ -1607,7 +1699,9 @@ async function cmdInit(args) { console.log(`\n${c.bold}Taskplane Init${c.reset}\n`); if (tasksRootOverride && !noExamplesFlag && !includeExamples) { - console.log(` ${INFO} Using custom --tasks-root (${tasksRootOverride}); skipping example tasks by default.`); + console.log( + ` ${INFO} Using custom --tasks-root (${tasksRootOverride}); skipping example tasks by default.`, + ); console.log(` Use --include-examples to scaffold examples into that directory.\n`); } @@ -1619,8 +1713,8 @@ async function cmdInit(args) { if (detection.mode === "error") { die( "Not a git repo and no git repos found in subdirectories.\n" + - " Run from inside a git repository, or from a workspace root\n" + - " that contains git repositories as subdirectories." + " Run from inside a git repository, or from a workspace root\n" + + " that contains git repositories as subdirectories.", ); } @@ -1631,14 +1725,16 @@ async function cmdInit(args) { // Non-interactive: default to repo mode (safe default, no prompt) resolvedMode = "repo"; console.log(` ${INFO} Ambiguous layout detected (git repo with git repo subdirectories).`); - console.log(` Defaulting to ${c.cyan}repo mode${c.reset} (use interactive mode for workspace).\n`); + console.log( + ` Defaulting to ${c.cyan}repo mode${c.reset} (use interactive mode for workspace).\n`, + ); } else { // Interactive: prompt the user console.log(` ${WARN} This directory is a git repo AND contains git repos as subdirectories.`); console.log(` Subdirectory repos found: ${detection.subRepos.join(", ")}\n`); const modeChoice = await ask( "Mode: (r)epo — treat as single monorepo, or (w)orkspace — treat subdirs as independent repos", - "r" + "r", ); resolvedMode = modeChoice.toLowerCase().startsWith("w") ? "workspace" : "repo"; console.log(); @@ -1659,7 +1755,9 @@ async function cmdInit(args) { // Scenario B: existing monorepo config — block reinit unless --force if (effectiveAlreadyInitialized && !force && resolvedMode === "repo") { console.log(` ${INFO} Project already initialized (config exists in .pi/).`); - console.log(` Run ${c.cyan}taskplane doctor${c.reset} to verify, or use ${c.cyan}--force${c.reset} to reinitialize.\n`); + console.log( + ` Run ${c.cyan}taskplane doctor${c.reset} to verify, or use ${c.cyan}--force${c.reset} to reinitialize.\n`, + ); return; } @@ -1690,23 +1788,30 @@ async function cmdInit(args) { } catch {} return null; })(); - const workspaceTasksRoot = (existingWorkspaceJson?.routing?.tasks_root - || existingRootYaml?.routing?.tasks_root - || "taskplane-tasks").replace(/\\/g, "/"); - const workspaceDefaultRepo = existingWorkspaceJson?.routing?.default_repo - || existingRootYaml?.routing?.default_repo - || configRepo; + const workspaceTasksRoot = ( + existingWorkspaceJson?.routing?.tasks_root || + existingRootYaml?.routing?.tasks_root || + "taskplane-tasks" + ).replace(/\\/g, "/"); + const workspaceDefaultRepo = + existingWorkspaceJson?.routing?.default_repo || + existingRootYaml?.routing?.default_repo || + configRepo; const workspaceRepoNames = Array.from( new Set([ ...detection.subRepos, - ...((Array.isArray(existingWorkspaceJson?.repos) ? existingWorkspaceJson.repos : []) + ...(Array.isArray(existingWorkspaceJson?.repos) ? existingWorkspaceJson.repos : []) .map((repo) => repo?.name) - .filter(Boolean)), + .filter(Boolean), ]), ).sort(); - console.log(` ${c.dim}Mode: workspace (${detection.subRepos.length} git repositories found)${c.reset}`); - console.log(` ${INFO} Found existing Taskplane config in ${c.cyan}${configRepo}/.taskplane/${c.reset}`); + console.log( + ` ${c.dim}Mode: workspace (${detection.subRepos.length} git repositories found)${c.reset}`, + ); + console.log( + ` ${INFO} Found existing Taskplane config in ${c.cyan}${configRepo}/.taskplane/${c.reset}`, + ); console.log(` Using existing configuration.\n`); // ── Pointer idempotency ───────────────────────────────── @@ -1739,16 +1844,27 @@ async function cmdInit(args) { // Malformed pointer file — treat as invalid, will be overwritten console.log(` ${WARN} .pi/taskplane-pointer.json exists but is malformed — will overwrite.`); } - if (existingPointer && existingPointer.config_repo === configRepo && existingPointer.config_path === ".taskplane") { - console.log(` ${c.dim}skip${c.reset} .pi/taskplane-pointer.json (already points to ${configRepo}/.taskplane/)`); + if ( + existingPointer && + existingPointer.config_repo === configRepo && + existingPointer.config_path === ".taskplane" + ) { + console.log( + ` ${c.dim}skip${c.reset} .pi/taskplane-pointer.json (already points to ${configRepo}/.taskplane/)`, + ); console.log(`\n${OK} ${c.bold}Workspace already configured.${c.reset}`); console.log(` Run ${c.cyan}taskplane doctor${c.reset} to verify.\n`); return; } // Pointer exists but points elsewhere (or was malformed) — prompt to overwrite if (existingPointer && !isPreset) { - console.log(` ${WARN} .pi/taskplane-pointer.json already exists (points to ${existingPointer.config_repo}/.taskplane/).`); - const proceed = await confirm(" Update pointer to point to " + configRepo + "/.taskplane/?", true); + console.log( + ` ${WARN} .pi/taskplane-pointer.json already exists (points to ${existingPointer.config_repo}/.taskplane/).`, + ); + const proceed = await confirm( + " Update pointer to point to " + configRepo + "/.taskplane/?", + true, + ); if (!proceed) { console.log(" Aborted."); return; @@ -1762,11 +1878,9 @@ async function cmdInit(args) { config_repo: configRepo, config_path: ".taskplane", }; - writeFile( - pointerPath, - JSON.stringify(pointer, null, 2) + "\n", - { label: ".pi/taskplane-pointer.json" } - ); + writeFile(pointerPath, JSON.stringify(pointer, null, 2) + "\n", { + label: ".pi/taskplane-pointer.json", + }); writeFile( workspaceYamlPath, @@ -1776,11 +1890,16 @@ async function cmdInit(args) { // ── Gitignore enforcement in config repo (Scenario D) ─── // Ensure .gitignore exists even when reusing existing config - const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { dryRun: false, prefix: ".taskplane/" }); + const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { + dryRun: false, + prefix: ".taskplane/", + }); if (gitignoreResult.created) { console.log(` ${c.green}create${c.reset} ${configRepo}/.gitignore`); } else if (gitignoreResult.added.length > 0) { - console.log(` ${c.green}update${c.reset} ${configRepo}/.gitignore (${gitignoreResult.added.length} entries added)`); + console.log( + ` ${c.green}update${c.reset} ${configRepo}/.gitignore (${gitignoreResult.added.length} entries added)`, + ); } console.log(`\n${OK} ${c.bold}Workspace pointer created.${c.reset}\n`); @@ -1788,8 +1907,12 @@ async function cmdInit(args) { console.log(` Pointer: ${c.cyan}.pi/taskplane-pointer.json${c.reset}`); console.log(` Workspace config: ${c.cyan}.pi/taskplane-workspace.yaml${c.reset}\n`); console.log(`${c.bold}Quick start:${c.reset}`); - console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`); - console.log(` ${c.cyan}taskplane doctor${c.reset} # verify setup`); + console.log( + ` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`, + ); + console.log( + ` ${c.cyan}taskplane doctor${c.reset} # verify setup`, + ); console.log(); return; } @@ -1798,7 +1921,9 @@ async function cmdInit(args) { if (resolvedMode === "repo") { console.log(` ${c.dim}Mode: repo (standard monorepo)${c.reset}`); } else if (resolvedMode === "workspace") { - console.log(` ${c.dim}Mode: workspace (${detection.subRepos.length} git repositories found)${c.reset}`); + console.log( + ` ${c.dim}Mode: workspace (${detection.subRepos.length} git repositories found)${c.reset}`, + ); } console.log(); @@ -1813,7 +1938,9 @@ async function cmdInit(args) { if (isPreset || dryRun) { // Non-interactive: pick first repo alphabetically as default configRepoName = detection.subRepos[0]; - console.log(` ${INFO} Using ${c.cyan}${configRepoName}${c.reset} as config repo (first alphabetically).\n`); + console.log( + ` ${INFO} Using ${c.cyan}${configRepoName}${c.reset} as config repo (first alphabetically).\n`, + ); } else { // Interactive: prompt user to choose config repo console.log(` Which repo should hold Taskplane config?`); @@ -1821,10 +1948,7 @@ async function cmdInit(args) { console.log(` ${c.dim}${i + 1}.${c.reset} ${detection.subRepos[i]}`); } console.log(); - const configRepoAnswer = await ask( - "Config repo (name or number)", - detection.subRepos[0] - ); + const configRepoAnswer = await ask("Config repo (name or number)", detection.subRepos[0]); // Accept numeric index or repo name const asNum = parseInt(configRepoAnswer, 10); if (asNum >= 1 && asNum <= detection.subRepos.length) { @@ -1873,7 +1997,14 @@ async function cmdInit(args) { // ── Dry-run: show what would be created ───────────────────── if (dryRun) { console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`); - printWorkspaceFileList(vars, noExamples, preset, exampleTemplateDirs, configRepoName, configRepoRoot); + printWorkspaceFileList( + vars, + noExamples, + preset, + exampleTemplateDirs, + configRepoName, + configRepoRoot, + ); console.log(` ${c.green}create${c.reset} .pi/taskplane-pointer.json`); console.log(` ${c.green}create${c.reset} .pi/taskplane-workspace.yaml`); console.log(); @@ -1894,7 +2025,7 @@ async function cmdInit(args) { copyTemplate( path.join(TEMPLATES_DIR, "agents", "local", agent), path.join(taskplaneDir, "agents", agent), - { skipIfExists, label: `${configRepoName}/.taskplane/agents/${agent}` } + { skipIfExists, label: `${configRepoName}/.taskplane/agents/${agent}` }, ); } @@ -1903,7 +2034,7 @@ async function cmdInit(args) { writeFile( path.join(taskplaneDir, "taskplane-config.json"), JSON.stringify(projectConfig, null, 2) + "\n", - { skipIfExists, label: `${configRepoName}/.taskplane/taskplane-config.json` } + { skipIfExists, label: `${configRepoName}/.taskplane/taskplane-config.json` }, ); // Version tracker (always overwrite) @@ -1916,12 +2047,12 @@ async function cmdInit(args) { writeFile( path.join(taskplaneDir, "taskplane.json"), JSON.stringify(versionInfo, null, 2) + "\n", - { label: `${configRepoName}/.taskplane/taskplane.json` } + { label: `${configRepoName}/.taskplane/taskplane.json` }, ); // Workspace definition (workspace.json) const workspaceConfig = { - repos: detection.subRepos.map(name => ({ + repos: detection.subRepos.map((name) => ({ name, path: `../${name}`, default_branch: "main", @@ -1935,7 +2066,7 @@ async function cmdInit(args) { writeFile( path.join(taskplaneDir, "workspace.json"), JSON.stringify(workspaceConfig, null, 2) + "\n", - { skipIfExists, label: `${configRepoName}/.taskplane/workspace.json` } + { skipIfExists, label: `${configRepoName}/.taskplane/workspace.json` }, ); // CONTEXT.md — tasks area context @@ -1946,11 +2077,10 @@ async function cmdInit(args) { : vars.tasks_root; const tasksDir = path.join(configRepoRoot, tasksRootInRepo); const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8"); - writeFile( - path.join(tasksDir, "CONTEXT.md"), - interpolate(contextSrc, vars), - { skipIfExists, label: `${configRepoName}/${vars.tasks_root}/CONTEXT.md` } - ); + writeFile(path.join(tasksDir, "CONTEXT.md"), interpolate(contextSrc, vars), { + skipIfExists, + label: `${configRepoName}/${vars.tasks_root}/CONTEXT.md`, + }); // Example tasks if (!noExamples) { @@ -1976,19 +2106,30 @@ async function cmdInit(args) { // Use .taskplane/ prefix so patterns apply within the config repo's // .taskplane/ directory (e.g., ".taskplane/.pi/batch-state.json") // Per spec: standard .pi/ patterns + .worktrees/ in config repo root - const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { dryRun: false, prefix: ".taskplane/" }); + const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { + dryRun: false, + prefix: ".taskplane/", + }); if (gitignoreResult.created) { console.log(` ${c.green}create${c.reset} ${configRepoName}/.gitignore`); } else if (gitignoreResult.added.length > 0) { - console.log(` ${c.green}update${c.reset} ${configRepoName}/.gitignore (${gitignoreResult.added.length} entries added)`); + console.log( + ` ${c.green}update${c.reset} ${configRepoName}/.gitignore (${gitignoreResult.added.length} entries added)`, + ); } else { - console.log(` ${c.dim}skip${c.reset} ${configRepoName}/.gitignore (all entries already present)`); + console.log( + ` ${c.dim}skip${c.reset} ${configRepoName}/.gitignore (all entries already present)`, + ); } // Check for tracked runtime artifacts in config repo (workspace-scoped) const wsIsInteractive = !isPreset && !dryRun; - await detectAndOfferUntrackArtifacts(configRepoRoot, { dryRun: false, interactive: wsIsInteractive, prefix: ".taskplane/" }); + await detectAndOfferUntrackArtifacts(configRepoRoot, { + dryRun: false, + interactive: wsIsInteractive, + prefix: ".taskplane/", + }); // ── Pointer file in workspace root .pi/ ───────────────────── const pointer = { @@ -1998,7 +2139,7 @@ async function cmdInit(args) { writeFile( path.join(projectRoot, ".pi", "taskplane-pointer.json"), JSON.stringify(pointer, null, 2) + "\n", - { label: ".pi/taskplane-pointer.json" } + { label: ".pi/taskplane-pointer.json" }, ); writeFile( path.join(projectRoot, ".pi", "taskplane-workspace.yaml"), @@ -2010,19 +2151,24 @@ async function cmdInit(args) { await autoCommitTaskFiles(configRepoRoot, vars.tasks_root); // Also stage and commit .taskplane/ directory and .gitignore try { - execSync('git add .taskplane/ .gitignore', { cwd: configRepoRoot, stdio: "pipe" }); + execSync("git add .taskplane/ .gitignore", { cwd: configRepoRoot, stdio: "pipe" }); const status = execSync("git diff --cached --name-only", { cwd: configRepoRoot, stdio: "pipe" }) - .toString().trim(); + .toString() + .trim(); if (status) { execSync('git commit -m "chore: initialize taskplane workspace config"', { cwd: configRepoRoot, stdio: "pipe", }); - console.log(`\n ${c.green}git${c.reset} committed .taskplane/ and .gitignore to ${configRepoName}`); + console.log( + `\n ${c.green}git${c.reset} committed .taskplane/ and .gitignore to ${configRepoName}`, + ); } } catch (err) { console.log(`\n ${WARN} Could not auto-commit .taskplane/ to ${configRepoName}.`); - console.log(` ${c.dim}Run manually: cd ${configRepoName} && git add .taskplane/ .gitignore && git commit -m "add taskplane config"${c.reset}`); + console.log( + ` ${c.dim}Run manually: cd ${configRepoName} && git add .taskplane/ .gitignore && git commit -m "add taskplane config"${c.reset}`, + ); } // ── Post-init guidance ────────────────────────────────────── @@ -2030,16 +2176,26 @@ async function cmdInit(args) { console.log(` Config repo: ${c.cyan}${configRepoName}/.taskplane/${c.reset}`); console.log(` Pointer: ${c.cyan}.pi/taskplane-pointer.json${c.reset}`); console.log(` Workspace: ${c.cyan}.pi/taskplane-workspace.yaml${c.reset}\n`); - console.log(` ${WARN} ${c.bold}Important:${c.reset} merge these changes to your default branch (e.g., ${c.cyan}develop${c.reset})`); + console.log( + ` ${WARN} ${c.bold}Important:${c.reset} merge these changes to your default branch (e.g., ${c.cyan}develop${c.reset})`, + ); console.log(` before other team members run ${c.cyan}taskplane init${c.reset}.\n`); console.log(` cd ${configRepoName}`); console.log(` git push && ${c.dim}[create PR / merge to default branch]${c.reset}\n`); console.log(`${c.bold}Quick start:${c.reset}`); - console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`); - console.log(` ${c.cyan}/orch${c.reset} # start the taskplane supervisor`); - console.log(` ${c.cyan}/orch all${c.reset} # run all open tasks`); + console.log( + ` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`, + ); + console.log( + ` ${c.cyan}/orch${c.reset} # start the taskplane supervisor`, + ); + console.log( + ` ${c.cyan}/orch all${c.reset} # run all open tasks`, + ); if (inferTaskplaneInstallScope() === "global") { - console.log(` ${c.cyan}taskplane config --save-as-defaults${c.reset} # save these agent defaults for future inits`); + console.log( + ` ${c.cyan}taskplane config --save-as-defaults${c.reset} # save these agent defaults for future inits`, + ); } console.log(); return; @@ -2101,7 +2257,7 @@ async function cmdInit(args) { copyTemplate( path.join(TEMPLATES_DIR, "agents", "local", agent), path.join(projectRoot, ".pi", "agents", agent), - { skipIfExists, label: `.pi/agents/${agent}` } + { skipIfExists, label: `.pi/agents/${agent}` }, ); } @@ -2122,16 +2278,15 @@ async function cmdInit(args) { writeFile( path.join(projectRoot, ".pi", "taskplane.json"), JSON.stringify(versionInfo, null, 2) + "\n", - { label: ".pi/taskplane.json" } + { label: ".pi/taskplane.json" }, ); // CONTEXT.md const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8"); - writeFile( - path.join(projectRoot, vars.tasks_root, "CONTEXT.md"), - interpolate(contextSrc, vars), - { skipIfExists, label: `${vars.tasks_root}/CONTEXT.md` } - ); + writeFile(path.join(projectRoot, vars.tasks_root, "CONTEXT.md"), interpolate(contextSrc, vars), { + skipIfExists, + label: `${vars.tasks_root}/CONTEXT.md`, + }); // Example tasks if (!noExamples) { @@ -2164,7 +2319,9 @@ async function cmdInit(args) { if (gitignoreResult.created) { console.log(` ${c.green}create${c.reset} .gitignore`); } else if (gitignoreResult.added.length > 0) { - console.log(` ${c.green}update${c.reset} .gitignore (${gitignoreResult.added.length} entries added)`); + console.log( + ` ${c.green}update${c.reset} .gitignore (${gitignoreResult.added.length} entries added)`, + ); } else { console.log(` ${c.dim}skip${c.reset} .gitignore (all entries already present)`); } @@ -2179,11 +2336,19 @@ async function cmdInit(args) { // Report console.log(`\n${OK} ${c.bold}Taskplane initialized!${c.reset}\n`); console.log(`${c.bold}Quick start:${c.reset}`); - console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`); - console.log(` ${c.cyan}/orch${c.reset} # start the taskplane supervisor`); - console.log(` ${c.cyan}/orch all${c.reset} # run all open tasks`); + console.log( + ` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`, + ); + console.log( + ` ${c.cyan}/orch${c.reset} # start the taskplane supervisor`, + ); + console.log( + ` ${c.cyan}/orch all${c.reset} # run all open tasks`, + ); if (inferTaskplaneInstallScope() === "global") { - console.log(` ${c.cyan}taskplane config --save-as-defaults${c.reset} # save these agent defaults for future inits`); + console.log( + ` ${c.cyan}taskplane config --save-as-defaults${c.reset} # save these agent defaults for future inits`, + ); } console.log(); } @@ -2214,12 +2379,22 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) { const project_name = await ask("Project name", dirName); const maxLanesInput = await ask("Max parallel lanes", "3"); const max_lanes = parseInt(maxLanesInput, 10) || 3; - const tasks_root_raw = tasksRootOverride || await ask("Tasks directory", "taskplane-tasks"); - const tasks_root = tasks_root_raw.trim().replace(/\\/g, "/").replace(/^\.\//g, "").replace(/\/+$/g, ""); + const tasks_root_raw = tasksRootOverride || (await ask("Tasks directory", "taskplane-tasks")); + const tasks_root = tasks_root_raw + .trim() + .replace(/\\/g, "/") + .replace(/^\.\//g, "") + .replace(/\/+$/g, ""); const default_area = await ask("Default area name", "general"); const default_prefix = await ask("Task ID prefix", "TP"); - const test_cmd = await ask("Test command (agents run this to verify work — blank to skip)", detected.test || ""); - const build_cmd = await ask("Build command (agents run this after tests — blank to skip)", detected.build || ""); + const test_cmd = await ask( + "Test command (agents run this to verify work — blank to skip)", + detected.test || "", + ); + const build_cmd = await ask( + "Build command (agents run this after tests — blank to skip)", + detected.build || "", + ); const slug = slugify(project_name); const explicit_orchestrator_overrides = {}; @@ -2265,7 +2440,9 @@ function printFileList(vars, noExamples, preset, exampleTemplateDirs = [], proje const gitignoreResult = ensureGitignoreEntries(projectRoot, { dryRun: true }); if (gitignoreResult.added.length > 0) { const action = fs.existsSync(path.join(projectRoot, ".gitignore")) ? "update" : "create"; - console.log(` ${c.green}${action}${c.reset} .gitignore (${gitignoreResult.added.length} entries)`); + console.log( + ` ${c.green}${action}${c.reset} .gitignore (${gitignoreResult.added.length} entries)`, + ); } else { console.log(` ${c.dim}skip${c.reset} .gitignore (all entries already present)`); } @@ -2278,7 +2455,14 @@ function printFileList(vars, noExamples, preset, exampleTemplateDirs = [], proje * Print the list of files that would be created for workspace mode (dry-run). * Similar to printFileList but paths are scoped to /.taskplane/. */ -function printWorkspaceFileList(vars, noExamples, preset, exampleTemplateDirs, configRepoName, configRepoRoot) { +function printWorkspaceFileList( + vars, + noExamples, + preset, + exampleTemplateDirs, + configRepoName, + configRepoRoot, +) { const prefix = `${configRepoName}/.taskplane`; const files = [ `${prefix}/agents/task-worker.md`, @@ -2299,12 +2483,19 @@ function printWorkspaceFileList(vars, noExamples, preset, exampleTemplateDirs, c for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`); // Show gitignore entries that would be added to config repo (workspace-scoped) - const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { dryRun: true, prefix: ".taskplane/" }); + const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { + dryRun: true, + prefix: ".taskplane/", + }); if (gitignoreResult.added.length > 0) { const action = fs.existsSync(path.join(configRepoRoot, ".gitignore")) ? "update" : "create"; - console.log(` ${c.green}${action}${c.reset} ${configRepoName}/.gitignore (${gitignoreResult.added.length} entries)`); + console.log( + ` ${c.green}${action}${c.reset} ${configRepoName}/.gitignore (${gitignoreResult.added.length} entries)`, + ); } else { - console.log(` ${c.dim}skip${c.reset} ${configRepoName}/.gitignore (all entries already present)`); + console.log( + ` ${c.dim}skip${c.reset} ${configRepoName}/.gitignore (all entries already present)`, + ); } } @@ -2463,7 +2654,7 @@ function loadWorkspaceConfigForDoctor(projectRoot) { function parseWorkspaceYaml(raw) { const lines = raw.split(/\r?\n/); const result = { repos: {}, routing: {} }; - let section = null; // "repos" | "routing" | null + let section = null; // "repos" | "routing" | null let currentRepoId = null; // current repo being parsed for (const line of lines) { @@ -2594,7 +2785,9 @@ function cmdDoctor() { const pkgVersion = getPackageVersion(); const isProjectLocal = PACKAGE_ROOT.includes(".pi"); const installType = isProjectLocal ? "project-local" : "global"; - console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`); + console.log( + ` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`, + ); if (isWorkspaceMode) { console.log(); @@ -2603,7 +2796,9 @@ function cmdDoctor() { const codeHint = wsResult.error.code ? ` [${wsResult.error.code}]` : ""; console.log(` ${FAIL} workspace mode detected but config is invalid${codeHint}`); console.log(` ${c.dim}${wsResult.error.message}${c.reset}`); - console.log(` ${c.dim}→ Fix .pi/taskplane-workspace.yaml or remove it to use repo mode${c.reset}`); + console.log( + ` ${c.dim}→ Fix .pi/taskplane-workspace.yaml or remove it to use repo mode${c.reset}`, + ); issues++; } else { // Valid workspace config — show summary banner @@ -2612,7 +2807,9 @@ function cmdDoctor() { const repoCount = repoIds.length; const defaultRepo = cfg.routing.defaultRepo; const tasksRoot = cfg.routing.tasksRoot; - console.log(` ${OK} workspace mode ${c.dim}(${repoCount} repo${repoCount !== 1 ? "s" : ""}, default: ${defaultRepo})${c.reset}`); + console.log( + ` ${OK} workspace mode ${c.dim}(${repoCount} repo${repoCount !== 1 ? "s" : ""}, default: ${defaultRepo})${c.reset}`, + ); console.log(` ${c.dim}repos: ${repoIds.join(", ")}${c.reset}`); console.log(` ${c.dim}tasks_root: ${tasksRoot}${c.reset}`); } @@ -2628,22 +2825,32 @@ function cmdDoctor() { let pointer = null; if (!fs.existsSync(pointerPath)) { console.log(` ${FAIL} .pi/taskplane-pointer.json missing [POINTER_MISSING]`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to create the workspace pointer${c.reset}`); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to create the workspace pointer${c.reset}`, + ); issues++; } else { try { pointer = JSON.parse(fs.readFileSync(pointerPath, "utf-8")); if (!pointer.config_repo || !pointer.config_path) { - console.log(` ${FAIL} .pi/taskplane-pointer.json missing required fields (config_repo, config_path) [POINTER_SCHEMA_INVALID]`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to recreate the pointer${c.reset}`); + console.log( + ` ${FAIL} .pi/taskplane-pointer.json missing required fields (config_repo, config_path) [POINTER_SCHEMA_INVALID]`, + ); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to recreate the pointer${c.reset}`, + ); pointer = null; issues++; } else { - console.log(` ${OK} .pi/taskplane-pointer.json ${c.dim}(→ ${pointer.config_repo}/${pointer.config_path})${c.reset}`); + console.log( + ` ${OK} .pi/taskplane-pointer.json ${c.dim}(→ ${pointer.config_repo}/${pointer.config_path})${c.reset}`, + ); } } catch { console.log(` ${FAIL} .pi/taskplane-pointer.json is not valid JSON [POINTER_PARSE_ERROR]`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to recreate the pointer${c.reset}`); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to recreate the pointer${c.reset}`, + ); issues++; } } @@ -2658,12 +2865,16 @@ function cmdDoctor() { configRepoRoot = null; issues++; } else if (!isInsideGitRepo(configRepoRoot)) { - console.log(` ${FAIL} config repo is not a git repository: ${pointer.config_repo} [CONFIG_REPO_NOT_GIT]`); + console.log( + ` ${FAIL} config repo is not a git repository: ${pointer.config_repo} [CONFIG_REPO_NOT_GIT]`, + ); console.log(` ${c.dim}→ Run: git init ${configRepoRoot}${c.reset}`); configRepoRoot = null; issues++; } else { - console.log(` ${OK} config repo: ${pointer.config_repo} ${c.dim}(${configRepoRoot})${c.reset}`); + console.log( + ` ${OK} config repo: ${pointer.config_repo} ${c.dim}(${configRepoRoot})${c.reset}`, + ); } } @@ -2672,8 +2883,12 @@ function cmdDoctor() { if (configRepoRoot) { const taskplaneDir = path.join(configRepoRoot, pointer.config_path); if (!fs.existsSync(taskplaneDir)) { - console.log(` ${FAIL} ${pointer.config_repo}/${pointer.config_path}/ not found [CONFIG_DIR_NOT_FOUND]`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to create the config directory${c.reset}`); + console.log( + ` ${FAIL} ${pointer.config_repo}/${pointer.config_path}/ not found [CONFIG_DIR_NOT_FOUND]`, + ); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to create the config directory${c.reset}`, + ); issues++; } else { console.log(` ${OK} ${pointer.config_repo}/${pointer.config_path}/ exists`); @@ -2689,7 +2904,9 @@ function cmdDoctor() { cwd: configRepoRoot, stdio: ["pipe", "pipe", "pipe"], timeout: 5000, - }).toString().trim(); + }) + .toString() + .trim(); // Detect default branch (try origin/HEAD, fall back to main/master heuristic) let defaultBranch = null; @@ -2698,7 +2915,9 @@ function cmdDoctor() { cwd: configRepoRoot, stdio: ["pipe", "pipe", "pipe"], timeout: 5000, - }).toString().trim(); + }) + .toString() + .trim(); // refs/remotes/origin/main → main defaultBranch = originHead.replace(/^refs\/remotes\/origin\//, ""); } catch { @@ -2721,28 +2940,40 @@ function cmdDoctor() { if (defaultBranch && currentBranch !== defaultBranch) { // Check if .taskplane/ exists on the default branch via git ls-tree try { - const lsOutput = execFileSync("git", ["ls-tree", "--name-only", defaultBranch, pointer.config_path + "/"], { - cwd: configRepoRoot, - stdio: ["pipe", "pipe", "pipe"], - timeout: 5000, - }).toString().trim(); + const lsOutput = execFileSync( + "git", + ["ls-tree", "--name-only", defaultBranch, pointer.config_path + "/"], + { + cwd: configRepoRoot, + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }, + ) + .toString() + .trim(); if (lsOutput) { console.log(` ${OK} ${pointer.config_path}/ exists on default branch (${defaultBranch})`); } else { - console.log(` ${WARN} ${pointer.config_path}/ exists on current branch (${currentBranch}) but not on default branch (${defaultBranch})`); + console.log( + ` ${WARN} ${pointer.config_path}/ exists on current branch (${currentBranch}) but not on default branch (${defaultBranch})`, + ); console.log(` ${c.dim}→ Merge to ${defaultBranch} so teammates can onboard${c.reset}`); } } catch { // ls-tree failed — directory doesn't exist on that branch - console.log(` ${WARN} ${pointer.config_path}/ exists on current branch (${currentBranch}) but not on default branch (${defaultBranch})`); + console.log( + ` ${WARN} ${pointer.config_path}/ exists on current branch (${currentBranch}) but not on default branch (${defaultBranch})`, + ); console.log(` ${c.dim}→ Merge to ${defaultBranch} so teammates can onboard${c.reset}`); } } else if (defaultBranch && currentBranch === defaultBranch) { console.log(` ${OK} ${pointer.config_path}/ on default branch (${defaultBranch})`); } else { // Could not determine default branch — skip this check silently - console.log(` ${INFO} could not determine default branch for ${pointer.config_repo} — skipping branch check`); + console.log( + ` ${INFO} could not determine default branch for ${pointer.config_repo} — skipping branch check`, + ); } } catch { // git commands failed — skip branch check @@ -2760,8 +2991,12 @@ function cmdDoctor() { // Check path exists on disk if (!fs.existsSync(resolvedPath)) { - console.log(` ${FAIL} repo: ${repoId} — path not found: ${resolvedPath} [WORKSPACE_REPO_PATH_NOT_FOUND]`); - console.log(` ${c.dim}→ Check repos.${repoId}.path in .pi/taskplane-workspace.yaml${c.reset}`); + console.log( + ` ${FAIL} repo: ${repoId} — path not found: ${resolvedPath} [WORKSPACE_REPO_PATH_NOT_FOUND]`, + ); + console.log( + ` ${c.dim}→ Check repos.${repoId}.path in .pi/taskplane-workspace.yaml${c.reset}`, + ); issues++; continue; } @@ -2775,9 +3010,13 @@ function cmdDoctor() { }); console.log(` ${OK} repo: ${repoId} ${c.dim}(${resolvedPath})${c.reset}`); } catch { - console.log(` ${FAIL} repo: ${repoId} — not a git repository: ${resolvedPath} [WORKSPACE_REPO_NOT_GIT]`); + console.log( + ` ${FAIL} repo: ${repoId} — not a git repository: ${resolvedPath} [WORKSPACE_REPO_NOT_GIT]`, + ); console.log(` ${c.dim}→ Run: git init ${resolvedPath}${c.reset}`); - console.log(` ${c.dim} or fix repos.${repoId}.path in .pi/taskplane-workspace.yaml${c.reset}`); + console.log( + ` ${c.dim} or fix repos.${repoId}.path in .pi/taskplane-workspace.yaml${c.reset}`, + ); issues++; } } @@ -2785,11 +3024,13 @@ function cmdDoctor() { // Check project config (common — both modes) console.log(); - const hasUnifiedJson = fs.existsSync(path.join(configLocation.root, configLocation.prefix, "taskplane-config.json")); - const hasYamlFallback = !hasUnifiedJson && ( - fs.existsSync(path.join(configLocation.root, configLocation.prefix, "task-runner.yaml")) || - fs.existsSync(path.join(configLocation.root, configLocation.prefix, "task-orchestrator.yaml")) + const hasUnifiedJson = fs.existsSync( + path.join(configLocation.root, configLocation.prefix, "taskplane-config.json"), ); + const hasYamlFallback = + !hasUnifiedJson && + (fs.existsSync(path.join(configLocation.root, configLocation.prefix, "task-runner.yaml")) || + fs.existsSync(path.join(configLocation.root, configLocation.prefix, "task-orchestrator.yaml"))); const configFiles = [ // JSON is required unless legacy YAML exists as fallback { path: "taskplane-config.json", required: !hasYamlFallback, hide: false }, @@ -2840,8 +3081,16 @@ function cmdDoctor() { // Detect YAML config files without a JSON equivalent (taskplane-config.json). { const yamlRunnerPath = path.join(configLocation.root, configLocation.prefix, "task-runner.yaml"); - const yamlOrchestratorPath = path.join(configLocation.root, configLocation.prefix, "task-orchestrator.yaml"); - const jsonConfigPath = path.join(configLocation.root, configLocation.prefix, "taskplane-config.json"); + const yamlOrchestratorPath = path.join( + configLocation.root, + configLocation.prefix, + "task-orchestrator.yaml", + ); + const jsonConfigPath = path.join( + configLocation.root, + configLocation.prefix, + "taskplane-config.json", + ); const hasYamlRunner = fs.existsSync(yamlRunnerPath); const hasYamlOrchestrator = fs.existsSync(yamlOrchestratorPath); @@ -2849,12 +3098,18 @@ function cmdDoctor() { if ((hasYamlRunner || hasYamlOrchestrator) && !hasJsonConfig) { console.log(` ${WARN} legacy YAML config detected in ${configLocation.label}`); - console.log(` ${c.dim}→ Run /taskplane-settings to migrate to taskplane-config.json${c.reset}`); + console.log( + ` ${c.dim}→ Run /taskplane-settings to migrate to taskplane-config.json${c.reset}`, + ); } } // Check task areas from config - const { paths: taskAreaPaths, contexts: taskAreaContexts, areaRepoIds } = discoverTaskAreaMetadata(projectRoot, configLocation.root, configLocation.prefix); + const { + paths: taskAreaPaths, + contexts: taskAreaContexts, + areaRepoIds, + } = discoverTaskAreaMetadata(projectRoot, configLocation.root, configLocation.prefix); if (taskAreaPaths.length > 0) { console.log(); for (const areaPath of taskAreaPaths) { @@ -2886,8 +3141,12 @@ function cmdDoctor() { if (knownRepoIds.includes(repoId)) { console.log(` ${OK} area '${areaName}' repo_id: ${repoId}`); } else { - console.log(` ${FAIL} area '${areaName}' repo_id '${repoId}' does not match any workspace repo [AREA_REPO_ID_UNKNOWN]`); - console.log(` ${c.dim}→ Available repos: ${knownRepoIds.join(", ")}. Fix repoId in ${configLocation.label}/taskplane-config.json${c.reset}`); + console.log( + ` ${FAIL} area '${areaName}' repo_id '${repoId}' does not match any workspace repo [AREA_REPO_ID_UNKNOWN]`, + ); + console.log( + ` ${c.dim}→ Available repos: ${knownRepoIds.join(", ")}. Fix repoId in ${configLocation.label}/taskplane-config.json${c.reset}`, + ); issues++; } } @@ -2921,22 +3180,30 @@ function cmdDoctor() { const gitignorePath = path.join(configRepoRoot, ".gitignore"); const gitignoreExists = fs.existsSync(gitignorePath); if (!gitignoreExists) { - console.log(` ${WARN} ${configRepoName}/.gitignore missing — Taskplane runtime entries not protected`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`); + console.log( + ` ${WARN} ${configRepoName}/.gitignore missing — Taskplane runtime entries not protected`, + ); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`, + ); // WARN doesn't increment issues (it's advisory, not a failure) } else { const content = fs.readFileSync(gitignorePath, "utf-8"); - const existingLines = new Set(content.split(/\r?\n/).map(l => l.trim())); + const existingLines = new Set(content.split(/\r?\n/).map((l) => l.trim())); const allEntries = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES]; const missing = allEntries - .map(entry => `${prefix}${entry}`) - .filter(prefixed => !existingLines.has(prefixed)); + .map((entry) => `${prefix}${entry}`) + .filter((prefixed) => !existingLines.has(prefixed)); if (missing.length === 0) { console.log(` ${OK} ${configRepoName}/.gitignore has all Taskplane runtime entries`); } else { - console.log(` ${WARN} ${configRepoName}/.gitignore missing ${missing.length} Taskplane runtime entr${missing.length === 1 ? "y" : "ies"}`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`); + console.log( + ` ${WARN} ${configRepoName}/.gitignore missing ${missing.length} Taskplane runtime entr${missing.length === 1 ? "y" : "ies"}`, + ); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`, + ); } } @@ -2947,22 +3214,26 @@ function cmdDoctor() { cwd: configRepoRoot, stdio: ["pipe", "pipe", "pipe"], timeout: 10000, - }).toString().trim(); + }) + .toString() + .trim(); const trackedFiles = raw ? raw.split(/\r?\n/) : []; if (trackedFiles.length > 0) { - const prefixedPatterns = ALL_GITIGNORE_PATTERNS.map(p => `${prefix}${p}`); - const patterns = prefixedPatterns.map(p => patternToRegex(p)); - const matchedFiles = trackedFiles.filter(file => - patterns.some(regex => regex.test(file)) - ); + const prefixedPatterns = ALL_GITIGNORE_PATTERNS.map((p) => `${prefix}${p}`); + const patterns = prefixedPatterns.map((p) => patternToRegex(p)); + const matchedFiles = trackedFiles.filter((file) => patterns.some((regex) => regex.test(file))); if (matchedFiles.length > 0) { - console.log(` ${FAIL} ${matchedFiles.length} runtime artifact${matchedFiles.length === 1 ? "" : "s"} tracked by git in ${configRepoName}`); + console.log( + ` ${FAIL} ${matchedFiles.length} runtime artifact${matchedFiles.length === 1 ? "" : "s"} tracked by git in ${configRepoName}`, + ); for (const file of matchedFiles) { console.log(` ${c.dim}${file}${c.reset}`); } - console.log(` ${c.dim}→ Run: cd ${configRepoName} && git rm --cached ${matchedFiles.join(" ")}${c.reset}`); + console.log( + ` ${c.dim}→ Run: cd ${configRepoName} && git rm --cached ${matchedFiles.join(" ")}${c.reset}`, + ); issues++; } else { console.log(` ${OK} no runtime artifacts tracked by git in ${configRepoName}`); @@ -2983,18 +3254,24 @@ function cmdDoctor() { const gitignoreExists = fs.existsSync(gitignorePath); if (!gitignoreExists) { console.log(` ${WARN} .gitignore missing — Taskplane runtime entries not protected`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`, + ); } else { const content = fs.readFileSync(gitignorePath, "utf-8"); - const existingLines = new Set(content.split(/\r?\n/).map(l => l.trim())); + const existingLines = new Set(content.split(/\r?\n/).map((l) => l.trim())); const allEntries = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES]; - const missing = allEntries.filter(entry => !existingLines.has(entry)); + const missing = allEntries.filter((entry) => !existingLines.has(entry)); if (missing.length === 0) { console.log(` ${OK} .gitignore has all Taskplane runtime entries`); } else { - console.log(` ${WARN} .gitignore missing ${missing.length} Taskplane runtime entr${missing.length === 1 ? "y" : "ies"}`); - console.log(` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`); + console.log( + ` ${WARN} .gitignore missing ${missing.length} Taskplane runtime entr${missing.length === 1 ? "y" : "ies"}`, + ); + console.log( + ` ${c.dim}→ Run ${c.cyan}taskplane init${c.dim} to add them, or add manually${c.reset}`, + ); } } @@ -3005,17 +3282,19 @@ function cmdDoctor() { cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"], timeout: 10000, - }).toString().trim(); + }) + .toString() + .trim(); const trackedFiles = raw ? raw.split(/\r?\n/) : []; if (trackedFiles.length > 0) { - const patterns = ALL_GITIGNORE_PATTERNS.map(p => patternToRegex(p)); - const matchedFiles = trackedFiles.filter(file => - patterns.some(regex => regex.test(file)) - ); + const patterns = ALL_GITIGNORE_PATTERNS.map((p) => patternToRegex(p)); + const matchedFiles = trackedFiles.filter((file) => patterns.some((regex) => regex.test(file))); if (matchedFiles.length > 0) { - console.log(` ${FAIL} ${matchedFiles.length} runtime artifact${matchedFiles.length === 1 ? "" : "s"} tracked by git`); + console.log( + ` ${FAIL} ${matchedFiles.length} runtime artifact${matchedFiles.length === 1 ? "" : "s"} tracked by git`, + ); for (const file of matchedFiles) { console.log(` ${c.dim}${file}${c.reset}`); } @@ -3036,7 +3315,9 @@ function cmdDoctor() { if (issues === 0) { console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`); } else { - console.log(`${FAIL} ${issues} issue(s) found. Run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`); + console.log( + `${FAIL} ${issues} issue(s) found. Run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`, + ); process.exit(1); } } @@ -3057,7 +3338,9 @@ function cmdVersion() { if (fs.existsSync(tpJson)) { try { const info = JSON.parse(fs.readFileSync(tpJson, "utf-8")); - console.log(` Config: .pi/taskplane.json (v${info.version}, initialized ${info.installedAt?.slice(0, 10) || "unknown"})`); + console.log( + ` Config: .pi/taskplane.json (v${info.version}, initialized ${info.installedAt?.slice(0, 10) || "unknown"})`, + ); } catch { console.log(` Config: .pi/taskplane.json (unreadable)`); } diff --git a/biome.json b/biome.json index 2fbb1a9d..a58851db 100644 --- a/biome.json +++ b/biome.json @@ -1,47 +1,59 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", - "files": { - "includes": [ - "extensions/**/*.ts", - "extensions/**/*.tsx", - "bin/**/*.mjs", - "scripts/**/*.mjs", - "!**/node_modules", - "!dashboard/public", - "!extensions/types", - "!.pi", - "!.worktrees" - ] - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "off", - "noAssignInExpressions": "off" - }, - "complexity": { - "noForEach": "off", - "noExcessiveCognitiveComplexity": "off" - }, - "style": { - "noNonNullAssertion": "off", - "useConst": "off", - "noParameterAssign": "off", - "useDefaultParameterLast": "off", - "noUnusedTemplateLiteral": "off" - }, - "correctness": { - "noUnusedVariables": "warn", - "noUnusedImports": "warn" - }, - "performance": { - "noDelete": "off" - } - } - }, - "formatter": { - "enabled": false - } + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "files": { + "includes": [ + "extensions/**/*.ts", + "extensions/**/*.tsx", + "bin/**/*.mjs", + "scripts/**/*.mjs", + "!**/node_modules", + "!dashboard/public", + "!extensions/types", + "!.pi", + "!.worktrees" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noAssignInExpressions": "off" + }, + "complexity": { + "noForEach": "off", + "noExcessiveCognitiveComplexity": "off" + }, + "style": { + "noNonNullAssertion": "off", + "useConst": "off", + "noParameterAssign": "off", + "useDefaultParameterLast": "off", + "noUnusedTemplateLiteral": "off" + }, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedImports": "warn" + }, + "performance": { + "noDelete": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 1, + "lineWidth": 100, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always" + } + } } diff --git a/docs/maintainers/development-setup.md b/docs/maintainers/development-setup.md index c3212e97..3f81714d 100644 --- a/docs/maintainers/development-setup.md +++ b/docs/maintainers/development-setup.md @@ -121,6 +121,47 @@ and `@mariozechner/pi-tui` to local mock stubs so tests don't need the real pack --- +## Code style and `git blame.ignoreRevsFile` (recommended one-time config) + +Taskplane uses [Biome](https://biomejs.dev) as both linter and formatter. The +formatter rules are pinned in `biome.json` and applied uniformly across the +codebase. + +Available scripts (run from the repo root): + +```bash +npm run lint # report lint issues (no fixes) +npm run lint:fix # apply safe lint autofixes +npm run format # format files in-place +npm run format:check # check formatting (CI-style; non-zero exit on diff) +``` + +### `.git-blame-ignore-revs` + +The repo ships a `.git-blame-ignore-revs` file at the root that lists +commits whose changes are purely mechanical — chiefly the one-shot Biome +formatter adoption commit (TP-193). Without this file, `git blame` would +bottom out on the formatter commit for nearly every line in the codebase +and hide the real authoring history. + +**Recommended one-time per-developer setup:** + +```bash +git config blame.ignoreRevsFile .git-blame-ignore-revs +``` + +This is **recommended, not required**. Without it, `git blame` still works +— it just attributes every formatter-touched line to the format-adoption +commit instead of the underlying author. The same `.git-blame-ignore-revs` +file is also picked up automatically by GitHub's web `Blame` view (no +client-side config needed there). + +When you add a future bulk-mechanical commit (e.g., a one-shot codemod or +another formatter migration), append its full 40-character SHA + a +comment block describing what it did to `.git-blame-ignore-revs`. + +--- + ## Recommended local dev loop 1. Edit extension/CLI/template code diff --git a/extensions/reviewer-extension.ts b/extensions/reviewer-extension.ts index d00b5b6e..404233cc 100644 --- a/extensions/reviewer-extension.ts +++ b/extensions/reviewer-extension.ts @@ -48,7 +48,8 @@ export default function reviewerExtension(pi: ExtensionAPI) { "Block until the next review request is available, then return its content. " + "Call this after completing each review to wait for the next one. " + "Returns 'SHUTDOWN' when the task is complete and you should exit.", - promptSnippet: "wait_for_review() — block until the next review request arrives (persistent reviewer mode)", + promptSnippet: + "wait_for_review() — block until the next review request arrives (persistent reviewer mode)", promptGuidelines: [ "Call wait_for_review() to receive each review request.", "After writing your review to the specified output file, call wait_for_review() again.", @@ -82,11 +83,14 @@ export default function reviewerExtension(pi: ExtensionAPI) { if (!existsSync(requestPath)) { // Signal fired but request file doesn't exist (race condition or error) return { - content: [{ - type: "text" as const, - text: `ERROR — Signal file ${REVIEWER_SIGNAL_PREFIX}${signalNum} found but ` + - `${signalContent} does not exist. Waiting for next signal.`, - }], + content: [ + { + type: "text" as const, + text: + `ERROR — Signal file ${REVIEWER_SIGNAL_PREFIX}${signalNum} found but ` + + `${signalContent} does not exist. Waiting for next signal.`, + }, + ], details: undefined, }; } @@ -103,16 +107,18 @@ export default function reviewerExtension(pi: ExtensionAPI) { // Check timeout if (Date.now() - startTime > REVIEWER_WAIT_TIMEOUT_MS) { return { - content: [{ - type: "text" as const, - text: "TIMEOUT — No review request received within the timeout period. Exit cleanly.", - }], + content: [ + { + type: "text" as const, + text: "TIMEOUT — No review request received within the timeout period. Exit cleanly.", + }, + ], details: undefined, }; } // Wait before next poll - await new Promise(resolve => setTimeout(resolve, REVIEWER_POLL_INTERVAL_MS)); + await new Promise((resolve) => setTimeout(resolve, REVIEWER_POLL_INTERVAL_MS)); } }, }); diff --git a/extensions/taskplane/abort.ts b/extensions/taskplane/abort.ts index f2e13b0c..da554941 100644 --- a/extensions/taskplane/abort.ts +++ b/extensions/taskplane/abort.ts @@ -8,7 +8,18 @@ import { join } from "path"; import { execLog, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts"; import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts"; import { deleteBatchState, persistRuntimeState } from "./persistence.ts"; -import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts"; +import type { + AbortActionStep, + AbortErrorCode, + AbortLaneResult, + AbortMode, + AbortResult, + AbortTargetSession, + AllocatedLane, + OrchBatchRuntimeState, + PersistedBatchState, + PersistedLaneRecord, +} from "./types.ts"; // ── Abort Pure Functions ───────────────────────────────────────────── @@ -37,7 +48,7 @@ export function selectAbortTargetSessions( // Filter to only lane and merge sessions for the exact orchestrator prefix. // Handles both repo-mode (`-lane-`) and workspace-mode // (`--lane-`) session name formats. - const targetNames = allSessionNames.filter(name => { + const targetNames = allSessionNames.filter((name) => { const prefixWithDash = `${prefix}-`; if (!name.startsWith(prefixWithDash)) return false; const suffix = name.slice(prefixWithDash.length); @@ -78,7 +89,10 @@ export function selectAbortTargetSessions( } // Build lookup from runtime lanes - const runtimeLookup = new Map(); + const runtimeLookup = new Map< + string, + { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null } + >(); for (const lane of runtimeLanes) { const currentTask = lane.tasks.length > 0 ? lane.tasks[0] : null; runtimeLookup.set(lane.laneSessionId, { @@ -90,7 +104,7 @@ export function selectAbortTargetSessions( }); } - return targetNames.map(sessionName => { + return targetNames.map((sessionName) => { const runtime = runtimeLookup.get(sessionName); const persisted = persistedLookup.get(sessionName); @@ -184,7 +198,6 @@ export function discoverAbortSessionNames( return [...names]; } - // ── Abort Orchestration Functions ──────────────────────────────────── /** @@ -207,10 +220,18 @@ export function writeWrapUpFiles( if (!target.taskFolderInWorktree) { // Skip child sessions (workers, reviewers) — only main lane sessions have task folders // Also skip merge sessions (no task folder) - if (target.sessionName.endsWith("-worker") || target.sessionName.endsWith("-reviewer") || target.sessionName.includes("merge")) { + if ( + target.sessionName.endsWith("-worker") || + target.sessionName.endsWith("-reviewer") || + target.sessionName.includes("merge") + ) { results.push({ sessionName: target.sessionName, written: false, error: null }); } else { - results.push({ sessionName: target.sessionName, written: false, error: "No task folder resolved" }); + results.push({ + sessionName: target.sessionName, + written: false, + error: "No task folder resolved", + }); } continue; } @@ -220,7 +241,11 @@ export function writeWrapUpFiles( // Ensure directory exists if (!existsSync(target.taskFolderInWorktree)) { - results.push({ sessionName: target.sessionName, written: false, error: `Task folder does not exist: ${target.taskFolderInWorktree}` }); + results.push({ + sessionName: target.sessionName, + written: false, + error: `Task folder does not exist: ${target.taskFolderInWorktree}`, + }); continue; } @@ -262,7 +287,7 @@ export async function waitForSessionExit( const deadline = Date.now() + gracePeriodMs; while (Date.now() < deadline) { const sleepMs = Math.max(1, Math.min(pollIntervalMs, deadline - Date.now())); - await new Promise(r => setTimeout(r, sleepMs)); + await new Promise((r) => setTimeout(r, sleepMs)); } return { exited: [], remaining: [...sessionNames] }; @@ -357,7 +382,11 @@ export async function executeAbort( repoRoot, ); } catch (err) { - execLog("abort", batchState.batchId, `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`); + execLog( + "abort", + batchState.batchId, + `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`, + ); } // TP-108: Kill all V2 merge agents (process-owned, not TMUX) @@ -370,7 +399,11 @@ export async function executeAbort( // Step 3: Discover target sessions from Runtime V2 state sources. const allSessionNames = discoverAbortSessionNames(prefix, persistedState, batchState.currentLanes); if (allSessionNames.length === 0) { - execLog("abort", batchState.batchId, `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`); + execLog( + "abort", + batchState.batchId, + `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`, + ); } // Step 4: Select and enrich target sessions @@ -400,7 +433,7 @@ export async function executeAbort( } // Step 5b: Wait for sessions to exit - const allTargetNames = targets.map(t => t.sessionName); + const allTargetNames = targets.map((t) => t.sessionName); const waitResult = await waitForSessionExit(allTargetNames, gracePeriodMs, pollIntervalMs); gracefulExits = waitResult.exited.length; @@ -414,7 +447,7 @@ export async function executeAbort( for (const kr of killResults) { killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error }); } - const killFailures = killResults.filter(kr => !kr.killed); + const killFailures = killResults.filter((kr) => !kr.killed); if (killFailures.length > 0) { errors.push({ code: "ABORT_KILL_FAILED", @@ -426,7 +459,7 @@ export async function executeAbort( // Build lane results const exitedSet = new Set(waitResult.exited); for (const target of targets) { - const wrapUp = wrapUpResults.find(wr => wr.sessionName === target.sessionName); + const wrapUp = wrapUpResults.find((wr) => wr.sessionName === target.sessionName); const wasGraceful = exitedSet.has(target.sessionName); const killResult = killResultBySession.get(target.sessionName); const sessionKilled = wasGraceful || killResult?.killed === true; @@ -443,7 +476,7 @@ export async function executeAbort( } } else { // Hard mode: kill all immediately - const allTargetNames = targets.map(t => t.sessionName); + const allTargetNames = targets.map((t) => t.sessionName); const killResults = killOrchSessions(allTargetNames, { stateRoot: repoRoot, batchId: batchState.batchId, @@ -452,7 +485,7 @@ export async function executeAbort( for (const kr of killResults) { killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error }); } - const killFailures = killResults.filter(kr => !kr.killed); + const killFailures = killResults.filter((kr) => !kr.killed); if (killFailures.length > 0) { errors.push({ code: "ABORT_KILL_FAILED", @@ -490,7 +523,7 @@ export async function executeAbort( return { mode, sessionsFound: targets.length, - sessionsKilled: laneResults.filter(lr => lr.sessionKilled).length, + sessionsKilled: laneResults.filter((lr) => lr.sessionKilled).length, gracefulExits, laneResults, wrapUpFailures, @@ -499,4 +532,3 @@ export async function executeAbort( durationMs: Date.now() - startTime, }; } - diff --git a/extensions/taskplane/agent-bridge-extension.ts b/extensions/taskplane/agent-bridge-extension.ts index 47711764..d0715832 100644 --- a/extensions/taskplane/agent-bridge-extension.ts +++ b/extensions/taskplane/agent-bridge-extension.ts @@ -52,7 +52,11 @@ function resolveOutboxDir(): string { /** * Write a message to the agent's outbox. */ -function writeOutbox(type: "reply" | "escalate", content: string, replyTo?: string): { id: string } { +function writeOutbox( + type: "reply" | "escalate", + content: string, + replyTo?: string, +): { id: string } { const outboxDir = resolveOutboxDir(); mkdirSync(outboxDir, { recursive: true }); @@ -89,7 +93,11 @@ const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; const AUTONOMY_PATTERN = /^(interactive|supervised|autonomous)$/; function resolveActiveSegmentId(): string | null { - const raw = (process.env.TASKPLANE_ACTIVE_SEGMENT_ID || process.env.TASKPLANE_SEGMENT_ID || "").trim(); + const raw = ( + process.env.TASKPLANE_ACTIVE_SEGMENT_ID || + process.env.TASKPLANE_SEGMENT_ID || + "" + ).trim(); if (!raw || raw === "null" || raw === "(none / whole-task execution)") return null; return raw; } @@ -125,8 +133,14 @@ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string writeFileSync(tempPath, JSON.stringify(request, null, 2) + "\n", "utf-8"); renameSync(tempPath, finalPath); } catch (err) { - try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* cleanup */ } - throw new Error(`Failed to write segment expansion request: ${err instanceof Error ? err.message : String(err)}`); + try { + if (existsSync(tempPath)) unlinkSync(tempPath); + } catch { + /* cleanup */ + } + throw new Error( + `Failed to write segment expansion request: ${err instanceof Error ? err.message : String(err)}`, + ); } return finalPath; @@ -215,11 +229,7 @@ export function isStepMarkedComplete(statusPath: string, stepNum: number): boole // 2. delimiter length >= opener length, // 3. nothing follows the delimiter except whitespace. const trailingIsWhitespace = /^\s*$/.test(trailing); - if ( - char === fenceOpener.char && - length >= fenceOpener.length && - trailingIsWhitespace - ) { + if (char === fenceOpener.char && length >= fenceOpener.length && trailingIsWhitespace) { fenceOpener = null; continue; } @@ -258,26 +268,32 @@ export default function (pi: ExtensionAPI) { content: Type.String({ description: "Reply content (max 4KB)", }), - replyTo: Type.Optional(Type.String({ - description: "Message ID being replied to (from a steering message)", - })), + replyTo: Type.Optional( + Type.String({ + description: "Message ID being replied to (from a steering message)", + }), + ), }), async execute(_toolCallId, params) { try { const result = writeOutbox("reply", params.content, params.replyTo); return { - content: [{ - type: "text" as const, - text: `✅ Reply sent to supervisor (ID: ${result.id})`, - }], + content: [ + { + type: "text" as const, + text: `✅ Reply sent to supervisor (ID: ${result.id})`, + }, + ], details: undefined, }; } catch (err) { return { - content: [{ - type: "text" as const, - text: `❌ Failed to send reply: ${err instanceof Error ? err.message : String(err)}`, - }], + content: [ + { + type: "text" as const, + text: `❌ Failed to send reply: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -305,18 +321,22 @@ export default function (pi: ExtensionAPI) { try { const result = writeOutbox("escalate", params.content); return { - content: [{ - type: "text" as const, - text: `⚠️ Escalation sent to supervisor (ID: ${result.id}). Continue working on other items while waiting for guidance.`, - }], + content: [ + { + type: "text" as const, + text: `⚠️ Escalation sent to supervisor (ID: ${result.id}). Continue working on other items while waiting for guidance.`, + }, + ], details: undefined, }; } catch (err) { return { - content: [{ - type: "text" as const, - text: `❌ Failed to escalate: ${err instanceof Error ? err.message : String(err)}`, - }], + content: [ + { + type: "text" as const, + text: `❌ Failed to escalate: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -340,8 +360,7 @@ export default function (pi: ExtensionAPI) { description: "Request additional repository segments for the current task at runtime. " + "Writes a request file to the worker outbox for engine processing.", - promptSnippet: - "request_segment_expansion(requestedRepoIds, rationale, placement?, edges?)", + promptSnippet: "request_segment_expansion(requestedRepoIds, rationale, placement?, edges?)", promptGuidelines: [ "Use this when runtime discovery reveals additional repos are needed.", "Do not wait for approval; continue current segment work after requesting.", @@ -355,18 +374,22 @@ export default function (pi: ExtensionAPI) { rationale: Type.String({ description: "Why these repos are needed", }), - placement: Type.Optional(Type.Union([ - Type.Literal("after-current"), - Type.Literal("end"), - ], { - description: "Where to place new segments: after-current (default) or end", - })), - edges: Type.Optional(Type.Array(Type.Object({ - from: Type.String({ description: "Source repo ID" }), - to: Type.String({ description: "Destination repo ID" }), - }), { - description: "Optional ordering edges between requested repos", - })), + placement: Type.Optional( + Type.Union([Type.Literal("after-current"), Type.Literal("end")], { + description: "Where to place new segments: after-current (default) or end", + }), + ), + edges: Type.Optional( + Type.Array( + Type.Object({ + from: Type.String({ description: "Source repo ID" }), + to: Type.String({ description: "Destination repo ID" }), + }), + { + description: "Optional ordering edges between requested repos", + }, + ), + ), }), async execute(_toolCallId, params) { const autonomy = resolveSupervisorAutonomy(); @@ -428,9 +451,11 @@ export default function (pi: ExtensionAPI) { placement: params.placement === "end" ? "end" : "after-current", edges: Array.isArray(params.edges) ? params.edges - .filter((edge): edge is { from: string; to: string } => Boolean(edge && typeof edge.from === "string" && typeof edge.to === "string")) - .map((edge) => ({ from: edge.from.trim(), to: edge.to.trim() })) - .filter((edge) => edge.from.length > 0 && edge.to.length > 0) + .filter((edge): edge is { from: string; to: string } => + Boolean(edge && typeof edge.from === "string" && typeof edge.to === "string"), + ) + .map((edge) => ({ from: edge.from.trim(), to: edge.to.trim() })) + .filter((edge) => edge.from.length > 0 && edge.to.length > 0) : [], timestamp: now, }; @@ -466,14 +491,13 @@ export default function (pi: ExtensionAPI) { // The reviewer runs as a separate Pi process, writes feedback to // .reviews/, and this tool returns the verdict to the worker. - - /** * Load the reviewer system prompt from base template + local override. * Uses resolveTaskplaneAgentTemplate (path-resolver.ts) for all platform support (TP-157). */ function loadReviewerPrompt(): string { - let basePrompt = "You are a code reviewer. Read the request and write your review to the specified output file."; + let basePrompt = + "You are a code reviewer. Read the request and write your review to the specified output file."; try { const templatePath = resolveTaskplaneAgentTemplate("task-reviewer"); if (existsSync(templatePath)) { @@ -481,9 +505,14 @@ export default function (pi: ExtensionAPI) { const fmEnd = raw.indexOf("---", 4); if (fmEnd > 0) basePrompt = raw.slice(fmEnd + 3).trim(); } - } catch { /* fall through to default */ } + } catch { + /* fall through to default */ + } // Local override - const localPaths = [join(process.cwd(), ".pi", "agents", "task-reviewer.md"), join(process.cwd(), "agents", "task-reviewer.md")]; + const localPaths = [ + join(process.cwd(), ".pi", "agents", "task-reviewer.md"), + join(process.cwd(), "agents", "task-reviewer.md"), + ]; for (const p of localPaths) { try { if (!existsSync(p)) continue; @@ -494,7 +523,9 @@ export default function (pi: ExtensionAPI) { if (localBody) basePrompt += "\n\n---\n\n## Project-Specific Guidance\n\n" + localBody; } break; - } catch { continue; } + } catch { + continue; + } } return basePrompt; } @@ -503,21 +534,24 @@ export default function (pi: ExtensionAPI) { return process.env.TASKPLANE_REVIEWER_STATE_PATH || join(taskFolder, ".reviewer-state.json"); } - function writeReviewerState(taskFolder: string, state: { - status: "running" | "done" | "error"; - elapsedMs: number; - toolCalls: number; - contextPct: number; - costUsd: number; - lastTool: string; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - updatedAt: number; - reviewType?: string; - reviewStep?: number; - }): void { + function writeReviewerState( + taskFolder: string, + state: { + status: "running" | "done" | "error"; + elapsedMs: number; + toolCalls: number; + contextPct: number; + costUsd: number; + lastTool: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + updatedAt: number; + reviewType?: string; + reviewStep?: number; + }, + ): void { const filePath = reviewerStatePath(taskFolder); const tmpPath = filePath + ".tmp"; writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n", "utf-8"); @@ -527,14 +561,25 @@ export default function (pi: ExtensionAPI) { function removeReviewerState(taskFolder: string): void { const filePath = reviewerStatePath(taskFolder); if (!existsSync(filePath)) return; - try { unlinkSync(filePath); } catch { /* best effort */ } + try { + unlinkSync(filePath); + } catch { + /* best effort */ + } } /** * Spawn a reviewer Pi subprocess and wait for it to complete. * Returns the process exit code. */ - function spawnReviewer(prompt: string, systemPrompt: string, cwd: string, taskFolder: string, reviewType?: string, reviewStep?: number): Promise { + function spawnReviewer( + prompt: string, + systemPrompt: string, + cwd: string, + taskFolder: string, + reviewType?: string, + reviewStep?: number, + ): Promise { // Pre-clean stale reviewer state from prior interrupted review removeReviewerState(taskFolder); return new Promise((resolve) => { @@ -548,9 +593,16 @@ export default function (pi: ExtensionAPI) { const cliPath = resolvePiCliPath(); const args = [ - cliPath, "--mode", "rpc", "--no-session", "--no-extensions", "--no-skills", - "--tools", reviewerTools, - "--system-prompt", systemPrompt, + cliPath, + "--mode", + "rpc", + "--no-session", + "--no-extensions", + "--no-skills", + "--tools", + reviewerTools, + "--system-prompt", + systemPrompt, ]; if (reviewerModel) args.push("--model", reviewerModel); if (reviewerThinking) args.push("--thinking", reviewerThinking); @@ -570,7 +622,9 @@ export default function (pi: ExtensionAPI) { reviewerExclusions = parsed.filter((v: unknown): v is string => typeof v === "string"); } } - } catch { /* ignore malformed */ } + } catch { + /* ignore malformed */ + } const filteredReviewerPackages = filterExcludedExtensions(reviewerPackages, reviewerExclusions); for (const pkg of filteredReviewerPackages) { args.push("-e", pkg); @@ -611,7 +665,9 @@ export default function (pi: ExtensionAPI) { reviewType, reviewStep, }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } }; // Write initial "running" state immediately so dashboard shows @@ -620,7 +676,11 @@ export default function (pi: ExtensionAPI) { const closeStdin = () => { setTimeout(() => { - try { proc.stdin?.end(); } catch { /* ignore */ } + try { + proc.stdin?.end(); + } catch { + /* ignore */ + } }, 100); }; @@ -642,9 +702,12 @@ export default function (pi: ExtensionAPI) { cacheReadTokens += usage.cacheRead || 0; cacheWriteTokens += usage.cacheWrite || 0; if (usage.cost) { - costUsd += typeof usage.cost === "object" - ? (usage.cost.total || 0) - : (typeof usage.cost === "number" ? usage.cost : 0); + costUsd += + typeof usage.cost === "object" + ? usage.cost.total || 0 + : typeof usage.cost === "number" + ? usage.cost + : 0; } } emitState("running"); @@ -653,11 +716,12 @@ export default function (pi: ExtensionAPI) { case "tool_execution_start": { toolCalls++; const toolName = event.toolName || "tool"; - const argPreview = typeof event.args === "string" - ? event.args.slice(0, 80) - : (event.args && typeof Object.values(event.args)[0] === "string" - ? String(Object.values(event.args)[0]).slice(0, 80) - : ""); + const argPreview = + typeof event.args === "string" + ? event.args.slice(0, 80) + : event.args && typeof Object.values(event.args)[0] === "string" + ? String(Object.values(event.args)[0]).slice(0, 80) + : ""; lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName; emitState("running"); break; @@ -688,7 +752,11 @@ export default function (pi: ExtensionAPI) { if (line.endsWith("\r")) line = line.slice(0, -1); if (!line.trim()) continue; let event: any; - try { event = JSON.parse(line); } catch { continue; } + try { + event = JSON.parse(line); + } catch { + continue; + } handleEvent(event); } }); @@ -697,9 +765,16 @@ export default function (pi: ExtensionAPI) { proc.on("error", () => finalize(1)); // Timeout: 10 minutes - setTimeout(() => { - try { proc.kill("SIGTERM"); } catch { /* ignore */ } - }, 10 * 60 * 1000); + setTimeout( + () => { + try { + proc.kill("SIGTERM"); + } catch { + /* ignore */ + } + }, + 10 * 60 * 1000, + ); }); } @@ -721,13 +796,14 @@ export default function (pi: ExtensionAPI) { ], parameters: Type.Object({ step: Type.Number({ description: "Step number to review" }), - type: Type.Union( - [Type.Literal("plan"), Type.Literal("code")], - { description: 'Review type: "plan" or "code"' }, + type: Type.Union([Type.Literal("plan"), Type.Literal("code")], { + description: 'Review type: "plan" or "code"', + }), + baseline: Type.Optional( + Type.String({ + description: "Git commit SHA for code review diff baseline", + }), ), - baseline: Type.Optional(Type.String({ - description: "Git commit SHA for code review diff baseline", - })), }), async execute(_toolCallId, params) { const { step: stepNum, type: reviewType, baseline } = params; @@ -766,7 +842,9 @@ export default function (pi: ExtensionAPI) { const statusContent = readFileSync(statusPath, "utf-8"); const rcMatch = statusContent.match(/\*\*Review Counter:\*\*\s*(\d+)/); if (rcMatch) reviewCounter = parseInt(rcMatch[1]); - } catch { /* default 0 */ } + } catch { + /* default 0 */ + } reviewCounter++; const num = String(reviewCounter).padStart(3, "0"); @@ -780,14 +858,21 @@ export default function (pi: ExtensionAPI) { if (!existsSync(pf)) continue; const content = readFileSync(pf, "utf-8"); const stepMatch = content.match(new RegExp(`###\\s+Step\\s+${stepNum}[:\\s]+(.+)`)); - if (stepMatch) { stepName = stepMatch[1].trim(); break; } + if (stepMatch) { + stepName = stepMatch[1].trim(); + break; + } } - } catch { /* use default */ } + } catch { + /* use default */ + } // Generate review request prompt const projectName = process.env.TASKPLANE_PROJECT_NAME || "project"; const diffCmd = baseline ? `git diff ${baseline}..HEAD` : `git diff`; - const diffNamesCmd = baseline ? `git diff ${baseline}..HEAD --name-only` : `git diff --name-only`; + const diffNamesCmd = baseline + ? `git diff ${baseline}..HEAD --name-only` + : `git diff --name-only`; let reviewPrompt: string; if (reviewType === "plan") { @@ -835,14 +920,26 @@ export default function (pi: ExtensionAPI) { try { const systemPrompt = loadReviewerPrompt(); - const exitCode = await spawnReviewer(reviewPrompt, systemPrompt, cwd, taskFolder, reviewType, stepNum); + const exitCode = await spawnReviewer( + reviewPrompt, + systemPrompt, + cwd, + taskFolder, + reviewType, + stepNum, + ); // Update review counter in STATUS.md try { const status = readFileSync(statusPath, "utf-8"); - const updated = status.replace(/\*\*Review Counter:\*\*\s*\d+/, `**Review Counter:** ${reviewCounter}`); + const updated = status.replace( + /\*\*Review Counter:\*\*\s*\d+/, + `**Review Counter:** ${reviewCounter}`, + ); writeFileSync(statusPath, updated); - } catch { /* best effort */ } + } catch { + /* best effort */ + } // Read review output and extract verdict if (existsSync(outputPath)) { @@ -861,7 +958,9 @@ export default function (pi: ExtensionAPI) { const status = readFileSync(statusPath, "utf-8"); const logEntry = `| ${new Date().toISOString().slice(0, 16).replace("T", " ")} | Review R${num} | ${reviewType} Step ${stepNum}: ${verdict} |\n`; writeFileSync(statusPath, status.trimEnd() + "\n" + logEntry); - } catch { /* best effort */ } + } catch { + /* best effort */ + } removeReviewerState(taskFolder); @@ -871,20 +970,48 @@ export default function (pi: ExtensionAPI) { } else if (verdict === "REVISE") { const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i); const details = summaryMatch ? summaryMatch[1].trim().slice(0, 500) : "See review file."; - return { content: [{ type: "text" as const, text: `REVISE: ${details}\n\nFull review: ${reviewFile}` }], details: undefined }; + return { + content: [ + { type: "text" as const, text: `REVISE: ${details}\n\nFull review: ${reviewFile}` }, + ], + details: undefined, + }; } else if (verdict === "RETHINK") { - return { content: [{ type: "text" as const, text: `RETHINK — reconsider approach. See ${reviewFile}` }], details: undefined }; + return { + content: [ + { type: "text" as const, text: `RETHINK — reconsider approach. See ${reviewFile}` }, + ], + details: undefined, + }; } else { - return { content: [{ type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` }], details: undefined }; + return { + content: [ + { type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` }, + ], + details: undefined, + }; } } else { removeReviewerState(taskFolder); - return { content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer exited (code ${exitCode}) but produced no output.` }], details: undefined }; + return { + content: [ + { + type: "text" as const, + text: `UNAVAILABLE — reviewer exited (code ${exitCode}) but produced no output.`, + }, + ], + details: undefined, + }; } } catch (err) { removeReviewerState(taskFolder); return { - content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer failed: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `UNAVAILABLE — reviewer failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } diff --git a/extensions/taskplane/agent-host.ts b/extensions/taskplane/agent-host.ts index e6c98d4d..ca09362b 100644 --- a/extensions/taskplane/agent-host.ts +++ b/extensions/taskplane/agent-host.ts @@ -22,8 +22,13 @@ import { spawn, type ChildProcess } from "child_process"; import { - readFileSync, writeFileSync, appendFileSync, mkdirSync, - existsSync, readdirSync, renameSync, + readFileSync, + writeFileSync, + appendFileSync, + mkdirSync, + existsSync, + readdirSync, + renameSync, } from "fs"; import { join, dirname, basename, resolve } from "path"; import { StringDecoder } from "string_decoder"; @@ -121,13 +126,19 @@ import { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts"; */ export function buildWorkerToolsAllowlist(userTools: string | undefined | null): string { const userPart = (userTools && userTools.trim()) || DEFAULT_WORKER_USER_TOOLS; - const rawUserList = userPart.split(",").map((s) => s.trim()).filter(Boolean); + const rawUserList = userPart + .split(",") + .map((s) => s.trim()) + .filter(Boolean); // Guard against delimiter-only / whitespace-only inputs (e.g. ",", " , ") // that would otherwise parse to an empty list and yield bridge-tools-only // workers with no file/shell capabilities. - const userList = rawUserList.length > 0 - ? rawUserList - : DEFAULT_WORKER_USER_TOOLS.split(",").map((s) => s.trim()).filter(Boolean); + const userList = + rawUserList.length > 0 + ? rawUserList + : DEFAULT_WORKER_USER_TOOLS.split(",") + .map((s) => s.trim()) + .filter(Boolean); const merged = new Set(userList); for (const t of ENGINE_BRIDGE_TOOLS) merged.add(t); return Array.from(merged).join(","); @@ -155,9 +166,13 @@ function extractAssistantText(message: Record): string { // Guard: skip null/non-object entries to prevent TypeError on malformed streams if (Array.isArray(message.content)) { const textBlocks = message.content - .filter((b: unknown): b is { type: string; text: string } => - typeof b === "object" && b !== null && - (b as any).type === "text" && typeof (b as any).text === "string") + .filter( + (b: unknown): b is { type: string; text: string } => + typeof b === "object" && + b !== null && + (b as any).type === "text" && + typeof (b as any).text === "string", + ) .map((b) => b.text); if (textBlocks.length > 0) return textBlocks.join("\n"); } @@ -304,8 +319,10 @@ function isValidMailboxMessage(obj: any): boolean { typeof obj.batchId === "string" && typeof obj.from === "string" && typeof obj.to === "string" && - typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) && - typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) && + typeof obj.timestamp === "number" && + Number.isFinite(obj.timestamp) && + typeof obj.type === "string" && + MAILBOX_MESSAGE_TYPES.has(obj.type) && typeof obj.content === "string" ); } @@ -330,7 +347,6 @@ export function spawnAgent( onEvent?: AgentEventCallback, onTelemetry?: AgentTelemetryCallback, ): { promise: Promise; kill: () => void } { - const cliPath = resolvePiCliPath(); const closeDelayMs = opts.closeDelayMs ?? 100; const timeoutMs = opts.timeoutMs ?? 0; @@ -369,9 +385,16 @@ export function spawnAgent( let stdinClosed = false; let assistantMessageEnds = 0; const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5; - let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0; - let costUsd = 0, toolCalls = 0, retries = 0, compactions = 0; - let lastTool = "", error: string | null = null; + let inputTokens = 0, + outputTokens = 0, + cacheReadTokens = 0, + cacheWriteTokens = 0; + let costUsd = 0, + toolCalls = 0, + retries = 0, + compactions = 0; + let lastTool = "", + error: string | null = null; let contextUsage: AgentHostResult["contextUsage"] = null; let stderrBuffer = ""; const STDERR_MAX = 2048; @@ -388,7 +411,11 @@ export function spawnAgent( timeoutHandle = setTimeout(() => { timedOut = true; killed = true; - try { proc.kill("SIGTERM"); } catch { /* ignore */ } + try { + proc.kill("SIGTERM"); + } catch { + /* ignore */ + } }, timeoutMs); } @@ -397,12 +424,14 @@ export function spawnAgent( const refreshRegistrySnapshot = (force: boolean = false) => { if (!opts.stateRoot) return; const now = Date.now(); - if (!force && (now - lastRegistryRefreshAt) < REGISTRY_REFRESH_INTERVAL_MS) return; + if (!force && now - lastRegistryRefreshAt < REGISTRY_REFRESH_INTERVAL_MS) return; try { const snapshot = buildRegistrySnapshot(opts.stateRoot, opts.batchId); writeRegistrySnapshot(opts.stateRoot, snapshot); lastRegistryRefreshAt = now; - } catch { /* best effort */ } + } catch { + /* best effort */ + } }; // Registry integration: write manifest before process is considered visible @@ -430,10 +459,18 @@ export function spawnAgent( stdinClosed = true; if (closeDelayMs > 0) { setTimeout(() => { - try { proc.stdin?.end(); } catch { /* ignore */ } + try { + proc.stdin?.end(); + } catch { + /* ignore */ + } }, closeDelayMs); } else { - try { proc.stdin?.end(); } catch { /* ignore */ } + try { + proc.stdin?.end(); + } catch { + /* ignore */ + } } } @@ -456,7 +493,9 @@ export function spawnAgent( try { mkdirSync(dirname(opts.eventsPath), { recursive: true }); appendFileSync(opts.eventsPath, JSON.stringify(event) + "\n", "utf-8"); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } } @@ -481,9 +520,15 @@ export function spawnAgent( if (!existsSync(inboxDir)) continue; let entries: string[]; - try { entries = readdirSync(inboxDir); } catch { continue; } + try { + entries = readdirSync(inboxDir); + } catch { + continue; + } - const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")).sort(); + const msgFiles = entries + .filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")) + .sort(); if (msgFiles.length === 0) continue; const ackDir = join(opts.mailboxDir, "ack"); @@ -509,12 +554,24 @@ export function spawnAgent( if (isBroadcast) { // Do NOT remove the shared broadcast inbox file. Persist a per-agent // ack marker so all agents can consume the same broadcast exactly once. - try { writeFileSync(ackPath, raw, "utf-8"); } catch { /* best effort */ } + try { + writeFileSync(ackPath, raw, "utf-8"); + } catch { + /* best effort */ + } } else { - try { renameSync(join(inboxDir, filename), ackPath); } catch { /* race ok */ } + try { + renameSync(join(inboxDir, filename), ackPath); + } catch { + /* race ok */ + } } - emitEvent("message_delivered", { messageId: msg.id, content: msg.content, broadcast: isBroadcast }); + emitEvent("message_delivered", { + messageId: msg.id, + content: msg.content, + broadcast: isBroadcast, + }); if (opts.stateRoot) { appendMailboxAuditEvent(opts.stateRoot, expectedBatchId, { type: "message_delivered", @@ -530,11 +587,18 @@ export function spawnAgent( // TP-090: steering-pending flag if (opts.steeringPendingPath) { try { - appendFileSync(opts.steeringPendingPath, - JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n", "utf-8"); - } catch { /* best effort */ } + appendFileSync( + opts.steeringPendingPath, + JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n", + "utf-8", + ); + } catch { + /* best effort */ + } } - } catch { /* skip malformed */ } + } catch { + /* skip malformed */ + } } } } @@ -576,9 +640,15 @@ export function spawnAgent( const summary = { exitCode: result.exitCode, exitSignal: result.signal, - tokens: (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens) > 0 - ? { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens } - : null, + tokens: + inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens > 0 + ? { + input: inputTokens, + output: outputTokens, + cacheRead: cacheReadTokens, + cacheWrite: cacheWriteTokens, + } + : null, cost: costUsd > 0 ? costUsd : null, toolCalls, retries, @@ -589,23 +659,29 @@ export function spawnAgent( contextUsage: contextUsage || null, }; writeFileSync(opts.exitSummaryPath, JSON.stringify(summary, null, 2) + "\n", "utf-8"); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } - const exitEventType: RuntimeAgentEventType = - timedOut ? "agent_timeout" : - killed ? "agent_killed" : - (exitCode === 0 && agentEnded) ? "agent_exited" : - "agent_crashed"; + const exitEventType: RuntimeAgentEventType = timedOut + ? "agent_timeout" + : killed + ? "agent_killed" + : exitCode === 0 && agentEnded + ? "agent_exited" + : "agent_crashed"; emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut }); // Registry integration: update manifest to terminal status if (opts.stateRoot) { - const terminalStatus = - timedOut ? "timed_out" as const : - killed ? "killed" as const : - (exitCode === 0 && agentEnded) ? "exited" as const : - "crashed" as const; + const terminalStatus = timedOut + ? ("timed_out" as const) + : killed + ? ("killed" as const) + : exitCode === 0 && agentEnded + ? ("exited" as const) + : ("crashed" as const); updateManifestStatus(opts.stateRoot, opts.batchId, opts.agentId, terminalStatus); refreshRegistrySnapshot(true); } @@ -623,7 +699,11 @@ export function spawnAgent( if (!line.trim()) continue; let event: any; - try { event = JSON.parse(line); } catch { continue; } + try { + event = JSON.parse(line); + } catch { + continue; + } if (!event || !event.type) continue; // Accumulate telemetry @@ -636,7 +716,12 @@ export function spawnAgent( cacheReadTokens += usage.cacheRead || 0; cacheWriteTokens += usage.cacheWrite || 0; if (usage.cost) { - costUsd += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0); + costUsd += + typeof usage.cost === "object" + ? usage.cost.total || 0 + : typeof usage.cost === "number" + ? usage.cost + : 0; } } // TP-111: Emit assistant_message with bounded content @@ -652,8 +737,15 @@ export function spawnAgent( // then periodically at a bounded cadence to refresh context usage. if (event.message?.role === "assistant") { assistantMessageEnds += 1; - if (assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0) { - try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ } + if ( + assistantMessageEnds === 1 || + assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0 + ) { + try { + proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); + } catch { + /* ignore */ + } } } // Check mailbox @@ -662,7 +754,16 @@ export function spawnAgent( refreshRegistrySnapshot(false); // Emit telemetry update if (onTelemetry) { - onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage }); + onTelemetry({ + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + costUsd, + toolCalls, + lastTool, + contextUsage, + }); } break; } @@ -670,8 +771,12 @@ export function spawnAgent( toolCalls++; currentTurnHadToolCalls = true; const toolName = event.toolName || "tool"; - const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) : - (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : ""); + const argPreview = + typeof event.args === "string" + ? event.args.slice(0, 300) + : event.args && typeof Object.values(event.args)[0] === "string" + ? String(Object.values(event.args)[0]).slice(0, 300) + : ""; lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName; // TP-111: Bounded payload only — no raw args in durable event log const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : ""; @@ -680,14 +785,21 @@ export function spawnAgent( } case "tool_execution_end": { // TP-111: Include bounded result summary for dashboard display - const toolResultSummary = typeof event.result === "string" ? event.result.slice(0, 200) - : event.output ? String(event.output).slice(0, 200) : ""; + const toolResultSummary = + typeof event.result === "string" + ? event.result.slice(0, 200) + : event.output + ? String(event.output).slice(0, 200) + : ""; emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary }); break; } case "auto_retry_start": { retries++; - emitEvent("retry_started", { attempt: event.attempt, error: event.errorMessage || event.error }); + emitEvent("retry_started", { + attempt: event.attempt, + error: event.errorMessage || event.error, + }); break; } case "auto_compaction_start": { @@ -704,7 +816,16 @@ export function spawnAgent( emitEvent("context_usage", { ...event.data.contextUsage }); // Emit telemetry immediately so context % is live in dashboard if (onTelemetry) { - onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage }); + onTelemetry({ + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + costUsd, + toolCalls, + lastTool, + contextUsage, + }); } } break; @@ -717,60 +838,62 @@ export function spawnAgent( // because workers commonly use tools (reads/greps) then exit // with a text declaration ("Now let me fix this:") without // actually making the edit. - const shouldIntercept = opts.onPrematureExit - && exitInterceptionCount < maxExitInterceptions; + const shouldIntercept = opts.onPrematureExit && exitInterceptionCount < maxExitInterceptions; if (shouldIntercept) { exitInterceptionCount++; const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout // Wrap in Promise.resolve().then() to catch synchronous throws const interceptPromise = Promise.resolve().then(() => - opts.onPrematureExit!(lastAssistantMessage)); + opts.onPrematureExit!(lastAssistantMessage), + ); const timeoutPromise = new Promise((res) => - setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS)); - Promise.race([interceptPromise, timeoutPromise]) - .then( - (newPrompt: string | null) => { - if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) { - // Re-prompt the agent with supervisor guidance - agentEnded = false; // Reset for the new turn - currentTurnHadToolCalls = false; // Reset for new turn - proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n"); - emitEvent("exit_intercepted", { - interceptionCount: exitInterceptionCount, - assistantMessage: truncatePayload(lastAssistantMessage, 500), - supervisorConsulted: true, - action: "reprompt", - newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS), - }); - } else { - // Callback returned null or stdin already closed — close session - const reason = stdinClosed ? "stdin_closed" - : newPrompt === null ? "callback_returned_null" + setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS), + ); + Promise.race([interceptPromise, timeoutPromise]).then( + (newPrompt: string | null) => { + if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) { + // Re-prompt the agent with supervisor guidance + agentEnded = false; // Reset for the new turn + currentTurnHadToolCalls = false; // Reset for new turn + proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n"); + emitEvent("exit_intercepted", { + interceptionCount: exitInterceptionCount, + assistantMessage: truncatePayload(lastAssistantMessage, 500), + supervisorConsulted: true, + action: "reprompt", + newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS), + }); + } else { + // Callback returned null or stdin already closed — close session + const reason = stdinClosed + ? "stdin_closed" + : newPrompt === null + ? "callback_returned_null" : "unknown"; - emitEvent("exit_intercepted", { - interceptionCount: exitInterceptionCount, - assistantMessage: truncatePayload(lastAssistantMessage, 500), - supervisorConsulted: true, - action: "close", - reason, - }); - closeStdin(); - } - }, - (err: unknown) => { - // Callback rejected — emit single diagnostic event and close - const msg = err instanceof Error ? err.message : String(err); emitEvent("exit_intercepted", { interceptionCount: exitInterceptionCount, assistantMessage: truncatePayload(lastAssistantMessage, 500), - supervisorConsulted: false, + supervisorConsulted: true, action: "close", - reason: "callback_error", - error: msg, + reason, }); closeStdin(); - }, - ); + } + }, + (err: unknown) => { + // Callback rejected — emit single diagnostic event and close + const msg = err instanceof Error ? err.message : String(err); + emitEvent("exit_intercepted", { + interceptionCount: exitInterceptionCount, + assistantMessage: truncatePayload(lastAssistantMessage, 500), + supervisorConsulted: false, + action: "close", + reason: "callback_error", + error: msg, + }); + closeStdin(); + }, + ); } else { // No callback, had tool calls, or interception limit reached — close normally if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) { @@ -820,7 +943,11 @@ export function spawnAgent( const kill = () => { killed = true; - try { proc.kill("SIGTERM"); } catch { /* ignore */ } + try { + proc.kill("SIGTERM"); + } catch { + /* ignore */ + } }; return { promise, kill }; diff --git a/extensions/taskplane/cleanup.ts b/extensions/taskplane/cleanup.ts index ebdeb82b..a998f4e2 100644 --- a/extensions/taskplane/cleanup.ts +++ b/extensions/taskplane/cleanup.ts @@ -64,7 +64,10 @@ export interface PostIntegrateCleanupResult { * @param batchId - Batch ID to scope deletion * @returns Cleanup result with counts and warnings */ -export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIntegrateCleanupResult { +export function cleanupPostIntegrate( + stateRoot: string, + batchId: string, +): PostIntegrateCleanupResult { const result: PostIntegrateCleanupResult = { telemetryFilesDeleted: 0, mergeFilesDeleted: 0, @@ -116,10 +119,11 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn try { const entries = readdirSync(piDir); for (const entry of entries) { - if (entry.includes(batchId) && ( - (entry.startsWith("merge-result-") && entry.endsWith(".json")) || - (entry.startsWith("merge-request-") && entry.endsWith(".txt")) - )) { + if ( + entry.includes(batchId) && + ((entry.startsWith("merge-result-") && entry.endsWith(".json")) || + (entry.startsWith("merge-request-") && entry.endsWith(".txt"))) + ) { try { unlinkSync(join(piDir, entry)); result.mergeFilesDeleted++; @@ -140,7 +144,9 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn rmSync(mailboxBatchDir, { recursive: true, force: true }); result.mailboxDirsDeleted = 1; } catch (err: unknown) { - result.warnings.push(`Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`); + result.warnings.push( + `Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`, + ); } } @@ -151,7 +157,9 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn rmSync(snapshotBatchDir, { recursive: true, force: true }); result.snapshotDirsDeleted = 1; } catch (err: unknown) { - result.warnings.push(`Failed to delete context-snapshots directory ${snapshotBatchDir}: ${(err as Error).message}`); + result.warnings.push( + `Failed to delete context-snapshots directory ${snapshotBatchDir}: ${(err as Error).message}`, + ); } } @@ -163,7 +171,12 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn */ export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string { const parts: string[] = []; - const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted + result.mailboxDirsDeleted + result.snapshotDirsDeleted; + const totalDeleted = + result.telemetryFilesDeleted + + result.mergeFilesDeleted + + result.promptFilesDeleted + + result.mailboxDirsDeleted + + result.snapshotDirsDeleted; if (totalDeleted > 0) { const segments: string[] = []; @@ -287,26 +300,32 @@ export function sweepStaleArtifacts( }; // Sweep telemetry files - sweepDir(join(stateRoot, ".pi", "telemetry"), (name) => - name.endsWith(".jsonl") || - name.endsWith("-exit.json") || - (name.startsWith("lane-prompt-") && name.endsWith(".txt")), + sweepDir( + join(stateRoot, ".pi", "telemetry"), + (name) => + name.endsWith(".jsonl") || + name.endsWith("-exit.json") || + (name.startsWith("lane-prompt-") && name.endsWith(".txt")), ); // Sweep merge result/request files - sweepDir(join(stateRoot, ".pi"), (name) => - (name.startsWith("merge-result-") && name.endsWith(".json")) || - (name.startsWith("merge-request-") && name.endsWith(".txt")), + sweepDir( + join(stateRoot, ".pi"), + (name) => + (name.startsWith("merge-result-") && name.endsWith(".json")) || + (name.startsWith("merge-request-") && name.endsWith(".txt")), ); // Sweep stale worker conversation logs (.pi/worker-conversation-*.jsonl) - sweepDir(join(stateRoot, ".pi"), (name) => - name.startsWith("worker-conversation-") && name.endsWith(".jsonl"), + sweepDir( + join(stateRoot, ".pi"), + (name) => name.startsWith("worker-conversation-") && name.endsWith(".jsonl"), ); // Sweep stale lane state files (.pi/lane-state-*.json) - sweepDir(join(stateRoot, ".pi"), (name) => - name.startsWith("lane-state-") && name.endsWith(".json"), + sweepDir( + join(stateRoot, ".pi"), + (name) => name.startsWith("lane-state-") && name.endsWith(".json"), ); // Sweep stale batch directories under a parent (mailbox, context-snapshots, verification) @@ -328,7 +347,9 @@ export function sweepStaleArtifacts( } } } catch (err: unknown) { - result.warnings.push(`Failed to read ${label} directory ${parentDir}: ${(err as Error).message}`); + result.warnings.push( + `Failed to read ${label} directory ${parentDir}: ${(err as Error).message}`, + ); } }; @@ -351,7 +372,11 @@ export function formatPreflightSweep(result: PreflightSweepResult): string { if (result.skipped) { return `ℹ️ Preflight sweep skipped: ${result.skipReason}`; } - if (result.staleFilesDeleted === 0 && result.staleDirsDeleted === 0 && result.warnings.length === 0) { + if ( + result.staleFilesDeleted === 0 && + result.staleDirsDeleted === 0 && + result.warnings.length === 0 + ) { return ""; // Nothing to report } const parts: string[] = []; @@ -621,27 +646,27 @@ export function cleanupPriorBatchArtifacts( }; // Clean telemetry files from prior batches - cleanDir(join(piDir, "telemetry"), (name) => - name.endsWith(".jsonl") || - name.endsWith("-exit.json") || - (name.startsWith("lane-prompt-") && name.endsWith(".txt")), + cleanDir( + join(piDir, "telemetry"), + (name) => + name.endsWith(".jsonl") || + name.endsWith("-exit.json") || + (name.startsWith("lane-prompt-") && name.endsWith(".txt")), ); // Clean merge result/request files from prior batches - cleanDir(piDir, (name) => - (name.startsWith("merge-result-") && name.endsWith(".json")) || - (name.startsWith("merge-request-") && name.endsWith(".txt")), + cleanDir( + piDir, + (name) => + (name.startsWith("merge-result-") && name.endsWith(".json")) || + (name.startsWith("merge-request-") && name.endsWith(".txt")), ); // Clean worker conversation logs from prior batches - cleanDir(piDir, (name) => - name.startsWith("worker-conversation-") && name.endsWith(".jsonl"), - ); + cleanDir(piDir, (name) => name.startsWith("worker-conversation-") && name.endsWith(".jsonl")); // Clean lane state files from prior batches - cleanDir(piDir, (name) => - name.startsWith("lane-state-") && name.endsWith(".json"), - ); + cleanDir(piDir, (name) => name.startsWith("lane-state-") && name.endsWith(".json")); // Clean batch-scoped directories (mailbox, context-snapshots) const cleanBatchDirs = (parentDir: string): void => { @@ -678,7 +703,9 @@ export function formatPriorBatchCleanup(result: PriorBatchCleanupResult): string if (result.itemsDeleted === 0 && result.warnings.length === 0) return ""; const parts: string[] = []; if (result.itemsDeleted > 0) { - parts.push(`🧹 Prior batch cleanup: removed ${result.itemsDeleted} artifact(s) from previous batch(es)`); + parts.push( + `🧹 Prior batch cleanup: removed ${result.itemsDeleted} artifact(s) from previous batch(es)`, + ); } for (const warning of result.warnings) { parts.push(` ⚠️ ${warning}`); @@ -706,10 +733,7 @@ export interface PreflightCleanupResult { * @param deps - Sweep dependencies (active batch check) * @returns Combined cleanup result */ -export function runPreflightCleanup( - stateRoot: string, - deps: SweepDeps, -): PreflightCleanupResult { +export function runPreflightCleanup(stateRoot: string, deps: SweepDeps): PreflightCleanupResult { const sweep = sweepStaleArtifacts(stateRoot, deps); const rotation = rotateSupervisorLogs(stateRoot); return { sweep, rotation }; @@ -724,10 +748,15 @@ export function formatPreflightCleanup(result: PreflightCleanupResult): string { const parts: string[] = []; // Layer 2: age-based sweep - if (!result.sweep.skipped && (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0)) { + if ( + !result.sweep.skipped && + (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0) + ) { const segments: string[] = []; - if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`); - if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`); + if (result.sweep.staleFilesDeleted > 0) + segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`); + if (result.sweep.staleDirsDeleted > 0) + segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`); parts.push(`removed ${segments.join(" and ")} (>3 days old)`); } diff --git a/extensions/taskplane/config-loader.ts b/extensions/taskplane/config-loader.ts index 96462457..3222d016 100644 --- a/extensions/taskplane/config-loader.ts +++ b/extensions/taskplane/config-loader.ts @@ -45,7 +45,6 @@ import type { GlobalPreferences, } from "./config-schema.ts"; - // ── Error Types ────────────────────────────────────────────────────── /** @@ -72,7 +71,6 @@ export class ConfigLoadError extends Error { } } - // ── Deep Clone Helper ──────────────────────────────────────────────── /** Deep clone a config object to avoid cross-call mutation. */ @@ -80,7 +78,6 @@ function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)); } - // ── Deep Merge Helper ──────────────────────────────────────────────── /** @@ -176,12 +173,16 @@ function migrateGlobalPreferences(raw: Record, prefsPath: string): } if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") { raw.orchestrator.orchestrator.spawnMode = "subprocess"; - console.error(`[taskplane] Auto-migrated global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`); + console.error( + `[taskplane] Auto-migrated global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`, + ); migrated = true; } if (raw.taskRunner?.worker?.spawnMode === "tmux") { raw.taskRunner.worker.spawnMode = "subprocess"; - console.error(`[taskplane] Auto-migrated global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`); + console.error( + `[taskplane] Auto-migrated global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`, + ); migrated = true; } if (migrated) { @@ -191,15 +192,18 @@ function migrateGlobalPreferences(raw: Record, prefsPath: string): renameSync(tmpPath, prefsPath); console.error(`[taskplane] Preferences file updated: ${prefsPath}`); } catch (err) { - console.error(`[taskplane] Warning: could not persist preferences migration to disk: ${err instanceof Error ? err.message : err}`); + console.error( + `[taskplane] Warning: could not persist preferences migration to disk: ${err instanceof Error ? err.message : err}`, + ); } } return migrated; } /** Reset migration guard (for testing). @internal */ -export function _resetMigrationGuard(): void { _projectMigrationDone = false; } - +export function _resetMigrationGuard(): void { + _projectMigrationDone = false; +} // ── YAML snake_case → camelCase Mapping ────────────────────────────── @@ -300,7 +304,8 @@ function mapTaskRunnerYaml(raw: any): Partial { // Record sections with structural inner keys if (raw.task_areas) result.taskAreas = convertRecordSection(raw.task_areas); - if (raw.standards_overrides) result.standardsOverrides = convertRecordSection(raw.standards_overrides); + if (raw.standards_overrides) + result.standardsOverrides = convertRecordSection(raw.standards_overrides); // Flat record sections (keys are identifiers, values are strings) if (raw.reference_docs) result.referenceDocs = preserveRecord(raw.reference_docs); @@ -341,7 +346,8 @@ function mapOrchestratorYaml(raw: any): Partial { if (raw.assignment) { result.assignment = {}; if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy; - if (raw.assignment.size_weights) result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights); + if (raw.assignment.size_weights) + result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights); } // pre_warm: auto_detect is structural, commands is user-defined, always is array @@ -398,9 +404,11 @@ function normalizeWorkspaceSection( }; } - const defaultRepo = typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : ""; + const defaultRepo = + typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : ""; const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : ""; - let taskPacketRepo = typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : ""; + let taskPacketRepo = + typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : ""; if (!taskPacketRepo && defaultRepo) { taskPacketRepo = defaultRepo; @@ -426,7 +434,6 @@ function normalizeWorkspaceSection( }; } - // ── Config File Path Resolution ────────────────────────────────────── /** @@ -490,7 +497,7 @@ function loadJsonConfig(configRoot: string): Partial | null { throw new ConfigLoadError( "CONFIG_VERSION_MISSING", `${jsonPath} is missing required field "configVersion". ` + - `Expected configVersion: ${CONFIG_VERSION}.`, + `Expected configVersion: ${CONFIG_VERSION}.`, ); } @@ -498,15 +505,23 @@ function loadJsonConfig(configRoot: string): Partial | null { throw new ConfigLoadError( "CONFIG_VERSION_UNSUPPORTED", `${jsonPath} has configVersion ${parsed.configVersion}, but this version of Taskplane ` + - `only supports configVersion ${CONFIG_VERSION}. Please upgrade Taskplane.`, + `only supports configVersion ${CONFIG_VERSION}. Please upgrade Taskplane.`, ); } const overrides: Partial = {}; - if (parsed.taskRunner && typeof parsed.taskRunner === "object" && !Array.isArray(parsed.taskRunner)) { + if ( + parsed.taskRunner && + typeof parsed.taskRunner === "object" && + !Array.isArray(parsed.taskRunner) + ) { overrides.taskRunner = deepClone(parsed.taskRunner); } - if (parsed.orchestrator && typeof parsed.orchestrator === "object" && !Array.isArray(parsed.orchestrator)) { + if ( + parsed.orchestrator && + typeof parsed.orchestrator === "object" && + !Array.isArray(parsed.orchestrator) + ) { overrides.orchestrator = deepClone(parsed.orchestrator); } if (parsed.workspace) { @@ -519,7 +534,6 @@ function loadJsonConfig(configRoot: string): Partial | null { return overrides; } - // ── YAML Loading ───────────────────────────────────────────────────── /** @@ -612,7 +626,6 @@ function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefin } } - // ── Global Preferences (Layer 2) ───────────────────────────────────── /** @@ -703,7 +716,12 @@ export function loadGlobalPreferencesWithMeta(): GlobalPreferencesLoadResult { }; } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).length === 0) { + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + Object.keys(parsed).length === 0 + ) { return { preferences: bootstrapGlobalPreferencesFile(prefsPath), wasBootstrapped: true, @@ -730,7 +748,9 @@ export function loadGlobalPreferences(): GlobalPreferences { * Unknown keys are silently dropped — this is the Layer 2 boundary guardrail. */ function normalizePreferenceThinkingMode(value: unknown): string { - const cleaned = String(value ?? "").trim().toLowerCase(); + const cleaned = String(value ?? "") + .trim() + .toLowerCase(); if (!cleaned || cleaned === "inherit") return ""; if (cleaned === "on") return "high"; if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) { @@ -739,7 +759,9 @@ function normalizePreferenceThinkingMode(value: unknown): string { return ""; } -function extractInitAgentDefaults(rawInitDefaults: unknown): GlobalPreferences["initAgentDefaults"] | undefined { +function extractInitAgentDefaults( + rawInitDefaults: unknown, +): GlobalPreferences["initAgentDefaults"] | undefined { if (!rawInitDefaults || typeof rawInitDefaults !== "object" || Array.isArray(rawInitDefaults)) { return undefined; } @@ -750,9 +772,12 @@ function extractInitAgentDefaults(rawInitDefaults: unknown): GlobalPreferences[" if (typeof raw.workerModel === "string") extracted.workerModel = raw.workerModel; if (typeof raw.reviewerModel === "string") extracted.reviewerModel = raw.reviewerModel; if (typeof raw.mergeModel === "string") extracted.mergeModel = raw.mergeModel; - if (raw.workerThinking !== undefined) extracted.workerThinking = normalizePreferenceThinkingMode(raw.workerThinking); - if (raw.reviewerThinking !== undefined) extracted.reviewerThinking = normalizePreferenceThinkingMode(raw.reviewerThinking); - if (raw.mergeThinking !== undefined) extracted.mergeThinking = normalizePreferenceThinkingMode(raw.mergeThinking); + if (raw.workerThinking !== undefined) + extracted.workerThinking = normalizePreferenceThinkingMode(raw.workerThinking); + if (raw.reviewerThinking !== undefined) + extracted.reviewerThinking = normalizePreferenceThinkingMode(raw.reviewerThinking); + if (raw.mergeThinking !== undefined) + extracted.mergeThinking = normalizePreferenceThinkingMode(raw.mergeThinking); return Object.keys(extracted).length > 0 ? extracted : undefined; } @@ -764,7 +789,10 @@ function extractConfigOverrideSection(rawSection: unknown): Record return deepClone(rawSection as Record); } -function extractAllowlistedPreferences(raw: Record, prefsPath: string): GlobalPreferences { +function extractAllowlistedPreferences( + raw: Record, + prefsPath: string, +): GlobalPreferences { migrateGlobalPreferences(raw, prefsPath); const prefs: GlobalPreferences = {}; @@ -821,20 +849,37 @@ function extractAllowlistedPreferences(raw: Record, prefsPath: stri * Preferences-only fields (`dashboardPort`, `initAgentDefaults`) are preserved * in `GlobalPreferences` but intentionally not merged into runtime config. */ -export function applyGlobalPreferences(config: TaskplaneConfig, prefs: GlobalPreferences): TaskplaneConfig { +export function applyGlobalPreferences( + config: TaskplaneConfig, + prefs: GlobalPreferences, +): TaskplaneConfig { // Helper: only apply non-empty string values const applyStr = (val: string | undefined, setter: (v: string) => void) => { if (val !== undefined && val !== "") setter(val); }; // 1) Legacy flat aliases - applyStr(prefs.operatorId, (v) => { config.orchestrator.orchestrator.operatorId = v; }); - applyStr(prefs.sessionPrefix, (v) => { config.orchestrator.orchestrator.sessionPrefix = v; }); - applyStr(prefs.workerModel, (v) => { config.taskRunner.worker.model = v; }); - applyStr(prefs.reviewerModel, (v) => { config.taskRunner.reviewer.model = v; }); - applyStr(prefs.mergeModel, (v) => { config.orchestrator.merge.model = v; }); - applyStr(prefs.mergeThinking, (v) => { config.orchestrator.merge.thinking = v; }); - applyStr(prefs.supervisorModel, (v) => { config.orchestrator.supervisor.model = v; }); + applyStr(prefs.operatorId, (v) => { + config.orchestrator.orchestrator.operatorId = v; + }); + applyStr(prefs.sessionPrefix, (v) => { + config.orchestrator.orchestrator.sessionPrefix = v; + }); + applyStr(prefs.workerModel, (v) => { + config.taskRunner.worker.model = v; + }); + applyStr(prefs.reviewerModel, (v) => { + config.taskRunner.reviewer.model = v; + }); + applyStr(prefs.mergeModel, (v) => { + config.orchestrator.merge.model = v; + }); + applyStr(prefs.mergeThinking, (v) => { + config.orchestrator.merge.thinking = v; + }); + applyStr(prefs.supervisorModel, (v) => { + config.orchestrator.supervisor.model = v; + }); // spawnMode: enum — apply if defined (not a string-empty check) if (prefs.spawnMode !== undefined) { @@ -862,11 +907,15 @@ export function applyGlobalPreferences(config: TaskplaneConfig, prefs: GlobalPre // Runtime safety: nested legacy values may arrive through config-shaped overrides. if ((config.orchestrator.orchestrator as Record).spawnMode === "tmux") { config.orchestrator.orchestrator.spawnMode = "subprocess"; - console.error(`[taskplane] Auto-migrated runtime global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`); + console.error( + `[taskplane] Auto-migrated runtime global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`, + ); } if ((config.taskRunner.worker as Record).spawnMode === "tmux") { config.taskRunner.worker.spawnMode = "subprocess"; - console.error(`[taskplane] Auto-migrated runtime global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`); + console.error( + `[taskplane] Auto-migrated runtime global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`, + ); } return config; @@ -891,11 +940,7 @@ export function hasConfigFiles(root: string): boolean { // coordination file, not a project config). Without this distinction, // workspace root's .pi/taskplane-workspace.yaml causes resolveConfigRoot // to short-circuit before checking the pointer-resolved config root (#424). - const files = [ - PROJECT_CONFIG_FILENAME, - "task-runner.yaml", - "task-orchestrator.yaml", - ]; + const files = [PROJECT_CONFIG_FILENAME, "task-runner.yaml", "task-orchestrator.yaml"]; for (const f of files) { if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true; } @@ -942,7 +987,10 @@ function mergeProjectOverrides(config: TaskplaneConfig, overrides: Partial, overrides.taskRunner as Record); } if (overrides.orchestrator) { - deepMerge(config.orchestrator as Record, overrides.orchestrator as Record); + deepMerge( + config.orchestrator as Record, + overrides.orchestrator as Record, + ); } if (overrides.workspace) { if (!config.workspace || typeof config.workspace !== "object") { @@ -956,7 +1004,9 @@ function migrateProjectOverrides(overrides: Partial, configRoot if (_projectMigrationDone) return false; let migrated = false; - const orchestratorCore = overrides.orchestrator?.orchestrator as Record | undefined; + const orchestratorCore = overrides.orchestrator?.orchestrator as + | Record + | undefined; if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) { const currentPrefix = orchestratorCore.sessionPrefix; const isDefault = currentPrefix === undefined || currentPrefix === "orch"; @@ -969,7 +1019,9 @@ function migrateProjectOverrides(overrides: Partial, configRoot } if (orchestratorCore?.spawnMode === "tmux") { (orchestratorCore as any).spawnMode = "subprocess"; - console.error(`[taskplane] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`); + console.error( + `[taskplane] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`, + ); migrated = true; } @@ -1005,7 +1057,9 @@ function migrateProjectOverrides(overrides: Partial, configRoot console.error(`[taskplane] Config file updated: ${jsonPath}`); } } catch (err) { - console.error(`[taskplane] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`); + console.error( + `[taskplane] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`, + ); } } @@ -1077,7 +1131,6 @@ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): Taskp return config; } - // ── Backward-Compatible Adapters ───────────────────────────────────── // The following adapter functions convert the unified camelCase config @@ -1090,7 +1143,9 @@ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): Taskp * to preserve record/dictionary keys verbatim (e.g., sizeWeights S/M/L, * preWarm.commands keys, etc.). */ -export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.ts").OrchestratorConfig { +export function toOrchestratorConfig( + config: TaskplaneConfig, +): import("./types.ts").OrchestratorConfig { const o = config.orchestrator; return { orchestrator: { diff --git a/extensions/taskplane/config-schema.ts b/extensions/taskplane/config-schema.ts index 6d8f2ad0..0ab8ba68 100644 --- a/extensions/taskplane/config-schema.ts +++ b/extensions/taskplane/config-schema.ts @@ -66,7 +66,6 @@ export const CONFIG_VERSION = 1; */ export const PROJECT_CONFIG_FILENAME = "taskplane-config.json"; - // ── Task Runner Section Interfaces ─────────────────────────────────── /** Project metadata */ @@ -207,7 +206,6 @@ export interface QualityGateConfig { passThreshold: PassThreshold; } - // ── Task Runner Combined Section ───────────────────────────────────── /** @@ -257,7 +255,6 @@ export interface TaskRunnerSection { modelFallback: ModelFallbackMode; } - // ── Orchestrator Section Interfaces ────────────────────────────────── /** Core orchestrator settings */ @@ -393,7 +390,6 @@ export interface VerificationConfig { flakyReruns: number; } - // ── Orchestrator Combined Section ──────────────────────────────────── /** @@ -428,7 +424,6 @@ export interface OrchestratorSection { supervisor: SupervisorSectionConfig; } - // ── Workspace Section Interfaces ───────────────────────────────────── /** Workspace repo definition (JSON config shape). */ @@ -459,7 +454,6 @@ export interface WorkspaceSectionConfig { routing: WorkspaceRoutingSectionConfig; } - // ── Unified Config ─────────────────────────────────────────────────── /** @@ -491,7 +485,6 @@ export interface TaskplaneConfig { workspace?: WorkspaceSectionConfig; } - // ── Global Preferences (Layer 2) ───────────────────────────────────── /** @@ -527,11 +520,12 @@ export interface InitAgentDefaultsPreferences { mergeThinking?: string; } -export type DeepPartial = T extends Array - ? Array> - : T extends object - ? { [K in keyof T]?: DeepPartial } - : T; +export type DeepPartial = + T extends Array + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; export interface GlobalPreferences { /** @@ -590,7 +584,6 @@ export const GLOBAL_PREFERENCES_FILENAME = "preferences.json"; */ export const GLOBAL_PREFERENCES_SUBDIR = "taskplane"; - // ── Defaults ───────────────────────────────────────────────────────── /** Default task runner section values */ diff --git a/extensions/taskplane/config.ts b/extensions/taskplane/config.ts index 6a1bfc29..bce8b8c9 100644 --- a/extensions/taskplane/config.ts +++ b/extensions/taskplane/config.ts @@ -11,7 +11,12 @@ * @module orch/config */ -import { loadProjectConfig, toOrchestratorConfig, toTaskRunnerConfig, hasConfigFiles } from "./config-loader.ts"; +import { + loadProjectConfig, + toOrchestratorConfig, + toTaskRunnerConfig, + hasConfigFiles, +} from "./config-loader.ts"; export { hasConfigFiles, resolveConfigRoot } from "./config-loader.ts"; import type { OrchestratorConfig, TaskRunnerConfig } from "./types.ts"; import type { SupervisorConfig } from "./supervisor.ts"; @@ -31,7 +36,10 @@ import { DEFAULT_SUPERVISOR_CONFIG } from "./supervisor.ts"; * * Returns the legacy `OrchestratorConfig` (snake_case) shape. */ -export function loadOrchestratorConfig(cwd: string, pointerConfigRoot?: string): OrchestratorConfig { +export function loadOrchestratorConfig( + cwd: string, + pointerConfigRoot?: string, +): OrchestratorConfig { const unified = loadProjectConfig(cwd, pointerConfigRoot); return toOrchestratorConfig(unified); } diff --git a/extensions/taskplane/diagnostic-reports.ts b/extensions/taskplane/diagnostic-reports.ts index a40d4b3c..486bae66 100644 --- a/extensions/taskplane/diagnostic-reports.ts +++ b/extensions/taskplane/diagnostic-reports.ts @@ -15,7 +15,15 @@ import { join } from "path"; import { execLog } from "./execution.ts"; import { resolveOperatorId } from "./naming.ts"; -import type { AllocatedLane, LaneTaskOutcome, OrchBatchRuntimeState, OrchestratorConfig, PersistedTaskRecord, BatchDiagnostics, PersistedTaskExitSummary } from "./types.ts"; +import type { + AllocatedLane, + LaneTaskOutcome, + OrchBatchRuntimeState, + OrchestratorConfig, + PersistedTaskRecord, + BatchDiagnostics, + PersistedTaskExitSummary, +} from "./types.ts"; import { defaultBatchDiagnostics } from "./types.ts"; // ── Types ──────────────────────────────────────────────────────────── @@ -173,7 +181,7 @@ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticE * Serialize diagnostic events to JSONL format (one JSON object per line). */ export function eventsToJsonl(events: DiagnosticEvent[]): string { - return events.map(e => JSON.stringify(e)).join("\n") + "\n"; + return events.map((e) => JSON.stringify(e)).join("\n") + "\n"; } // ── Human-Readable Summary ─────────────────────────────────────────── @@ -206,7 +214,10 @@ function formatCost(cost: number): string { /** * Generate a human-readable markdown summary report. */ -export function buildMarkdownReport(input: DiagnosticReportInput, events: DiagnosticEvent[]): string { +export function buildMarkdownReport( + input: DiagnosticReportInput, + events: DiagnosticEvent[], +): string { const { batchId, phase, mode, startedAt, endedAt, diagnostics } = input; const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input; @@ -248,7 +259,7 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno lines.push(`|------|--------|---------------|------|----------|---------|`); for (const evt of events) { lines.push( - `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |` + `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |`, ); } lines.push(``); @@ -276,8 +287,8 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno } else { for (const repoKey of repoKeys) { const repoEvents = byRepo.get(repoKey)!; - const repoSucceeded = repoEvents.filter(e => e.status === "succeeded").length; - const repoFailed = repoEvents.filter(e => e.status === "failed").length; + const repoSucceeded = repoEvents.filter((e) => e.status === "succeeded").length; + const repoFailed = repoEvents.filter((e) => e.status === "failed").length; const repoCost = repoEvents.reduce((sum, e) => sum + e.cost, 0); lines.push(`### ${repoKey}`); @@ -290,7 +301,7 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno lines.push(`|------|--------|---------------|------|----------|`); for (const evt of repoEvents) { lines.push( - `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |` + `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |`, ); } lines.push(``); @@ -377,7 +388,10 @@ export function assembleDiagnosticInput( ): DiagnosticReportInput { // Build lookup maps for fast per-task enrichment (mirrors serializeBatchState logic). const laneByTaskId = new Map(); - const allocatedTaskByTaskId = new Map(); + const allocatedTaskByTaskId = new Map< + string, + { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane } + >(); for (const lane of lanes) { for (const allocTask of lane.tasks) { laneByTaskId.set(allocTask.taskId, lane); @@ -401,48 +415,46 @@ export function assembleDiagnosticInput( } // Build task records sorted by taskId for deterministic output. - const tasks: PersistedTaskRecord[] = [...taskIdSet] - .sort() - .map((taskId): PersistedTaskRecord => { - const lane = laneByTaskId.get(taskId); - const outcome = outcomeByTaskId.get(taskId); - const allocated = allocatedTaskByTaskId.get(taskId); - - const record: PersistedTaskRecord = { - taskId, - laneNumber: lane?.laneNumber ?? 0, - sessionName: outcome?.sessionName || lane?.laneSessionId || "", - status: outcome?.status ?? "pending", - taskFolder: "", - startedAt: outcome?.startTime ?? null, - endedAt: outcome?.endTime ?? null, - doneFileFound: outcome?.doneFileFound ?? false, - exitReason: outcome?.exitReason ?? "", - }; - - // Repo attribution from allocated task metadata (workspace mode). - if (allocated?.allocatedTask.task?.promptRepoId !== undefined) { - record.repoId = allocated.allocatedTask.task.promptRepoId; - } - if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { - record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; - } + const tasks: PersistedTaskRecord[] = [...taskIdSet].sort().map((taskId): PersistedTaskRecord => { + const lane = laneByTaskId.get(taskId); + const outcome = outcomeByTaskId.get(taskId); + const allocated = allocatedTaskByTaskId.get(taskId); + + const record: PersistedTaskRecord = { + taskId, + laneNumber: lane?.laneNumber ?? 0, + sessionName: outcome?.sessionName || lane?.laneSessionId || "", + status: outcome?.status ?? "pending", + taskFolder: "", + startedAt: outcome?.startTime ?? null, + endedAt: outcome?.endTime ?? null, + doneFileFound: outcome?.doneFileFound ?? false, + exitReason: outcome?.exitReason ?? "", + }; - // Partial progress fields from outcome. - if (outcome?.partialProgressCommits !== undefined) { - record.partialProgressCommits = outcome.partialProgressCommits; - } - if (outcome?.partialProgressBranch !== undefined) { - record.partialProgressBranch = outcome.partialProgressBranch; - } + // Repo attribution from allocated task metadata (workspace mode). + if (allocated?.allocatedTask.task?.promptRepoId !== undefined) { + record.repoId = allocated.allocatedTask.task.promptRepoId; + } + if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { + record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; + } - // v3: Exit diagnostic from outcome. - if (outcome?.exitDiagnostic !== undefined) { - record.exitDiagnostic = outcome.exitDiagnostic; - } + // Partial progress fields from outcome. + if (outcome?.partialProgressCommits !== undefined) { + record.partialProgressCommits = outcome.partialProgressCommits; + } + if (outcome?.partialProgressBranch !== undefined) { + record.partialProgressBranch = outcome.partialProgressBranch; + } - return record; - }); + // v3: Exit diagnostic from outcome. + if (outcome?.exitDiagnostic !== undefined) { + record.exitDiagnostic = outcome.exitDiagnostic; + } + + return record; + }); return { orchConfig, diff --git a/extensions/taskplane/diagnostics.ts b/extensions/taskplane/diagnostics.ts index 9c6c9eb5..7f27f60d 100644 --- a/extensions/taskplane/diagnostics.ts +++ b/extensions/taskplane/diagnostics.ts @@ -249,20 +249,20 @@ export const CONTEXT_OVERFLOW_THRESHOLD_PCT = 90; * @since TP-055 */ export const MODEL_ACCESS_ERROR_PATTERNS: readonly RegExp[] = [ - /\b(?:401|403)\b/, // HTTP auth/forbidden status codes - /\b429\b/, // HTTP rate limit - /model[_ ]not[_ ]found/i, // Model not found - /model[_ ](?:is[_ ])?unavailable/i, // Model unavailable - /model[_ ](?:has[_ ]been[_ ])?deprecated/i, // Model deprecated + /\b(?:401|403)\b/, // HTTP auth/forbidden status codes + /\b429\b/, // HTTP rate limit + /model[_ ]not[_ ]found/i, // Model not found + /model[_ ](?:is[_ ])?unavailable/i, // Model unavailable + /model[_ ](?:has[_ ]been[_ ])?deprecated/i, // Model deprecated /api[_ ]key[_ ](?:expired|invalid|revoked)/i, // API key issues - /invalid[_ ]api[_ ]key/i, // Invalid API key (alternate phrasing) + /invalid[_ ]api[_ ]key/i, // Invalid API key (alternate phrasing) /authentication[_ ](?:failed|error|required)/i, // Auth failures - /authorization[_ ](?:failed|error|denied)/i, // Authz failures - /access[_ ]denied/i, // Generic access denied - /permission[_ ]denied/i, // Permission denied - /quota[_ ]exceeded/i, // Quota exceeded - /rate[_ ]limit/i, // Rate limit (phrase) - /insufficient[_ ]quota/i, // Insufficient quota + /authorization[_ ](?:failed|error|denied)/i, // Authz failures + /access[_ ]denied/i, // Generic access denied + /permission[_ ]denied/i, // Permission denied + /quota[_ ]exceeded/i, // Quota exceeded + /rate[_ ]limit/i, // Rate limit (phrase) + /insufficient[_ ]quota/i, // Insufficient quota ]; /** @@ -277,7 +277,7 @@ export const MODEL_ACCESS_ERROR_PATTERNS: readonly RegExp[] = [ */ export function isModelAccessError(errorMessage: string): boolean { if (!errorMessage) return false; - return MODEL_ACCESS_ERROR_PATTERNS.some(pattern => pattern.test(errorMessage)); + return MODEL_ACCESS_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage)); } /** diff --git a/extensions/taskplane/discovery.ts b/extensions/taskplane/discovery.ts index 053876e6..eab1301f 100644 --- a/extensions/taskplane/discovery.ts +++ b/extensions/taskplane/discovery.ts @@ -6,7 +6,16 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from " import { join, dirname, basename, resolve } from "path"; import { FATAL_DISCOVERY_CODES } from "./types.ts"; -import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, SegmentCheckboxGroup, StepSegmentMapping, TaskArea, WorkspaceConfig } from "./types.ts"; +import type { + DiscoveryError, + DiscoveryResult, + ParsedTask, + PromptSegmentDagMetadata, + SegmentCheckboxGroup, + StepSegmentMapping, + TaskArea, + WorkspaceConfig, +} from "./types.ts"; // ── PROMPT.md Parsing ──────────────────────────────────────────────── @@ -233,8 +242,7 @@ function parseSegmentDagMetadata( metadata: null, error: { code: "SEGMENT_DAG_INVALID", - message: - `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`, + message: `Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`, taskId, taskPath: promptPath, }, @@ -258,8 +266,7 @@ function parseSegmentDagMetadata( metadata: null, error: { code: "SEGMENT_REPO_UNKNOWN", - message: - `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`, + message: `Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`, taskId, taskPath: promptPath, }, @@ -270,8 +277,7 @@ function parseSegmentDagMetadata( metadata: null, error: { code: "SEGMENT_REPO_UNKNOWN", - message: - `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`, + message: `Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`, taskId, taskPath: promptPath, }, @@ -334,8 +340,7 @@ function parseSegmentDagMetadata( metadata: null, error: { code: "SEGMENT_DAG_INVALID", - message: - `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`, + message: `Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`, taskId, taskPath: promptPath, }, @@ -516,15 +521,15 @@ export function parseStepSegmentMapping( } seenRepoIds.add(seg.repoId); - const nextSegIndex = j + 1 < segmentHeaders.length ? segmentHeaders[j + 1].index : stepContent.length; + const nextSegIndex = + j + 1 < segmentHeaders.length ? segmentHeaders[j + 1].index : stepContent.length; const segContent = stepContent.slice(seg.index, nextSegIndex); const checkboxes = extractCheckboxes(segContent); if (checkboxes.length === 0) { warnings.push({ code: "SEGMENT_STEP_EMPTY", - message: - `Task ${taskId} Step ${header.stepNumber} has empty segment "${seg.repoId}" with no checkboxes.`, + message: `Task ${taskId} Step ${header.stepNumber} has empty segment "${seg.repoId}" with no checkboxes.`, taskId, }); } @@ -650,9 +655,7 @@ export function parsePromptForOrchestrator( // ── Extract dependencies ───────────────────────────────────── const dependencies: string[] = []; - const depSectionMatch = content.match( - /^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m, - ); + const depSectionMatch = content.match(/^##\s+Dependencies\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m); if (depSectionMatch) { const depBody = depSectionMatch[1].trim(); @@ -669,9 +672,7 @@ export function parsePromptForOrchestrator( } // Pattern 2: Bullet list "- COMP-005 ...", "- **time-off/TO-014** ..." - const bulletMatches = depBody.matchAll( - /^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim, - ); + const bulletMatches = depBody.matchAll(/^[\s-]*\*?\*?((?:[a-z0-9-]+\/)?[A-Z]+-\d+)\*?\*?/gim); for (const m of bulletMatches) { const dep = normalizeDependencyReference(m[1]); if (!dependencies.includes(dep)) dependencies.push(dep); @@ -709,15 +710,13 @@ export function parsePromptForOrchestrator( if (afterHeader !== -1) { const rest = content.slice(afterHeader + 1); const nextSectionMatch = rest.search(/^##\s|^---/m); - execTargetSectionBody = nextSectionMatch !== -1 - ? rest.slice(0, nextSectionMatch) - : rest; + execTargetSectionBody = nextSectionMatch !== -1 ? rest.slice(0, nextSectionMatch) : rest; } } if (execTargetSectionBody !== null) { // Match "Repo: api" or "**Repo:** api" or "Workspace: api" with whitespace const repoLineMatch = execTargetSectionBody.match( - /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/mi, + /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/im, ); if (repoLineMatch) { const candidate = repoLineMatch[1].trim().toLowerCase(); @@ -729,9 +728,7 @@ export function parsePromptForOrchestrator( // Priority 2 (fallback): Inline "**Repo:** " or "**Workspace:** " anywhere in content if (!promptRepoId) { - const inlineRepoMatch = content.match( - /^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m, - ); + const inlineRepoMatch = content.match(/^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m); if (inlineRepoMatch) { const candidate = inlineRepoMatch[1].trim().toLowerCase(); if (REPO_ID_PATTERN.test(candidate)) { @@ -742,9 +739,7 @@ export function parsePromptForOrchestrator( // ── Extract file scope ─────────────────────────────────────── const fileScope: string[] = []; - const fileScopeMatch = content.match( - /^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m, - ); + const fileScopeMatch = content.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=\n##\s|\n---|\n$)/m); if (fileScopeMatch) { const scopeBody = fileScopeMatch[1].trim(); @@ -812,7 +807,6 @@ export function parsePromptForOrchestrator( }; } - // ── Area Scanning ──────────────────────────────────────────────────── /** @@ -887,7 +881,6 @@ export function scanAreaForTasks( return { tasks, errors }; } - // ── Completed Task Set ─────────────────────────────────────────────── /** @@ -957,7 +950,6 @@ export function buildCompletedTaskSet(areaPaths: string[]): Set { return completed; } - // ── Argument Resolution ────────────────────────────────────────────── /** @@ -995,10 +987,7 @@ export function resolveArguments( if (!areaScanPaths.includes(fullPath)) { areaScanPaths.push(fullPath); } - } else if ( - token.endsWith("PROMPT.md") && - existsSync(resolve(cwd, token)) - ) { + } else if (token.endsWith("PROMPT.md") && existsSync(resolve(cwd, token))) { // Single PROMPT.md file directTaskFolders.push(resolve(cwd, dirname(token))); } else if (existsSync(resolve(cwd, token))) { @@ -1129,7 +1118,6 @@ export function applyDependenciesFromCache( return { applied }; } - // ── Task Registry ──────────────────────────────────────────────────── /** @@ -1238,7 +1226,6 @@ export function buildTaskRegistry( return { pending, completed, errors }; } - // ── Cross-Area Dependency Resolution ───────────────────────────────── /** Candidate match for a dependency reference found in task areas. */ @@ -1407,7 +1394,6 @@ export function resolveDependencies( return errors; } - // ── Task-to-Repo Routing ───────────────────────────────────────────── /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */ @@ -1440,7 +1426,9 @@ export function resolveTaskRouting( for (const task of discovery.pending.values()) { // ── Explicit segment DAG repo validation (workspace IDs) ─ if (task.explicitSegmentDag) { - const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId)); + const unknownRepos = task.explicitSegmentDag.repoIds.filter( + (repoId) => !validRepoIds.has(repoId), + ); if (unknownRepos.length > 0) { errors.push({ code: "SEGMENT_REPO_UNKNOWN", @@ -1577,9 +1565,8 @@ export function resolveTaskRouting( if (!validRepoIds.has(seg.repoId)) { const knownRepos = [...validRepoIds.keys()]; const suggestions = suggestRepoMatches(seg.repoId, knownRepos); - const suggestionHint = suggestions.length > 0 - ? ` Did you mean: ${suggestions.join(", ")}?` - : ""; + const suggestionHint = + suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : ""; errors.push({ code: "SEGMENT_STEP_REPO_INVALID", message: @@ -1599,7 +1586,6 @@ export function resolveTaskRouting( return errors; } - // ── Discovery Pipeline (Public) ────────────────────────────────────── /** @@ -1721,7 +1707,7 @@ export function runDiscovery( for (const task of discovery.pending.values()) { if (!task.stepSegmentMap) continue; for (const step of task.stepSegmentMap) { - const stepRepoIds = step.segments.map(s => s.repoId); + const stepRepoIds = step.segments.map((s) => s.repoId); const seen = new Set(); for (const rid of stepRepoIds) { if (seen.has(rid)) { @@ -1765,26 +1751,15 @@ export function formatDiscoveryResults(result: DiscoveryResult): string { } lines.push("Pending Tasks:"); - const sortedAreas = [...byArea.entries()].sort((a, b) => - a[0].localeCompare(b[0]), - ); + const sortedAreas = [...byArea.entries()].sort((a, b) => a[0].localeCompare(b[0])); for (const [area, tasks] of sortedAreas) { lines.push(` ${area}:`); - const sortedTasks = [...tasks].sort((a, b) => - a.taskId.localeCompare(b.taskId), - ); + const sortedTasks = [...tasks].sort((a, b) => a.taskId.localeCompare(b.taskId)); for (const task of sortedTasks) { const deps = - task.dependencies.length > 0 - ? ` → depends on: ${task.dependencies.join(", ")}` - : ""; - const repo = - task.resolvedRepoId - ? ` → repo: ${task.resolvedRepoId}` - : ""; - lines.push( - ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`, - ); + task.dependencies.length > 0 ? ` → depends on: ${task.dependencies.join(", ")}` : ""; + const repo = task.resolvedRepoId ? ` → repo: ${task.resolvedRepoId}` : ""; + lines.push(` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`); } } lines.push(""); @@ -1815,4 +1790,3 @@ export function formatDiscoveryResults(result: DiscoveryResult): string { return lines.join("\n"); } - diff --git a/extensions/taskplane/engine-worker.ts b/extensions/taskplane/engine-worker.ts index f50c13cc..f35530d4 100644 --- a/extensions/taskplane/engine-worker.ts +++ b/extensions/taskplane/engine-worker.ts @@ -58,10 +58,7 @@ export type WorkerToMainMessage = /** * Messages sent FROM the main thread TO the worker. */ -export type WorkerInMessage = - | { type: "pause" } - | { type: "resume" } - | { type: "abort" }; +export type WorkerInMessage = { type: "pause" } | { type: "resume" } | { type: "abort" }; /** * Serializable form of OrchBatchRuntimeState fields synced to main thread. @@ -236,12 +233,14 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi }; try { - (process.send as ( - message: WorkerToMainMessage, - sendHandle?: unknown, - options?: unknown, - callback?: (error: Error | null) => void, - ) => boolean)(msg, undefined, undefined, () => done()); + ( + process.send as ( + message: WorkerToMainMessage, + sendHandle?: unknown, + options?: unknown, + callback?: (error: Error | null) => void, + ) => boolean + )(msg, undefined, undefined, () => done()); setTimeout(done, 75).unref(); } catch { done(); @@ -279,7 +278,9 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi }; process.once("uncaughtException", (err: unknown) => reportFatalAndExit("uncaughtException", err)); - process.once("unhandledRejection", (reason: unknown) => reportFatalAndExit("unhandledRejection", reason)); + process.once("unhandledRejection", (reason: unknown) => + reportFatalAndExit("unhandledRejection", reason), + ); // Dynamic imports — only loaded in engine context to avoid circular // dependencies when this module is imported from extension.ts @@ -349,40 +350,41 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi }; // ── Execute engine ─────────────────────────────────────────── - const enginePromise = data.mode === "resume" - ? resumeOrchBatch( - data.orchConfig, - data.runnerConfig, - data.cwd, - batchState, - onNotify, - onMonitorUpdate, - wsConfig, - data.workspaceRoot, - data.agentRoot, - data.force ?? false, - onSupervisorAlert, - data.supervisorAutonomy ?? "autonomous", - onLaneTerminated, - onLaneRespawned, - ) - : executeOrchBatch( - data.args ?? "", - data.orchConfig, - data.runnerConfig, - data.cwd, - batchState, - onNotify, - onMonitorUpdate, - wsConfig, - data.workspaceRoot, - data.agentRoot, - onEngineEvent, - onSupervisorAlert, - data.supervisorAutonomy ?? "autonomous", - onLaneTerminated, - onLaneRespawned, - ); + const enginePromise = + data.mode === "resume" + ? resumeOrchBatch( + data.orchConfig, + data.runnerConfig, + data.cwd, + batchState, + onNotify, + onMonitorUpdate, + wsConfig, + data.workspaceRoot, + data.agentRoot, + data.force ?? false, + onSupervisorAlert, + data.supervisorAutonomy ?? "autonomous", + onLaneTerminated, + onLaneRespawned, + ) + : executeOrchBatch( + data.args ?? "", + data.orchConfig, + data.runnerConfig, + data.cwd, + batchState, + onNotify, + onMonitorUpdate, + wsConfig, + data.workspaceRoot, + data.agentRoot, + onEngineEvent, + onSupervisorAlert, + data.supervisorAutonomy ?? "autonomous", + onLaneTerminated, + onLaneRespawned, + ); enginePromise .then(() => { @@ -401,7 +403,12 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi batchState.errors.push(`Unhandled engine error: ${normalized.message}`); } send({ type: "state-sync", state: serializeBatchState(batchState) }); - send({ type: "error", source: "enginePromise", message: normalized.message, stack: normalized.stack }); + send({ + type: "error", + source: "enginePromise", + message: normalized.message, + stack: normalized.stack, + }); process.disconnect?.(); }); }); diff --git a/extensions/taskplane/engine.ts b/extensions/taskplane/engine.ts index 7efe8c55..29c4df45 100644 --- a/extensions/taskplane/engine.ts +++ b/extensions/taskplane/engine.ts @@ -6,25 +6,126 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "f import { join, resolve } from "path"; import { formatDiscoveryResults, runDiscovery } from "./discovery.ts"; -import { buildReviewerEnv, buildWorkerEnv, buildWorkerExcludeEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts"; +import { + buildReviewerEnv, + buildWorkerEnv, + buildWorkerExcludeEnv, + computeTransitiveDependents, + execLog, + executeLaneV2, + executeWave, + killV2LaneAgents, + resolveCanonicalTaskPaths, +} from "./execution.ts"; import type { RuntimeBackend } from "./execution.ts"; import type { MonitorUpdateCallback } from "./execution.ts"; // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification // from the diagnostic-reports pipeline (populated by assembleDiagnosticInput). import { getCurrentBranch, runGit } from "./git.ts"; import { killAllMergeAgentsV2, mergeWaveByRepo, MergeHealthMonitor } from "./merge.ts"; -import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts"; +import { + applyMergeRetryLoop, + computeCleanupGatePolicy, + computeMergeFailurePolicy, + extractFailedRepoId, + formatRepoMergeSummary, + ORCH_MESSAGES, +} from "./messages.ts"; import type { CleanupGateRepoFailure } from "./messages.ts"; import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts"; import { resolveOperatorId } from "./naming.ts"; -import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, loadBatchState, persistRuntimeState, saveBatchHistory, saveBatchMetaRuntimeArtifact, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts"; -import { readRegistrySnapshot, isTerminalStatus, isProcessAlive as registryIsProcessAlive } from "./process-registry.ts"; +import { + applyPartialProgressToOutcomes, + buildTier0EventBase, + deleteBatchState, + emitEngineEvent, + emitTier0Event, + loadBatchHistory, + loadBatchState, + persistRuntimeState, + saveBatchHistory, + saveBatchMetaRuntimeArtifact, + seedPendingOutcomesForAllocatedLanes, + syncTaskOutcomesFromMonitor, + upsertTaskOutcome, +} from "./persistence.ts"; +import { + readRegistrySnapshot, + isTerminalStatus, + isProcessAlive as registryIsProcessAlive, +} from "./process-registry.ts"; import { drainAgentOutbox } from "./mailbox.ts"; -import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts"; -import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedSegmentRecord, SegmentExpansionRequest, SupervisorAlert, SupervisorAlertCallback, TaskRunnerConfig, TaskSegmentPlan, TaskSegmentPlanMap, TaskSegmentNode, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts"; -import { buildDependencyGraph, computeWaveAssignments, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts"; -import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, preserveSkippedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts"; -import { runPreflightCleanup, formatPreflightCleanup, enforceTelemetrySizeCap, formatSizeCap, cleanupPriorBatchArtifacts, formatPriorBatchCleanup } from "./cleanup.ts"; +import { + buildBatchProgressSnapshot, + buildEngineEventBase, + buildSegmentId, + buildSupervisorSegmentFrontierSnapshot, + defaultResilienceState, + FATAL_DISCOVERY_CODES, + generateBatchId, + TIER0_RETRYABLE_CLASSIFICATIONS, + TIER0_RETRY_BUDGETS, + tier0ScopeKey, + tier0WaveScopeKey, +} from "./types.ts"; +import type { + AllocatedLane, + AllocatedTask, + BatchHistorySummary, + BatchTaskSummary, + BatchWaveSummary, + DiscoveryResult, + EngineEventCallback, + EscalationContext, + LaneExecutionResult, + LaneTaskOutcome, + MergeWaveResult, + OrchBatchPhase, + OrchBatchRuntimeState, + OrchestratorConfig, + ParsedTask, + PersistedSegmentRecord, + SegmentExpansionRequest, + SupervisorAlert, + SupervisorAlertCallback, + TaskRunnerConfig, + TaskSegmentPlan, + TaskSegmentPlanMap, + TaskSegmentNode, + Tier0EscalationPattern, + Tier0RecoveryPattern, + TokenCounts, + WaveExecutionResult, + WorkspaceConfig, +} from "./types.ts"; +import { + buildDependencyGraph, + computeWaveAssignments, + resolveBaseBranch, + resolveRepoRoot, + validateGraph, +} from "./waves.ts"; +import { + deleteBranchBestEffort, + forceCleanupWorktree, + formatPreflightResults, + listWorktrees, + preserveFailedLaneProgress, + preserveSkippedLaneProgress, + removeAllWorktrees, + removeWorktree, + runPreflight, + safeResetWorktree, + sleepSync, +} from "./worktree.ts"; +import { + runPreflightCleanup, + formatPreflightCleanup, + enforceTelemetrySizeCap, + formatSizeCap, + cleanupPriorBatchArtifacts, + formatPriorBatchCleanup, +} from "./cleanup.ts"; // ── Tier 0: Automatic Recovery Helpers (TP-039) ───────────────────── @@ -48,7 +149,12 @@ function emitTier0Escalation( lastError: string, affectedTasks: string[], suggestion: string, - extra?: Partial>, + extra?: Partial< + Pick< + import("./persistence.ts").Tier0Event, + "taskId" | "laneNumber" | "repoId" | "classification" | "scopeKey" + > + >, ): void { const escalation: EscalationContext = { pattern, @@ -167,12 +273,16 @@ export function isAllLanesSpawnFailedWave( */ export function buildSpawnFailureAlertExtras( outcome: { exitDiagnostic?: { classification?: string } | undefined } | undefined, -): { exitCategory: import("./diagnostics.ts").ExitClassification | undefined; summaryLine: string } { +): { + exitCategory: import("./diagnostics.ts").ExitClassification | undefined; + summaryLine: string; +} { const raw = outcome?.exitDiagnostic?.classification; const exitCategory = raw as import("./diagnostics.ts").ExitClassification | undefined; - const summaryLine = raw === "spawn_failure" - ? ` Spawn failure: worker process never started — escalate immediately (do not retry)\n` - : ""; + const summaryLine = + raw === "spawn_failure" + ? ` Spawn failure: worker process never started — escalate immediately (do not retry)\n` + : ""; return { exitCategory, summaryLine }; } @@ -216,8 +326,9 @@ export function resolveBatchHistoryTaskTokens( if (v2) return v2; } - const bySession = legacyLaneTokensByKey.get(outcome.sessionName) - || legacyLaneTokensByKey.get(outcome.sessionName?.replace(/-(?:worker|reviewer)$/, "")); + const bySession = + legacyLaneTokensByKey.get(outcome.sessionName) || + legacyLaneTokensByKey.get(outcome.sessionName?.replace(/-(?:worker|reviewer)$/, "")); if (bySession) return bySession; if (laneNumber > 0) { @@ -251,7 +362,10 @@ function buildSegmentDependencyMap(plan: TaskSegmentPlan): Map depsBySegmentId.get(edge.toSegmentId)!.push(edge.fromSegmentId); } for (const [segmentId, deps] of depsBySegmentId.entries()) { - depsBySegmentId.set(segmentId, [...new Set(deps)].sort((a, b) => a.localeCompare(b))); + depsBySegmentId.set( + segmentId, + [...new Set(deps)].sort((a, b) => a.localeCompare(b)), + ); } return depsBySegmentId; } @@ -283,7 +397,11 @@ export function resolveTaskWorkerAgentId( return `${lane.laneSessionId}-worker`; } -function listPendingSegmentExpansionRequestFiles(stateRoot: string, batchId: string, agentId: string): string[] { +function listPendingSegmentExpansionRequestFiles( + stateRoot: string, + batchId: string, + agentId: string, +): string[] { const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox"); if (!existsSync(outboxDir)) return []; let entries: string[] = []; @@ -314,7 +432,12 @@ function parseSegmentExpansionRequestPayload(payload: unknown): SegmentExpansion if (typeof candidate.requestId !== "string" || !candidate.requestId.trim()) return null; if (typeof candidate.taskId !== "string" || !candidate.taskId.trim()) return null; if (typeof candidate.fromSegmentId !== "string" || !candidate.fromSegmentId.trim()) return null; - if (!Array.isArray(candidate.requestedRepoIds) || candidate.requestedRepoIds.length === 0 || candidate.requestedRepoIds.some((repoId) => typeof repoId !== "string" || !repoId.trim())) return null; + if ( + !Array.isArray(candidate.requestedRepoIds) || + candidate.requestedRepoIds.length === 0 || + candidate.requestedRepoIds.some((repoId) => typeof repoId !== "string" || !repoId.trim()) + ) + return null; if (typeof candidate.rationale !== "string") return null; if (candidate.placement !== "after-current" && candidate.placement !== "end") return null; if (!Array.isArray(candidate.edges)) return null; @@ -385,7 +508,10 @@ function parseSegmentExpansionRequests(filePaths: string[]): { return { valid, malformed }; } -function markSegmentExpansionRequestFile(filePath: string, stateSuffix: "invalid" | "discarded" | "rejected" | "processed"): boolean { +function markSegmentExpansionRequestFile( + filePath: string, + stateSuffix: "invalid" | "discarded" | "rejected" | "processed", +): boolean { try { renameSync(filePath, `${filePath}.${stateSuffix}`); return true; @@ -536,7 +662,9 @@ export function processSegmentExpansionRequestAtBoundary( return { ok: true }; } -function buildOutgoingBySegmentId(dependsOnBySegmentId: Map): Map { +function buildOutgoingBySegmentId( + dependsOnBySegmentId: Map, +): Map { const outgoingBySegmentId = new Map(); for (const segmentId of dependsOnBySegmentId.keys()) { outgoingBySegmentId.set(segmentId, []); @@ -549,12 +677,19 @@ function buildOutgoingBySegmentId(dependsOnBySegmentId: Map): } } for (const [segmentId, outgoing] of outgoingBySegmentId.entries()) { - outgoingBySegmentId.set(segmentId, [...new Set(outgoing)].sort((a, b) => a.localeCompare(b))); + outgoingBySegmentId.set( + segmentId, + [...new Set(outgoing)].sort((a, b) => a.localeCompare(b)), + ); } return outgoingBySegmentId; } -function addDependency(dependencyMap: Map, segmentId: string, depSegmentId: string): void { +function addDependency( + dependencyMap: Map, + segmentId: string, + depSegmentId: string, +): void { const deps = dependencyMap.get(segmentId) ?? []; if (!deps.includes(depSegmentId)) { deps.push(depSegmentId); @@ -563,7 +698,11 @@ function addDependency(dependencyMap: Map, segmentId: string, } } -function removeDependency(dependencyMap: Map, segmentId: string, depSegmentId: string): void { +function removeDependency( + dependencyMap: Map, + segmentId: string, + depSegmentId: string, +): void { const deps = dependencyMap.get(segmentId) ?? []; const filtered = deps.filter((dep) => dep !== depSegmentId); dependencyMap.set(segmentId, filtered); @@ -573,12 +712,15 @@ function recomputeNextPendingSegmentIndex(segmentState: SegmentFrontierTaskState const nextPendingIndex = segmentState.orderedSegments.findIndex((segment) => { return segmentState.statusBySegmentId.get(segment.segmentId) === "pending"; }); - segmentState.nextSegmentIndex = nextPendingIndex >= 0 - ? nextPendingIndex - : segmentState.orderedSegments.length; + segmentState.nextSegmentIndex = + nextPendingIndex >= 0 ? nextPendingIndex : segmentState.orderedSegments.length; } -function hasTaskInFutureSegmentRounds(segmentRounds: string[][], fromIndex: number, taskId: string): boolean { +function hasTaskInFutureSegmentRounds( + segmentRounds: string[][], + fromIndex: number, + taskId: string, +): boolean { for (let idx = fromIndex; idx < segmentRounds.length; idx++) { if (segmentRounds[idx]?.includes(taskId)) { return true; @@ -644,7 +786,10 @@ export function applySegmentExpansionMutation( const dependencyMap = new Map(); for (const [segmentId, deps] of segmentState.dependsOnBySegmentId.entries()) { - dependencyMap.set(segmentId, [...new Set(deps)].sort((a, b) => a.localeCompare(b))); + dependencyMap.set( + segmentId, + [...new Set(deps)].sort((a, b) => a.localeCompare(b)), + ); } for (const segmentId of existingNodeById.keys()) { if (!dependencyMap.has(segmentId)) { @@ -659,8 +804,14 @@ export function applySegmentExpansionMutation( const outgoingBeforeMutation = buildOutgoingBySegmentId(dependencyMap); const anchorSuccessors = outgoingBeforeMutation.get(anchorSegmentId) ?? []; - const maxOrder = segmentState.orderedSegments.reduce((max, segment) => Math.max(max, segment.order), -1); - const repoMaxSequenceByRepo = buildRepoMaxSequenceByRepo(segmentState.orderedSegments, request.taskId); + const maxOrder = segmentState.orderedSegments.reduce( + (max, segment) => Math.max(max, segment.order), + -1, + ); + const repoMaxSequenceByRepo = buildRepoMaxSequenceByRepo( + segmentState.orderedSegments, + request.taskId, + ); const newNodes: TaskSegmentNode[] = []; const segmentIdByRequestedRepoId = new Map(); @@ -777,10 +928,15 @@ export function applySegmentExpansionMutation( if (nextOrderedSegmentIds.length !== dependencyMap.size) { // Topological sort failed to cover all nodes — likely a cycle introduced // by the expansion. Reject the mutation entirely and restore original state. - execLog("batch", request.taskId, "segment expansion rejected: topological sort failed (possible cycle)", { - expected: dependencyMap.size, - covered: nextOrderedSegmentIds.length, - }); + execLog( + "batch", + request.taskId, + "segment expansion rejected: topological sort failed (possible cycle)", + { + expected: dependencyMap.size, + covered: nextOrderedSegmentIds.length, + }, + ); // Full rollback to pre-mutation state for (const node of newNodes) { segmentState.statusBySegmentId.delete(node.segmentId); @@ -865,7 +1021,9 @@ export function upsertPendingExpandedSegmentRecords( let changed = false; for (const segmentId of pendingSegmentIds) { - const segment = segmentState.orderedSegments.find((candidate) => candidate.segmentId === segmentId); + const segment = segmentState.orderedSegments.find( + (candidate) => candidate.segmentId === segmentId, + ); if (!segment) continue; const existing = segmentRecords.find((record) => record.segmentId === segmentId); if (!existing && !insertedSegmentIdSet.has(segmentId)) { @@ -904,21 +1062,23 @@ export function upsertPendingExpandedSegmentRecords( } const recordChanged = - existing.taskId !== next.taskId - || existing.repoId !== next.repoId - || existing.status !== next.status - || existing.laneId !== next.laneId - || existing.sessionName !== next.sessionName - || existing.worktreePath !== next.worktreePath - || existing.branch !== next.branch - || existing.startedAt !== next.startedAt - || existing.endedAt !== next.endedAt - || existing.retries !== next.retries - || existing.exitReason !== next.exitReason - || existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length - || existing.dependsOnSegmentIds.some((depSegmentId, idx) => depSegmentId !== next.dependsOnSegmentIds[idx]) - || existing.expandedFrom !== next.expandedFrom - || existing.expansionRequestId !== next.expansionRequestId; + existing.taskId !== next.taskId || + existing.repoId !== next.repoId || + existing.status !== next.status || + existing.laneId !== next.laneId || + existing.sessionName !== next.sessionName || + existing.worktreePath !== next.worktreePath || + existing.branch !== next.branch || + existing.startedAt !== next.startedAt || + existing.endedAt !== next.endedAt || + existing.retries !== next.retries || + existing.exitReason !== next.exitReason || + existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length || + existing.dependsOnSegmentIds.some( + (depSegmentId, idx) => depSegmentId !== next.dependsOnSegmentIds[idx], + ) || + existing.expandedFrom !== next.expandedFrom || + existing.expansionRequestId !== next.expansionRequestId; if (recordChanged) { Object.assign(existing, next); @@ -952,7 +1112,9 @@ function recordProcessedSegmentExpansionRequestId( batchState.resilience = defaultResilienceState(); } const history = batchState.resilience.repairHistory; - if (history.some((entry) => entry.strategy === "segment-expansion-request" && entry.id === requestId)) { + if ( + history.some((entry) => entry.strategy === "segment-expansion-request" && entry.id === requestId) + ) { return false; } const now = Date.now(); @@ -975,7 +1137,9 @@ function upsertRunningSegmentRecord( const activeSegmentId = task.activeSegmentId; if (!activeSegmentId) return false; - const activeSegment = segmentState.orderedSegments.find((segment) => segment.segmentId === activeSegmentId); + const activeSegment = segmentState.orderedSegments.find( + (segment) => segment.segmentId === activeSegmentId, + ); if (!activeSegment) return false; const segmentRecords = ensureSegmentRecords(batchState); @@ -983,9 +1147,7 @@ function upsertRunningSegmentRecord( const existing = segmentRecords.find((record) => record.segmentId === activeSegmentId); const now = Date.now(); - const restarted = !!existing - && existing.status !== "running" - && existing.startedAt !== null; + const restarted = !!existing && existing.status !== "running" && existing.startedAt !== null; const next: PersistedSegmentRecord = { segmentId: activeSegmentId, @@ -996,20 +1158,12 @@ function upsertRunningSegmentRecord( sessionName: lane.laneSessionId, worktreePath: lane.worktreePath, branch: lane.branch, - startedAt: existing?.status === "running" - ? existing.startedAt - : (existing?.startedAt ?? now), + startedAt: existing?.status === "running" ? existing.startedAt : (existing?.startedAt ?? now), endedAt: null, - retries: existing - ? existing.retries + (restarted ? 1 : 0) - : 0, - exitReason: existing?.status === "running" - ? existing.exitReason - : "Segment running", + retries: existing ? existing.retries + (restarted ? 1 : 0) : 0, + exitReason: existing?.status === "running" ? existing.exitReason : "Segment running", dependsOnSegmentIds, - exitDiagnostic: existing?.status === "running" - ? existing.exitDiagnostic - : undefined, + exitDiagnostic: existing?.status === "running" ? existing.exitDiagnostic : undefined, expandedFrom: existing?.expandedFrom, expansionRequestId: existing?.expansionRequestId, }; @@ -1020,22 +1174,24 @@ function upsertRunningSegmentRecord( } const changed = - existing.taskId !== next.taskId - || existing.repoId !== next.repoId - || existing.status !== next.status - || existing.laneId !== next.laneId - || existing.sessionName !== next.sessionName - || existing.worktreePath !== next.worktreePath - || existing.branch !== next.branch - || existing.startedAt !== next.startedAt - || existing.endedAt !== next.endedAt - || existing.retries !== next.retries - || existing.exitReason !== next.exitReason - || existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length - || existing.dependsOnSegmentIds.some((segmentId, idx) => segmentId !== next.dependsOnSegmentIds[idx]) - || existing.exitDiagnostic !== next.exitDiagnostic - || existing.expandedFrom !== next.expandedFrom - || existing.expansionRequestId !== next.expansionRequestId; + existing.taskId !== next.taskId || + existing.repoId !== next.repoId || + existing.status !== next.status || + existing.laneId !== next.laneId || + existing.sessionName !== next.sessionName || + existing.worktreePath !== next.worktreePath || + existing.branch !== next.branch || + existing.startedAt !== next.startedAt || + existing.endedAt !== next.endedAt || + existing.retries !== next.retries || + existing.exitReason !== next.exitReason || + existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length || + existing.dependsOnSegmentIds.some( + (segmentId, idx) => segmentId !== next.dependsOnSegmentIds[idx], + ) || + existing.exitDiagnostic !== next.exitDiagnostic || + existing.expandedFrom !== next.expandedFrom || + existing.expansionRequestId !== next.expansionRequestId; if (changed) { Object.assign(existing, next); @@ -1052,16 +1208,17 @@ function upsertTerminalSegmentRecord( outcome: LaneTaskOutcome | undefined, lane: AllocatedLane | undefined, ): boolean { - const segment = segmentState.orderedSegments.find((candidate) => candidate.segmentId === segmentId); + const segment = segmentState.orderedSegments.find( + (candidate) => candidate.segmentId === segmentId, + ); if (!segment) return false; const segmentRecords = ensureSegmentRecords(batchState); const existing = segmentRecords.find((record) => record.segmentId === segmentId); const now = Date.now(); const dependsOnSegmentIds = segmentState.dependsOnBySegmentId.get(segmentId) ?? []; - const nextExitDiagnostic = status === "failed" - ? (outcome?.exitDiagnostic ?? existing?.exitDiagnostic) - : undefined; + const nextExitDiagnostic = + status === "failed" ? (outcome?.exitDiagnostic ?? existing?.exitDiagnostic) : undefined; const next: PersistedSegmentRecord = { segmentId, @@ -1075,11 +1232,13 @@ function upsertTerminalSegmentRecord( startedAt: existing?.startedAt ?? outcome?.startTime ?? now, endedAt: outcome?.endTime ?? now, retries: existing?.retries ?? 0, - exitReason: outcome?.exitReason ?? (status === "succeeded" - ? "Segment completed" - : status === "failed" - ? "Segment failed" - : "Segment skipped"), + exitReason: + outcome?.exitReason ?? + (status === "succeeded" + ? "Segment completed" + : status === "failed" + ? "Segment failed" + : "Segment skipped"), dependsOnSegmentIds, exitDiagnostic: nextExitDiagnostic, expandedFrom: existing?.expandedFrom, @@ -1092,22 +1251,24 @@ function upsertTerminalSegmentRecord( } const changed = - existing.taskId !== next.taskId - || existing.repoId !== next.repoId - || existing.status !== next.status - || existing.laneId !== next.laneId - || existing.sessionName !== next.sessionName - || existing.worktreePath !== next.worktreePath - || existing.branch !== next.branch - || existing.startedAt !== next.startedAt - || existing.endedAt !== next.endedAt - || existing.retries !== next.retries - || existing.exitReason !== next.exitReason - || existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length - || existing.dependsOnSegmentIds.some((depSegmentId, idx) => depSegmentId !== next.dependsOnSegmentIds[idx]) - || existing.exitDiagnostic !== next.exitDiagnostic - || existing.expandedFrom !== next.expandedFrom - || existing.expansionRequestId !== next.expansionRequestId; + existing.taskId !== next.taskId || + existing.repoId !== next.repoId || + existing.status !== next.status || + existing.laneId !== next.laneId || + existing.sessionName !== next.sessionName || + existing.worktreePath !== next.worktreePath || + existing.branch !== next.branch || + existing.startedAt !== next.startedAt || + existing.endedAt !== next.endedAt || + existing.retries !== next.retries || + existing.exitReason !== next.exitReason || + existing.dependsOnSegmentIds.length !== next.dependsOnSegmentIds.length || + existing.dependsOnSegmentIds.some( + (depSegmentId, idx) => depSegmentId !== next.dependsOnSegmentIds[idx], + ) || + existing.exitDiagnostic !== next.exitDiagnostic || + existing.expandedFrom !== next.expandedFrom || + existing.expansionRequestId !== next.expansionRequestId; if (changed) { Object.assign(existing, next); @@ -1165,7 +1326,7 @@ export function linearizeTaskSegmentPlan(plan: TaskSegmentPlan): TaskSegmentNode const ready: TaskSegmentNode[] = plan.segments .filter((segment) => (indegree.get(segment.segmentId) ?? 0) === 0) - .sort((a, b) => (a.order - b.order) || a.segmentId.localeCompare(b.segmentId)); + .sort((a, b) => a.order - b.order || a.segmentId.localeCompare(b.segmentId)); const ordered: TaskSegmentNode[] = []; while (ready.length > 0) { @@ -1178,7 +1339,7 @@ export function linearizeTaskSegmentPlan(plan: TaskSegmentPlan): TaskSegmentNode const depNode = nodeById.get(dep); if (depNode) { ready.push(depNode); - ready.sort((a, b) => (a.order - b.order) || a.segmentId.localeCompare(b.segmentId)); + ready.sort((a, b) => a.order - b.order || a.segmentId.localeCompare(b.segmentId)); } } } @@ -1186,7 +1347,9 @@ export function linearizeTaskSegmentPlan(plan: TaskSegmentPlan): TaskSegmentNode // Defensive fallback: malformed/cyclic plans retain deterministic segment order. if (ordered.length !== plan.segments.length) { - return [...plan.segments].sort((a, b) => (a.order - b.order) || a.segmentId.localeCompare(b.segmentId)); + return [...plan.segments].sort( + (a, b) => a.order - b.order || a.segmentId.localeCompare(b.segmentId), + ); } return ordered; @@ -1236,8 +1399,8 @@ export function resolveDisplayWaveNumber( fallbackTotal?: number, ): { displayWave: number; displayTotal: number } { const taskWaveIdx = roundToTaskWave?.[roundIdx]; - const displayWave = (taskWaveIdx != null) ? taskWaveIdx + 1 : roundIdx + 1; - const displayTotal = taskLevelWaveCount ?? fallbackTotal ?? (roundIdx + 1); + const displayWave = taskWaveIdx != null ? taskWaveIdx + 1 : roundIdx + 1; + const displayTotal = taskLevelWaveCount ?? fallbackTotal ?? roundIdx + 1; return { displayWave, displayTotal }; } @@ -1272,16 +1435,16 @@ export function buildSegmentFrontierWaves( // Resolve packetTaskPath to absolute so it works from any repo's worktree. // task.taskFolder is relative to workspace root (e.g., "shared-libs/task-management/.../TP-004"). // When a segment executes in a different repo, the lane worktree won't contain this path. - task.packetTaskPath = workspaceRoot - ? resolve(workspaceRoot, task.taskFolder) - : task.taskFolder; + task.packetTaskPath = workspaceRoot ? resolve(workspaceRoot, task.taskFolder) : task.taskFolder; } taskStateById.set(taskId, { taskId, orderedSegments, nextSegmentIndex: 0, - statusBySegmentId: new Map(orderedSegments.map((segment) => [segment.segmentId, "pending" as SegmentLifecycleStatus])), + statusBySegmentId: new Map( + orderedSegments.map((segment) => [segment.segmentId, "pending" as SegmentLifecycleStatus]), + ), dependsOnBySegmentId, terminalStatus: "pending", }); @@ -1373,7 +1536,7 @@ async function attemptWorkerCrashRetry( if (!lane) continue; // Find the task outcome to get exit info - const outcome = allTaskOutcomes.find(o => o.taskId === taskId); + const outcome = allTaskOutcomes.find((o) => o.taskId === taskId); if (!outcome) continue; // Use the canonical exit diagnostic classification when available. @@ -1384,7 +1547,9 @@ async function attemptWorkerCrashRetry( const classification = outcome.exitDiagnostic?.classification; if (!classification) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} has no exit diagnostic classification — skipping auto-retry (conservative)`, ); continue; @@ -1398,7 +1563,9 @@ async function attemptWorkerCrashRetry( // (spawn_failure is not in the set), but the explicit early-return // here gives operators a clearer log message at the gate site. if (classification === "spawn_failure") { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} spawn_failure — operator action required, NOT auto-retrying (TP-190)`, ); continue; @@ -1406,7 +1573,9 @@ async function attemptWorkerCrashRetry( // Check if retryable if (!TIER0_RETRYABLE_CLASSIFICATIONS.has(classification)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} exit classification "${classification}" is not retryable — skipping`, ); continue; @@ -1414,7 +1583,9 @@ async function attemptWorkerCrashRetry( // model_access_error is handled by attemptModelFallbackRetry() — skip here if (classification === "model_access_error") { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} classified as model_access_error — deferring to model fallback handler`, ); continue; @@ -1424,13 +1595,22 @@ async function attemptWorkerCrashRetry( const scopeKey = tier0ScopeKey("worker_crash", taskId, waveIdx); const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0; if (currentCount >= budget.maxRetries) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} retry budget exhausted (${currentCount}/${budget.maxRetries}) — skipping`, { scopeKey }, ); // Emit exhausted event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "worker_crash", currentCount, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "worker_crash", + currentCount, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1440,8 +1620,15 @@ async function attemptWorkerCrashRetry( affectedTaskIds: [taskId], suggestion: `Task ${taskId} failed with ${classification} and exhausted ${budget.maxRetries} retry attempt(s). Consider investigating the root cause or manually re-running the task.`, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "worker_crash", currentCount, budget.maxRetries, - `Retry budget exhausted for task ${taskId} (${classification})`, [taskId], + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "worker_crash", + currentCount, + budget.maxRetries, + `Retry budget exhausted for task ${taskId} (${classification})`, + [taskId], `Task ${taskId} failed with ${classification} and exhausted ${budget.maxRetries} retry attempt(s). Consider investigating the root cause or manually re-running the task.`, { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey }, ); @@ -1452,7 +1639,9 @@ async function attemptWorkerCrashRetry( batchState.resilience.retryCountByScope[scopeKey] = currentCount + 1; retriedCount++; - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: retrying task ${taskId} (worker_crash, attempt ${currentCount + 1}/${budget.maxRetries}, classification=${classification})`, { scopeKey, classification }, ); @@ -1463,7 +1652,14 @@ async function attemptWorkerCrashRetry( // Emit attempt event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "worker_crash", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_attempt", + batchState.batchId, + waveIdx, + "worker_crash", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1478,7 +1674,7 @@ async function attemptWorkerCrashRetry( } // Find the specific AllocatedTask - const allocatedTask = lane.tasks.find(t => t.taskId === taskId); + const allocatedTask = lane.tasks.find((t) => t.taskId === taskId); if (!allocatedTask) continue; // Re-execute: create a single-task lane config for executeLane @@ -1488,9 +1684,7 @@ async function attemptWorkerCrashRetry( }; const isWsMode = !!workspaceConfig; - const wsRoot = workspaceConfig - ? resolve(workspaceConfig.configPath, "..", "..") - : undefined; + const wsRoot = workspaceConfig ? resolve(workspaceConfig.configPath, "..", "..") : undefined; try { // Use a fresh pause signal for the retry — the batch pauseSignal @@ -1504,7 +1698,12 @@ async function attemptWorkerCrashRetry( retryPauseSignal, wsRoot, isWsMode, - { ORCH_BATCH_ID: batchState.batchId, ...buildWorkerEnv(runnerConfig?.worker), ...buildReviewerEnv(runnerConfig?.reviewer), ...buildWorkerExcludeEnv(runnerConfig?.workerExcludeExtensions) }, // TP-089: ensure mailbox works for retries + { + ORCH_BATCH_ID: batchState.batchId, + ...buildWorkerEnv(runnerConfig?.worker), + ...buildReviewerEnv(runnerConfig?.reviewer), + ...buildWorkerExcludeEnv(runnerConfig?.workerExcludeExtensions), + }, // TP-089: ensure mailbox works for retries ); const retryOutcome = retryResult.tasks[0]; @@ -1518,7 +1717,7 @@ async function attemptWorkerCrashRetry( // Update lane results — replace the failed task outcome for (const lr of waveResult.laneResults) { - const taskIdx = lr.tasks.findIndex(t => t.taskId === taskId); + const taskIdx = lr.tasks.findIndex((t) => t.taskId === taskId); if (taskIdx !== -1) { lr.tasks[taskIdx] = retryOutcome; break; @@ -1528,18 +1727,19 @@ async function attemptWorkerCrashRetry( // Update allTaskOutcomes upsertTaskOutcome(allTaskOutcomes, retryOutcome); - execLog("batch", batchState.batchId, - `tier0: task ${taskId} retry succeeded`, - { scopeKey }, - ); - onNotify( - `✅ Tier 0: Task ${taskId} retry succeeded`, - "info", - ); + execLog("batch", batchState.batchId, `tier0: task ${taskId} retry succeeded`, { scopeKey }); + onNotify(`✅ Tier 0: Task ${taskId} retry succeeded`, "info"); // Emit success event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "worker_crash", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_success", + batchState.batchId, + waveIdx, + "worker_crash", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1552,16 +1752,23 @@ async function attemptWorkerCrashRetry( if (retryOutcome) { upsertTaskOutcome(allTaskOutcomes, retryOutcome); } - execLog("batch", batchState.batchId, - `tier0: task ${taskId} retry failed again`, - { scopeKey, exitReason: retryOutcome?.exitReason }, - ); + execLog("batch", batchState.batchId, `tier0: task ${taskId} retry failed again`, { + scopeKey, + exitReason: retryOutcome?.exitReason, + }); // Emit exhausted event (retry failed and budget now consumed) const retryFailError = retryOutcome?.exitReason ?? `Task ${taskId} retry failed again`; const retryFailSuggestion = `Task ${taskId} failed again after retry (${classification}). The failure may be persistent — investigate task logs.`; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "worker_crash", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "worker_crash", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1571,23 +1778,37 @@ async function attemptWorkerCrashRetry( affectedTaskIds: [taskId], suggestion: retryFailSuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "worker_crash", currentCount + 1, budget.maxRetries, - retryFailError, [taskId], retryFailSuggestion, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "worker_crash", + currentCount + 1, + budget.maxRetries, + retryFailError, + [taskId], + retryFailSuggestion, { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey }, ); } } catch (err: unknown) { failedRetries.push(taskId); const errMsg = err instanceof Error ? err.message : String(err); - execLog("batch", batchState.batchId, - `tier0: task ${taskId} retry threw error: ${errMsg}`, - { scopeKey }, - ); + execLog("batch", batchState.batchId, `tier0: task ${taskId} retry threw error: ${errMsg}`, { + scopeKey, + }); // Emit exhausted event for exception during retry const exceptionSuggestion = `Task ${taskId} retry threw an exception: ${errMsg}. Investigate the execution environment.`; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "worker_crash", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "worker_crash", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1597,8 +1818,16 @@ async function attemptWorkerCrashRetry( affectedTaskIds: [taskId], suggestion: exceptionSuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "worker_crash", currentCount + 1, budget.maxRetries, - errMsg, [taskId], exceptionSuggestion, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "worker_crash", + currentCount + 1, + budget.maxRetries, + errMsg, + [taskId], + exceptionSuggestion, { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey }, ); } @@ -1680,7 +1909,7 @@ async function attemptModelFallbackRetry( const lane = taskToLane.get(taskId); if (!lane) continue; - const outcome = allTaskOutcomes.find(o => o.taskId === taskId); + const outcome = allTaskOutcomes.find((o) => o.taskId === taskId); if (!outcome) continue; const classification = outcome.exitDiagnostic?.classification; @@ -1690,12 +1919,21 @@ async function attemptModelFallbackRetry( const scopeKey = tier0ScopeKey("model_fallback", taskId, waveIdx); const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0; if (currentCount >= budget.maxRetries) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} model fallback retry budget exhausted (${currentCount}/${budget.maxRetries})`, { scopeKey }, ); emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "model_fallback", + currentCount, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1705,8 +1943,15 @@ async function attemptModelFallbackRetry( affectedTaskIds: [taskId], suggestion: `Task ${taskId} failed with model_access_error and model fallback retry exhausted. Check API key validity and model availability.`, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount, budget.maxRetries, - `Model fallback retry budget exhausted for task ${taskId}`, [taskId], + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "model_fallback", + currentCount, + budget.maxRetries, + `Model fallback retry budget exhausted for task ${taskId}`, + [taskId], `Task ${taskId} failed with model_access_error and model fallback retry exhausted. Check API key validity and model availability.`, { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey }, ); @@ -1718,7 +1963,9 @@ async function attemptModelFallbackRetry( retriedCount++; const failedModel = outcome.exitDiagnostic?.errorMessage || "configured model"; - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: model fallback — retrying task ${taskId} without explicit model (${failedModel} unavailable)`, { scopeKey, classification }, ); @@ -1729,7 +1976,14 @@ async function attemptModelFallbackRetry( // Emit attempt event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_attempt", + batchState.batchId, + waveIdx, + "model_fallback", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1744,7 +1998,7 @@ async function attemptModelFallbackRetry( } // Find the specific AllocatedTask - const allocatedTask = lane.tasks.find(t => t.taskId === taskId); + const allocatedTask = lane.tasks.find((t) => t.taskId === taskId); if (!allocatedTask) continue; // Re-execute with model fallback env var @@ -1754,16 +2008,19 @@ async function attemptModelFallbackRetry( }; const isWsMode = !!workspaceConfig; - const wsRoot = workspaceConfig - ? resolve(workspaceConfig.configPath, "..", "..") - : undefined; + const wsRoot = workspaceConfig ? resolve(workspaceConfig.configPath, "..", "..") : undefined; try { const retryPauseSignal = { paused: false }; // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal // the task-runner to use the session model instead of configured model. // TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries. - const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer), ...buildWorkerExcludeEnv(runnerConfig?.workerExcludeExtensions) }; + const modelFallbackEnv = { + TASKPLANE_MODEL_FALLBACK: "1", + ORCH_BATCH_ID: batchState.batchId, + ...buildReviewerEnv(runnerConfig?.reviewer), + ...buildWorkerExcludeEnv(runnerConfig?.workerExcludeExtensions), + }; const retryResult = await executeLaneV2( retryLane, orchConfig, @@ -1785,7 +2042,7 @@ async function attemptModelFallbackRetry( // Update lane results for (const lr of waveResult.laneResults) { - const taskIdx = lr.tasks.findIndex(t => t.taskId === taskId); + const taskIdx = lr.tasks.findIndex((t) => t.taskId === taskId); if (taskIdx !== -1) { lr.tasks[taskIdx] = retryOutcome; break; @@ -1794,17 +2051,20 @@ async function attemptModelFallbackRetry( upsertTaskOutcome(allTaskOutcomes, retryOutcome); - execLog("batch", batchState.batchId, - `tier0: task ${taskId} model fallback retry succeeded`, - { scopeKey }, - ); - onNotify( - `✅ Model fallback: Task ${taskId} succeeded with session model`, - "info", - ); + execLog("batch", batchState.batchId, `tier0: task ${taskId} model fallback retry succeeded`, { + scopeKey, + }); + onNotify(`✅ Model fallback: Task ${taskId} succeeded with session model`, "info"); emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_success", + batchState.batchId, + waveIdx, + "model_fallback", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1817,14 +2077,21 @@ async function attemptModelFallbackRetry( if (retryOutcome) { upsertTaskOutcome(allTaskOutcomes, retryOutcome); } - execLog("batch", batchState.batchId, - `tier0: task ${taskId} model fallback retry failed`, - { scopeKey, exitReason: retryOutcome?.exitReason }, - ); + execLog("batch", batchState.batchId, `tier0: task ${taskId} model fallback retry failed`, { + scopeKey, + exitReason: retryOutcome?.exitReason, + }); const retryFailError = retryOutcome?.exitReason ?? `Task ${taskId} model fallback retry failed`; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "model_fallback", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1834,8 +2101,15 @@ async function attemptModelFallbackRetry( affectedTaskIds: [taskId], suggestion: `Task ${taskId} failed even with session model fallback. Investigate task logs.`, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries, - retryFailError, [taskId], + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "model_fallback", + currentCount + 1, + budget.maxRetries, + retryFailError, + [taskId], `Task ${taskId} failed even with session model fallback. Investigate task logs.`, { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey }, ); @@ -1843,12 +2117,21 @@ async function attemptModelFallbackRetry( } catch (err: unknown) { failedRetries.push(taskId); const errMsg = err instanceof Error ? err.message : String(err); - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: task ${taskId} model fallback retry threw error: ${errMsg}`, { scopeKey }, ); emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "model_fallback", + currentCount + 1, + budget.maxRetries, + ), taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, @@ -1858,8 +2141,15 @@ async function attemptModelFallbackRetry( affectedTaskIds: [taskId], suggestion: `Model fallback retry for task ${taskId} threw an exception: ${errMsg}`, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries, - errMsg, [taskId], + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "model_fallback", + currentCount + 1, + budget.maxRetries, + errMsg, + [taskId], `Model fallback retry for task ${taskId} threw an exception: ${errMsg}`, { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey }, ); @@ -1921,22 +2211,39 @@ async function attemptStaleWorktreeRecovery( const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0; if (currentCount >= budget.maxRetries) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: stale worktree retry budget exhausted (${currentCount}/${budget.maxRetries})`, { scopeKey }, ); const staleExhaustedError = waveResult.allocationError.message; const staleExhaustedSuggestion = `Stale worktree cleanup exhausted ${budget.maxRetries} retry(s). Manually remove worktrees and prune git state.`; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "stale_worktree", currentCount, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "stale_worktree", + currentCount, + budget.maxRetries, + ), repoId: null, // wave-scoped error: staleExhaustedError, scopeKey, affectedTaskIds: waveTasks, suggestion: staleExhaustedSuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "stale_worktree", currentCount, budget.maxRetries, - staleExhaustedError, waveTasks, staleExhaustedSuggestion, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "stale_worktree", + currentCount, + budget.maxRetries, + staleExhaustedError, + waveTasks, + staleExhaustedSuggestion, { repoId: null, scopeKey }, ); return null; @@ -1944,14 +2251,23 @@ async function attemptStaleWorktreeRecovery( batchState.resilience.retryCountByScope[scopeKey] = currentCount + 1; - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: attempting stale worktree recovery (attempt ${currentCount + 1}/${budget.maxRetries})`, { scopeKey, allocationError: waveResult.allocationError.message }, ); // Emit attempt event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "stale_worktree", currentCount + 1, budget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_attempt", + batchState.batchId, + waveIdx, + "stale_worktree", + currentCount + 1, + budget.maxRetries, + ), repoId: null, // wave-scoped: allocation failure may span multiple repos classification: waveResult.allocationError.code, cooldownMs: budget.cooldownMs, @@ -1988,7 +2304,9 @@ async function attemptStaleWorktreeRecovery( } // Retry the wave execution - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: retrying wave ${waveIdx + 1} after stale worktree cleanup`, ); @@ -2014,12 +2332,14 @@ async function attemptStaleWorktreeRecovery( tools: runnerConfig?.reviewer?.tools || "", excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [], }, - runnerConfig?.worker ? { - model: runnerConfig.worker.model || "", - thinking: runnerConfig.worker.thinking || "", - tools: runnerConfig.worker.tools || "", - excludeExtensions: runnerConfig.worker.excludeExtensions ?? [], - } : undefined, + runnerConfig?.worker + ? { + model: runnerConfig.worker.model || "", + thinking: runnerConfig.worker.thinking || "", + tools: runnerConfig.worker.tools || "", + excludeExtensions: runnerConfig.worker.excludeExtensions ?? [], + } + : undefined, runnerConfig?.workerExcludeExtensions ?? [], onLaneTerminated, onLaneRespawned, @@ -2028,7 +2348,6 @@ async function attemptStaleWorktreeRecovery( return retryResult; } - export interface RuntimeBackendSelection { backend: RuntimeBackend; isSingleTask: boolean; @@ -2050,8 +2369,7 @@ export function selectRuntimeBackend( const isSingleTask = rawWaves.length === 1 && rawWaves[0]?.length === 1; const isRepoMode = !workspaceConfig; const argTokens = args.trim().split(/\s+/).filter(Boolean); - const isDirectPromptTarget = - argTokens.length === 1 && /PROMPT\.md$/i.test(argTokens[0]); + const isDirectPromptTarget = argTokens.length === 1 && /PROMPT\.md$/i.test(argTokens[0]); // TP-108: Runtime V2 for all repo-mode batches. // TP-109: Workspace mode also uses V2 now that packet-home paths are @@ -2166,20 +2484,40 @@ export async function executeOrchBatch( if (terminalEventEmitted) return; terminalEventEmitted = true; if (batchState.phase === "completed" || batchState.phase === "failed") { - emitEvent(stateRoot, { - ...buildEngineEventBase("batch_complete", batchState.batchId, batchState.currentWaveIndex, batchState.phase), - succeededTasks: batchState.succeededTasks, - failedTasks: batchState.failedTasks, - skippedTasks: batchState.skippedTasks, - blockedTasks: batchState.blockedTasks, - batchDurationMs: batchState.endedAt ? batchState.endedAt - batchState.startedAt : undefined, - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase( + "batch_complete", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), + succeededTasks: batchState.succeededTasks, + failedTasks: batchState.failedTasks, + skippedTasks: batchState.skippedTasks, + blockedTasks: batchState.blockedTasks, + batchDurationMs: batchState.endedAt ? batchState.endedAt - batchState.startedAt : undefined, + }, + onEngineEvent, + ); } else if (batchState.phase === "paused" || batchState.phase === "stopped") { - emitEvent(stateRoot, { - ...buildEngineEventBase("batch_paused", batchState.batchId, batchState.currentWaveIndex, batchState.phase), - reason: reason || (batchState.errors.length > 0 ? batchState.errors[batchState.errors.length - 1] : "paused"), - failedTasks: batchState.failedTasks, - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase( + "batch_paused", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), + reason: + reason || + (batchState.errors.length > 0 ? batchState.errors[batchState.errors.length - 1] : "paused"), + failedTasks: batchState.failedTasks, + }, + onEngineEvent, + ); } }; @@ -2200,7 +2538,10 @@ export async function executeOrchBatch( batchState.phase = "failed"; batchState.endedAt = Date.now(); batchState.errors.push("Cannot determine current branch (detached HEAD or not a git repo)"); - onNotify("❌ Cannot determine current branch. Ensure HEAD is on a branch (not detached).", "error"); + onNotify( + "❌ Cannot determine current branch. Ensure HEAD is on a branch (not detached).", + "error", + ); emitTerminalEvent(); return; } @@ -2258,10 +2599,17 @@ export async function executeOrchBatch( // Check persisted state — a prior batch may still be active try { const state = loadBatchState(stateRoot); - if (state && state.phase !== "completed" && state.phase !== "failed" && state.phase !== "stopped") { + if ( + state && + state.phase !== "completed" && + state.phase !== "failed" && + state.phase !== "stopped" + ) { return true; } - } catch { /* state unreadable — safe to sweep */ } + } catch { + /* state unreadable — safe to sweep */ + } return false; }, now: () => Date.now(), @@ -2324,14 +2672,12 @@ export async function executeOrchBatch( "info", ); } - const hasStrictErrors = fatalErrors.some( - (e) => e.code === "TASK_ROUTING_STRICT", - ); + const hasStrictErrors = fatalErrors.some((e) => e.code === "TASK_ROUTING_STRICT"); if (hasStrictErrors) { onNotify( "💡 Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" + - " Add a `## Execution Target` section with `Repo: ` to each task's PROMPT.md.\n" + - " To disable strict routing, set `routing.strict: false` in workspace config.", + " Add a `## Execution Target` section with `Repo: ` to each task's PROMPT.md.\n" + + " To disable strict routing, set `routing.strict: false` in workspace config.", "info", ); } @@ -2356,7 +2702,7 @@ export async function executeOrchBatch( if (!validation.valid) { batchState.phase = "failed"; batchState.endedAt = Date.now(); - const errMsgs = validation.errors.map(e => `[${e.code}] ${e.message}`).join("\n"); + const errMsgs = validation.errors.map((e) => `[${e.code}] ${e.message}`).join("\n"); batchState.errors.push(`Graph validation failed:\n${errMsgs}`); onNotify(`❌ Dependency graph errors:\n${errMsgs}`, "error"); emitTerminalEvent(); @@ -2375,14 +2721,16 @@ export async function executeOrchBatch( if (waveComputation.errors.length > 0) { batchState.phase = "failed"; batchState.endedAt = Date.now(); - const errMsgs = waveComputation.errors.map(e => `[${e.code}] ${e.message}`).join("\n"); + const errMsgs = waveComputation.errors.map((e) => `[${e.code}] ${e.message}`).join("\n"); batchState.errors.push(`Wave computation failed:\n${errMsgs}`); onNotify(`❌ Wave computation errors:\n${errMsgs}`, "error"); emitTerminalEvent(); return; } - const taskWaves = waveComputation.waves.map((wave) => wave.tasks.map((assignment) => assignment.taskId)); + const taskWaves = waveComputation.waves.map((wave) => + wave.tasks.map((assignment) => assignment.taskId), + ); const packetRepoId = workspaceConfig?.routing?.taskPacketRepo; const frontier = buildSegmentFrontierWaves( taskWaves, @@ -2428,19 +2776,27 @@ export async function executeOrchBatch( const repoBranch = getCurrentBranch(rRoot) || "HEAD"; const result = runGit(["branch", orchBranch, repoBranch], rRoot); if (result.ok) { - execLog("batch", batchState.batchId, `created orch branch in ${repoId}`, { orchBranch, base: repoBranch }); + execLog("batch", batchState.batchId, `created orch branch in ${repoId}`, { + orchBranch, + base: repoBranch, + }); } else { const errDetail = result.stderr || result.stdout || "unknown error"; execLog("batch", batchState.batchId, `failed to create orch branch in ${repoId}: ${errDetail}`); batchState.phase = "failed"; batchState.endedAt = Date.now(); - batchState.errors.push(`Failed to create orch branch '${orchBranch}' in ${repoId}: ${errDetail}`); + batchState.errors.push( + `Failed to create orch branch '${orchBranch}' in ${repoId}: ${errDetail}`, + ); onNotify(`❌ Failed to create orch branch '${orchBranch}' in ${repoId}: ${errDetail}`, "error"); orchBranchFailed = true; break; } } - if (orchBranchFailed) { emitTerminalEvent(); return; } + if (orchBranchFailed) { + emitTerminalEvent(); + return; + } } else { const branchResult = runGit(["branch", orchBranch, batchState.baseBranch], repoRoot); if (!branchResult.ok) { @@ -2452,7 +2808,10 @@ export async function executeOrchBatch( emitTerminalEvent(); return; } - execLog("batch", batchState.batchId, "created orch branch", { orchBranch, baseBranch: batchState.baseBranch }); + execLog("batch", batchState.batchId, "created orch branch", { + orchBranch, + baseBranch: batchState.baseBranch, + }); } batchState.orchBranch = orchBranch; @@ -2466,7 +2825,15 @@ export async function executeOrchBatch( batchState.phase = "executing"; // ── TS-009: Persist state on batch start (after wave computation) ── - persistRuntimeState("batch-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "batch-start", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // ── TP-187 (#539): Persist batch-meta runtime artifact ────────────── // Captures the wave plan and core scalars to a runtime-side file that @@ -2476,7 +2843,7 @@ export async function executeOrchBatch( saveBatchMetaRuntimeArtifact(stateRoot, { schemaVersion: 1, batchId: batchState.batchId, - wavePlan: wavePlan.map(wave => [...wave]), + wavePlan: wavePlan.map((wave) => [...wave]), baseBranch: batchState.baseBranch, orchBranch: batchState.orchBranch, mode: workspaceConfig ? "workspace" : "repo", @@ -2506,10 +2873,21 @@ export async function executeOrchBatch( execLog("batch", batchState.batchId, `batch paused before wave ${waveIdx + 1}`); { const { displayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); - onNotify(`⏸️ Batch paused before wave ${displayWave}. Resume not yet implemented (TS-009).`, "warning"); + onNotify( + `⏸️ Batch paused before wave ${displayWave}. Resume not yet implemented (TS-009).`, + "warning", + ); } // ── TS-009: Persist state on pause ── - persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "pause-before-wave", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // TP-040: Emit batch_paused event (via terminal helper for dedup) emitTerminalEvent(`Paused before wave ${waveIdx + 1}`); break; @@ -2518,7 +2896,15 @@ export async function executeOrchBatch( batchState.currentWaveIndex = waveIdx; // ── TS-009: Persist state on wave index change ── - persistRuntimeState("wave-index-change", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "wave-index-change", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // Filter wave tasks against blocked + terminal task sets, then bind the // next active segment for each surviving task. @@ -2563,26 +2949,48 @@ export async function executeOrchBatch( } if (blockedInWave.length > 0) { - execLog("batch", batchState.batchId, `wave ${waveIdx + 1}: skipping ${blockedInWave.length} blocked task(s)`, { - blocked: blockedInWave.join(","), - }); + execLog( + "batch", + batchState.batchId, + `wave ${waveIdx + 1}: skipping ${blockedInWave.length} blocked task(s)`, + { + blocked: blockedInWave.join(","), + }, + ); batchState.blockedTasks += blockedInWave.length; } if (terminalInWave.length > 0) { - execLog("batch", batchState.batchId, `wave ${waveIdx + 1}: skipping ${terminalInWave.length} terminal task(s)`, { - terminal: terminalInWave.join(","), - }); + execLog( + "batch", + batchState.batchId, + `wave ${waveIdx + 1}: skipping ${terminalInWave.length} terminal task(s)`, + { + terminal: terminalInWave.join(","), + }, + ); } if (waveTasks.length === 0) { - execLog("batch", batchState.batchId, `wave ${waveIdx + 1}: no tasks to execute (all blocked or terminal)`); + execLog( + "batch", + batchState.batchId, + `wave ${waveIdx + 1}: no tasks to execute (all blocked or terminal)`, + ); continue; } const handleWaveMonitorUpdate: MonitorUpdateCallback = (monitorState) => { const changed = syncTaskOutcomesFromMonitor(monitorState, allTaskOutcomes); if (changed) { - persistRuntimeState("task-transition", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "task-transition", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } onMonitorUpdate?.(monitorState); }; @@ -2593,13 +3001,23 @@ export async function executeOrchBatch( batchState.currentLanes = lanes; // TP-166: Use task-level wave number for operator display - const { displayWave, displayTotal } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); + const { displayWave, displayTotal } = resolveDisplayWaveNumber( + waveIdx, + roundToTaskWave, + taskLevelWaveCount, + ); onNotify( ORCH_MESSAGES.orchWaveStart(displayWave, displayTotal, waveTasks.length, lanes.length), "info", ); // TP-148: Build per-task segment context for the wave_start event - const waveSegmentContext: Array<{ taskId: string; segmentIndex: number; totalSegments: number; repoId: string; segmentId: string }> = []; + const waveSegmentContext: Array<{ + taskId: string; + segmentIndex: number; + totalSegments: number; + repoId: string; + segmentId: string; + }> = []; for (const taskId of waveTasks) { const segState = segmentStateByTask.get(taskId); if (segState && segState.orderedSegments.length > 1) { @@ -2616,12 +3034,16 @@ export async function executeOrchBatch( } } } - emitEvent(stateRoot, { - ...buildEngineEventBase("wave_start", batchState.batchId, waveIdx, batchState.phase), - taskIds: waveTasks, - laneCount: lanes.length, - ...(waveSegmentContext.length > 0 ? { segmentContext: waveSegmentContext } : {}), - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("wave_start", batchState.batchId, waveIdx, batchState.phase), + taskIds: waveTasks, + laneCount: lanes.length, + ...(waveSegmentContext.length > 0 ? { segmentContext: waveSegmentContext } : {}), + }, + onEngineEvent, + ); // TP-029: Track repos from newly allocated lanes for cleanup coverage for (const lane of lanes) { const laneRepoRoot = resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig); @@ -2634,7 +3056,8 @@ export async function executeOrchBatch( const task = discovery.pending.get(laneTask.taskId); const segmentState = segmentStateByTask.get(laneTask.taskId); if (!task || !segmentState) continue; - startedSegments = upsertRunningSegmentRecord(batchState, task, segmentState, lane) || startedSegments; + startedSegments = + upsertRunningSegmentRecord(batchState, task, segmentState, lane) || startedSegments; } } if (seededPendingOutcomes || startedSegments) { @@ -2672,12 +3095,14 @@ export async function executeOrchBatch( tools: runnerConfig?.reviewer?.tools || "", excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [], }, - runnerConfig?.worker ? { - model: runnerConfig.worker.model || "", - thinking: runnerConfig.worker.thinking || "", - tools: runnerConfig.worker.tools || "", - excludeExtensions: runnerConfig.worker.excludeExtensions ?? [], - } : undefined, + runnerConfig?.worker + ? { + model: runnerConfig.worker.model || "", + thinking: runnerConfig.worker.thinking || "", + tools: runnerConfig.worker.tools || "", + excludeExtensions: runnerConfig.worker.excludeExtensions ?? [], + } + : undefined, runnerConfig?.workerExcludeExtensions ?? [], emitLaneTerminated, onLaneRespawned ?? undefined, @@ -2719,24 +3144,48 @@ export async function executeOrchBatch( const staleCount = batchState.resilience?.retryCountByScope[staleScopeKey] ?? 1; if (staleRecovered) { emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "stale_worktree", staleCount, TIER0_RETRY_BUDGETS.stale_worktree.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_success", + batchState.batchId, + waveIdx, + "stale_worktree", + staleCount, + TIER0_RETRY_BUDGETS.stale_worktree.maxRetries, + ), repoId: null, // wave-scoped resolution: `Stale worktree cleanup succeeded — wave ${waveIdx + 1} re-executed successfully`, scopeKey: staleScopeKey, }); } else { - const staleRetryError = retryResult.allocationError?.message ?? "Allocation failed again after cleanup"; - const staleRetrySuggestion = "Stale worktree cleanup did not resolve the allocation failure. Manually inspect and remove worktrees."; + const staleRetryError = + retryResult.allocationError?.message ?? "Allocation failed again after cleanup"; + const staleRetrySuggestion = + "Stale worktree cleanup did not resolve the allocation failure. Manually inspect and remove worktrees."; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "stale_worktree", staleCount, TIER0_RETRY_BUDGETS.stale_worktree.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "stale_worktree", + staleCount, + TIER0_RETRY_BUDGETS.stale_worktree.maxRetries, + ), repoId: null, // wave-scoped error: staleRetryError, scopeKey: staleScopeKey, affectedTaskIds: waveTasks, suggestion: staleRetrySuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "stale_worktree", staleCount, TIER0_RETRY_BUDGETS.stale_worktree.maxRetries, - staleRetryError, waveTasks, staleRetrySuggestion, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "stale_worktree", + staleCount, + TIER0_RETRY_BUDGETS.stale_worktree.maxRetries, + staleRetryError, + waveTasks, + staleRetrySuggestion, { repoId: null, scopeKey: staleScopeKey }, ); } @@ -2777,17 +3226,22 @@ export async function executeOrchBatch( if (modelFallbackOutcome.succeededRetries.length > 0) { // Recompute blocked tasks after model fallback successes if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) { - const recomputed = computeTransitiveDependents( - new Set(waveResult.failedTaskIds), - depGraph, - ); + const recomputed = computeTransitiveDependents(new Set(waveResult.failedTaskIds), depGraph); waveResult.blockedTaskIds = [...recomputed].sort(); } else if (waveResult.failedTaskIds.length === 0) { waveResult.blockedTaskIds = []; } } if (modelFallbackOutcome.retriedCount > 0) { - persistRuntimeState("tier0-model-fallback", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "tier0-model-fallback", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } } @@ -2814,10 +3268,7 @@ export async function executeOrchBatch( // attemptWorkerCrashRetry already updated waveResult.failedTaskIds // and waveResult.succeededTaskIds in-place. if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) { - const recomputed = computeTransitiveDependents( - new Set(waveResult.failedTaskIds), - depGraph, - ); + const recomputed = computeTransitiveDependents(new Set(waveResult.failedTaskIds), depGraph); waveResult.blockedTaskIds = [...recomputed].sort(); } else if (waveResult.failedTaskIds.length === 0) { // All failures recovered — no blocked tasks @@ -2826,7 +3277,15 @@ export async function executeOrchBatch( } if (retryOutcome.retriedCount > 0) { // Persist updated state after retries - persistRuntimeState("tier0-worker-retry", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "tier0-worker-retry", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } // If stop-wave had paused the batch but Tier 0 retry recovered all @@ -2834,18 +3293,17 @@ export async function executeOrchBatch( // proceed. attemptWorkerCrashRetry already set stoppedEarly=false // and overallStatus="succeeded" on the waveResult (R002-4 fix). if ( - waveResult.failedTaskIds.length === 0 - && batchState.pauseSignal.paused - && waveResult.policyApplied === "stop-wave" + waveResult.failedTaskIds.length === 0 && + batchState.pauseSignal.paused && + waveResult.policyApplied === "stop-wave" ) { batchState.pauseSignal.paused = false; - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: all failed tasks recovered — clearing stop-wave pause`, ); - onNotify( - `✅ Tier 0: All failed tasks recovered — batch continuing past stop-wave`, - "info", - ); + onNotify(`✅ Tier 0: All failed tasks recovered — batch continuing past stop-wave`, "info"); } } @@ -2873,28 +3331,53 @@ export async function executeOrchBatch( const activeSegmentId = outcome?.segmentId ?? task.activeSegmentId; if (activeSegmentId) { segmentState.statusBySegmentId.set(activeSegmentId, "succeeded"); - upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "succeeded", outcome, laneByTaskId.get(taskId)); + upsertTerminalSegmentRecord( + batchState, + task, + segmentState, + activeSegmentId, + "succeeded", + outcome, + laneByTaskId.get(taskId), + ); - const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId, agentIdPrefix); + const workerAgentId = resolveTaskWorkerAgentId( + taskId, + allTaskOutcomes, + laneByTaskId, + agentIdPrefix, + ); if (workerAgentId) { - const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles(stateRoot, batchState.batchId, workerAgentId); + const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles( + stateRoot, + batchState.batchId, + workerAgentId, + ); if (pendingExpansionFiles.length > 0) { const parsedRequests = parseSegmentExpansionRequests(pendingExpansionFiles); for (const malformed of parsedRequests.malformed) { const renamed = markSegmentExpansionRequestFile(malformed.filePath, "invalid"); - execLog("batch", batchState.batchId, `segment expansion request malformed (${renamed ? "renamed to .invalid" : "rename failed"})`, { - taskId, - agentId: workerAgentId, - segmentId: activeSegmentId, - filePath: malformed.filePath, - reason: malformed.reason, - }); + execLog( + "batch", + batchState.batchId, + `segment expansion request malformed (${renamed ? "renamed to .invalid" : "rename failed"})`, + { + taskId, + agentId: workerAgentId, + segmentId: activeSegmentId, + filePath: malformed.filePath, + reason: malformed.reason, + }, + ); } - const orderedRequests = [...parsedRequests.valid].sort((a, b) => a.request.requestId.localeCompare(b.request.requestId)); - const scopedRequests = orderedRequests.filter((pendingRequest) => ( - pendingRequest.request.taskId === taskId - && pendingRequest.request.fromSegmentId === activeSegmentId - )); + const orderedRequests = [...parsedRequests.valid].sort((a, b) => + a.request.requestId.localeCompare(b.request.requestId), + ); + const scopedRequests = orderedRequests.filter( + (pendingRequest) => + pendingRequest.request.taskId === taskId && + pendingRequest.request.fromSegmentId === activeSegmentId, + ); let rejectedCount = 0; let acceptedCount = 0; for (const pendingRequest of scopedRequests) { @@ -2912,11 +3395,26 @@ export async function executeOrchBatch( if (!processingResult.ok) { rejectedCount += 1; processedSegmentExpansionRequestIds.add(requestId); - const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "failed"); + const recordedRequestId = recordProcessedSegmentExpansionRequestId( + batchState, + requestId, + "failed", + ); if (recordedRequestId) { - persistRuntimeState("segment-expansion-rejected", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "segment-expansion-rejected", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } - const renamedRejected = markSegmentExpansionRequestFile(pendingRequest.filePath, "rejected"); + const renamedRejected = markSegmentExpansionRequestFile( + pendingRequest.filePath, + "rejected", + ); emitAlert({ category: "segment-expansion-rejected", summary: @@ -2957,7 +3455,11 @@ export async function executeOrchBatch( requestId, batchState.orchBranch, ); - const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "succeeded"); + const recordedRequestId = recordProcessedSegmentExpansionRequestId( + batchState, + requestId, + "succeeded", + ); // TP-145 hardening: if .DONE was prematurely created by the // completing segment (because it was the last segment at that @@ -2973,11 +3475,11 @@ export async function executeOrchBatch( const lane = laneByTaskId.get(taskId); const doneDir = lane ? resolveCanonicalTaskPaths( - task.taskFolder, - lane.worktreePath, - repoRoot, - !!workspaceConfig, - ).taskFolderResolved + task.taskFolder, + lane.worktreePath, + repoRoot, + !!workspaceConfig, + ).taskFolderResolved : task.packetTaskPath || task.taskFolder; if (doneDir) { const donePath = join(doneDir, ".DONE"); @@ -2985,17 +3487,36 @@ export async function executeOrchBatch( try { unlinkSync(donePath); execLog("batch", batchState.batchId, "removed premature .DONE after segment expansion", { - taskId, donePath, requestId, + taskId, + donePath, + requestId, }); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } } - if (persistedInsertedSegments || recordedRequestId || mutation.insertedSegmentIds.length > 0) { - persistRuntimeState("segment-expansion-approved", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + if ( + persistedInsertedSegments || + recordedRequestId || + mutation.insertedSegmentIds.length > 0 + ) { + persistRuntimeState( + "segment-expansion-approved", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } - const renamedProcessed = markSegmentExpansionRequestFile(pendingRequest.filePath, "processed"); + const renamedProcessed = markSegmentExpansionRequestFile( + pendingRequest.filePath, + "processed", + ); emitAlert({ category: "segment-expansion-approved", summary: @@ -3016,17 +3537,22 @@ export async function executeOrchBatch( }); acceptedCount += 1; } - execLog("batch", batchState.batchId, `segment ${activeSegmentId} completed with ${pendingExpansionFiles.length} pending expansion request(s)`, { - taskId, - agentId: workerAgentId, - segmentId: activeSegmentId, - acceptedCount, - rejectedCount, - validRequests: parsedRequests.valid.length, - scopedRequests: scopedRequests.length, - ignoredRequests: orderedRequests.length - scopedRequests.length, - malformedRequests: parsedRequests.malformed.length, - }); + execLog( + "batch", + batchState.batchId, + `segment ${activeSegmentId} completed with ${pendingExpansionFiles.length} pending expansion request(s)`, + { + taskId, + agentId: workerAgentId, + segmentId: activeSegmentId, + acceptedCount, + rejectedCount, + validRequests: parsedRequests.valid.length, + scopedRequests: scopedRequests.length, + ignoredRequests: orderedRequests.length - scopedRequests.length, + malformedRequests: parsedRequests.malformed.length, + }, + ); } } } @@ -3042,17 +3568,26 @@ export async function executeOrchBatch( } } if (continuationTaskIds.size > 0) { - const continuationWave = scheduleContinuationSegmentRound(runtimeSegmentRounds, waveIdx, continuationTaskIds); + const continuationWave = scheduleContinuationSegmentRound( + runtimeSegmentRounds, + waveIdx, + continuationTaskIds, + ); // TP-166: Maintain roundToTaskWave mapping for the inserted continuation round. // The continuation belongs to the same task-level wave as the current round. const parentTaskWave = roundToTaskWave[waveIdx] ?? 0; roundToTaskWave.splice(waveIdx + 1, 0, parentTaskWave); batchState.roundToTaskWave = [...roundToTaskWave]; - execLog("batch", batchState.batchId, "scheduled continuation segment round for expanded task frontier", { - waveIndex: waveIdx, - taskIds: continuationWave.join(","), - runtimeSegmentRoundCount: runtimeSegmentRounds.length, - }); + execLog( + "batch", + batchState.batchId, + "scheduled continuation segment round for expanded task frontier", + { + waveIndex: waveIdx, + taskIds: continuationWave.join(","), + runtimeSegmentRoundCount: runtimeSegmentRounds.length, + }, + ); } for (const taskId of waveResult.failedTaskIds) { @@ -3063,11 +3598,28 @@ export async function executeOrchBatch( const activeSegmentId = failOutcome?.segmentId ?? task.activeSegmentId; if (activeSegmentId) { segmentState.statusBySegmentId.set(activeSegmentId, "failed"); - upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "failed", failOutcome, laneByTaskId.get(taskId)); + upsertTerminalSegmentRecord( + batchState, + task, + segmentState, + activeSegmentId, + "failed", + failOutcome, + laneByTaskId.get(taskId), + ); - const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId, agentIdPrefix); + const workerAgentId = resolveTaskWorkerAgentId( + taskId, + allTaskOutcomes, + laneByTaskId, + agentIdPrefix, + ); if (workerAgentId) { - const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles(stateRoot, batchState.batchId, workerAgentId); + const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles( + stateRoot, + batchState.batchId, + workerAgentId, + ); if (pendingExpansionFiles.length > 0) { const parsedRequests = parseSegmentExpansionRequests(pendingExpansionFiles); for (const malformed of parsedRequests.malformed) { @@ -3077,12 +3629,27 @@ export async function executeOrchBatch( let discardedCount = 0; let ignoredCount = 0; for (const requestFile of parsedRequests.valid) { - if (requestFile.request.taskId === taskId && requestFile.request.fromSegmentId === activeSegmentId) { + if ( + requestFile.request.taskId === taskId && + requestFile.request.fromSegmentId === activeSegmentId + ) { const requestId = requestFile.request.requestId; processedSegmentExpansionRequestIds.add(requestId); - const recordedRequestId = recordProcessedSegmentExpansionRequestId(batchState, requestId, "skipped"); + const recordedRequestId = recordProcessedSegmentExpansionRequestId( + batchState, + requestId, + "skipped", + ); if (recordedRequestId) { - persistRuntimeState("segment-expansion-discarded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "segment-expansion-discarded", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } if (markSegmentExpansionRequestFile(requestFile.filePath, "discarded")) { discardedCount += 1; @@ -3091,14 +3658,19 @@ export async function executeOrchBatch( } ignoredCount += 1; } - execLog("batch", batchState.batchId, `segment ${activeSegmentId} failed with ${pendingExpansionFiles.length} pending expansion request(s)`, { - taskId, - agentId: workerAgentId, - segmentId: activeSegmentId, - discardedCount, - ignoredCount, - malformedCount: parsedRequests.malformed.length, - }); + execLog( + "batch", + batchState.batchId, + `segment ${activeSegmentId} failed with ${pendingExpansionFiles.length} pending expansion request(s)`, + { + taskId, + agentId: workerAgentId, + segmentId: activeSegmentId, + discardedCount, + ignoredCount, + malformedCount: parsedRequests.malformed.length, + }, + ); if (discardedCount > 0) { emitAlert({ category: "segment-expansion-rejected", @@ -3133,7 +3705,15 @@ export async function executeOrchBatch( if (activeSegmentId) { segmentState.statusBySegmentId.set(activeSegmentId, "skipped"); const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId); - upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "skipped", outcome, laneByTaskId.get(taskId)); + upsertTerminalSegmentRecord( + batchState, + task, + segmentState, + activeSegmentId, + "skipped", + outcome, + laneByTaskId.get(taskId), + ); } task.activeSegmentId = null; segmentState.terminalStatus = "skipped"; @@ -3159,31 +3739,37 @@ export async function executeOrchBatch( // ── TP-040: Emit task_complete / task_failed events ────── // Emitted after Tier 0 retry so events reflect final status. for (const taskId of waveResult.succeededTaskIds) { - const outcome = allTaskOutcomes.find(o => o.taskId === taskId); - emitEvent(stateRoot, { - ...buildEngineEventBase("task_complete", batchState.batchId, waveIdx, batchState.phase), - taskId, - durationMs: outcome?.startTime && outcome?.endTime - ? outcome.endTime - outcome.startTime - : undefined, - outcome: "succeeded", - }, onEngineEvent); + const outcome = allTaskOutcomes.find((o) => o.taskId === taskId); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("task_complete", batchState.batchId, waveIdx, batchState.phase), + taskId, + durationMs: + outcome?.startTime && outcome?.endTime ? outcome.endTime - outcome.startTime : undefined, + outcome: "succeeded", + }, + onEngineEvent, + ); } for (const taskId of waveResult.failedTaskIds) { - const outcome = allTaskOutcomes.find(o => o.taskId === taskId); - emitEvent(stateRoot, { - ...buildEngineEventBase("task_failed", batchState.batchId, waveIdx, batchState.phase), - taskId, - durationMs: outcome?.startTime && outcome?.endTime - ? outcome.endTime - outcome.startTime - : undefined, - reason: outcome?.exitReason || "unknown", - partialProgress: (outcome?.partialProgressCommits ?? 0) > 0, - }, onEngineEvent); + const outcome = allTaskOutcomes.find((o) => o.taskId === taskId); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("task_failed", batchState.batchId, waveIdx, batchState.phase), + taskId, + durationMs: + outcome?.startTime && outcome?.endTime ? outcome.endTime - outcome.startTime : undefined, + reason: outcome?.exitReason || "unknown", + partialProgress: (outcome?.partialProgressCommits ?? 0) > 0, + }, + onEngineEvent, + ); // ── TP-076: Emit supervisor alert for task failure ────── - const laneForTask = latestAllocatedLanes.find(l => l.tasks.some(t => t.taskId === taskId)); - const allocatedTask = laneForTask?.tasks.find(t => t.taskId === taskId)?.task; + const laneForTask = latestAllocatedLanes.find((l) => l.tasks.some((t) => t.taskId === taskId)); + const allocatedTask = laneForTask?.tasks.find((t) => t.taskId === taskId)?.task; const exitReason = outcome?.exitReason || "unknown"; const hasPartialProgress = (outcome?.partialProgressCommits ?? 0) > 0; const segmentFrontier = buildSupervisorSegmentFrontierSnapshot( @@ -3193,12 +3779,14 @@ export async function executeOrchBatch( batchState.segments, outcome?.segmentId, ); - const segmentId = outcome?.segmentId - ?? allocatedTask?.activeSegmentId - ?? segmentFrontier?.activeSegmentId - ?? undefined; + const segmentId = + outcome?.segmentId ?? + allocatedTask?.activeSegmentId ?? + segmentFrontier?.activeSegmentId ?? + undefined; const repoId = segmentId - ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? laneForTask?.repoId) + ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? + laneForTask?.repoId) : laneForTask?.repoId; const segmentSummary = segmentId ? ` Segment: ${segmentId}${repoId ? ` (repo: ${repoId})` : ""}\n` @@ -3247,15 +3835,22 @@ export async function executeOrchBatch( // later, then emit lane-terminated so the supervisor process // suppresses any in-transit zombie alerts targeting this lane/agent. if (laneForTask) { - const hardFailAgentId = outcome?.sessionName && outcome.sessionName.length > 0 - ? outcome.sessionName - : `${laneForTask.laneSessionId}-worker`; + const hardFailAgentId = + outcome?.sessionName && outcome.sessionName.length > 0 + ? outcome.sessionName + : `${laneForTask.laneSessionId}-worker`; try { const drained = drainAgentOutbox(stateRoot, batchState.batchId, hardFailAgentId); if (drained > 0) { - execLog("batch", batchState.batchId, `hard-fail outbox drain: ${drained} entr${drained === 1 ? "y" : "ies"} for ${hardFailAgentId}`); - } - } catch { /* best effort — do not block termination */ } + execLog( + "batch", + batchState.batchId, + `hard-fail outbox drain: ${drained} entr${drained === 1 ? "y" : "ies"} for ${hardFailAgentId}`, + ); + } + } catch { + /* best effort — do not block termination */ + } emitLaneTerminated({ laneNumber: laneForTask.laneNumber, agentId: hardFailAgentId, @@ -3281,25 +3876,50 @@ export async function executeOrchBatch( const allFailedAreSpawnFailures = isAllLanesSpawnFailedWave(waveResult, allTaskOutcomes); if (allFailedAreSpawnFailures) { batchState.phase = "failed"; - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `phase → failed: every lane in wave ${waveIdx + 1} hit spawn_failure (TP-190 #561)`, { failedTasks: waveResult.failedTaskIds.join(",") }, ); onNotify( - ORCH_MESSAGES.orchBatchFailed(batchState.batchId, `all lanes in wave ${waveIdx + 1} failed to spawn (Runtime V2 spawn-failure — see task-failure alerts above)`), + ORCH_MESSAGES.orchBatchFailed( + batchState.batchId, + `all lanes in wave ${waveIdx + 1} failed to spawn (Runtime V2 spawn-failure — see task-failure alerts above)`, + ), "error", ); - persistRuntimeState("wave-spawn-failure", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "wave-spawn-failure", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); emitTerminalEvent(`All-lane spawn failure at wave ${waveIdx + 1}`); break; } // ── TS-009: Persist state after wave execution ── - persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "wave-execution-complete", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); const elapsedSec = Math.round((waveResult.endedAt - waveResult.startedAt) / 1000); { - const { displayWave: completeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); + const { displayWave: completeDisplayWave } = resolveDisplayWaveNumber( + waveIdx, + roundToTaskWave, + taskLevelWaveCount, + ); onNotify( ORCH_MESSAGES.orchWaveComplete( completeDisplayWave, @@ -3321,7 +3941,15 @@ export async function executeOrchBatch( if (waveResult.policyApplied === "stop-all") { batchState.phase = "stopped"; // ── TS-009: Persist state on stop-all ── - persistRuntimeState("stop-all", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "stop-all", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-all"), "error"); // TP-040: Emit batch_paused event (via terminal helper for dedup) emitTerminalEvent(`Stopped by stop-all policy at wave ${waveIdx + 1}`); @@ -3330,7 +3958,15 @@ export async function executeOrchBatch( if (waveResult.policyApplied === "stop-wave") { batchState.phase = "stopped"; // ── TS-009: Persist state on stop-wave ── - persistRuntimeState("stop-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "stop-wave", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-wave"), "error"); // TP-040: Emit batch_paused event (via terminal helper for dedup) emitTerminalEvent(`Stopped by stop-wave policy at wave ${waveIdx + 1}`); @@ -3348,11 +3984,9 @@ export async function executeOrchBatch( for (const lr of waveResult.laneResults) { laneOutcomeByNumber.set(lr.laneNumber, lr); } - const mixedOutcomeLanes = waveResult.laneResults.filter(lr => { - const hasSucceeded = lr.tasks.some(t => t.status === "succeeded"); - const hasHardFailure = lr.tasks.some( - t => t.status === "failed" || t.status === "stalled", - ); + const mixedOutcomeLanes = waveResult.laneResults.filter((lr) => { + const hasSucceeded = lr.tasks.some((t) => t.status === "succeeded"); + const hasHardFailure = lr.tasks.some((t) => t.status === "failed" || t.status === "stalled"); return hasSucceeded && hasHardFailure; }); @@ -3367,44 +4001,55 @@ export async function executeOrchBatch( if (!lane.worktreePath || !existsSync(lane.worktreePath)) continue; const laneOutcome = laneOutcomeByNumber.get(lane.laneNumber); if (!laneOutcome) continue; - const hasSucceeded = laneOutcome.tasks.some(t => t.status === "succeeded"); - const hasSkipped = laneOutcome.tasks.some(t => t.status === "skipped"); + const hasSucceeded = laneOutcome.tasks.some((t) => t.status === "succeeded"); + const hasSkipped = laneOutcome.tasks.some((t) => t.status === "skipped"); // Auto-commit merge candidates (succeeded) and skipped-task lanes if (!hasSucceeded && !hasSkipped) continue; try { const addResult = runGit(["add", "-A"], lane.worktreePath); if (!addResult.ok) { - execLog("merge", batchState.batchId, `safety-net: git add failed in ${lane.laneId}`, { stderr: addResult.stderr }); + execLog("merge", batchState.batchId, `safety-net: git add failed in ${lane.laneId}`, { + stderr: addResult.stderr, + }); continue; } const statusResult = runGit(["status", "--porcelain"], lane.worktreePath); if (!statusResult.ok || !statusResult.stdout?.trim()) continue; - const taskIds = lane.tasks.map(t => t.taskId).join(", "); + const taskIds = lane.tasks.map((t) => t.taskId).join(", "); const commitResult = runGit( ["commit", "-m", `safety-net: uncommitted artifacts for ${taskIds}`], lane.worktreePath, ); if (commitResult.ok) { - execLog("merge", batchState.batchId, `safety-net: auto-committed uncommitted files in ${lane.laneId}`, { - worktree: lane.worktreePath, - taskIds, - files: statusResult.stdout.trim(), - }); + execLog( + "merge", + batchState.batchId, + `safety-net: auto-committed uncommitted files in ${lane.laneId}`, + { + worktree: lane.worktreePath, + taskIds, + files: statusResult.stdout.trim(), + }, + ); } else { - execLog("merge", batchState.batchId, `safety-net: commit failed in ${lane.laneId}`, { stderr: commitResult.stderr }); + execLog("merge", batchState.batchId, `safety-net: commit failed in ${lane.laneId}`, { + stderr: commitResult.stderr, + }); } } catch (err: any) { - execLog("merge", batchState.batchId, `safety-net: unexpected error in ${lane.laneId}`, { error: err?.message }); + execLog("merge", batchState.batchId, `safety-net: unexpected error in ${lane.laneId}`, { + error: err?.message, + }); } } if (succeededSegmentTaskIdsForMerge.length > 0) { - const mergeableLaneCount = waveResult.allocatedLanes.filter(lane => { + const mergeableLaneCount = waveResult.allocatedLanes.filter((lane) => { const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded"); + const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded"); const hasHardFailure = outcome.tasks.some( - t => t.status === "failed" || t.status === "stalled", + (t) => t.status === "failed" || t.status === "stalled", ); return hasSucceeded && !hasHardFailure; }).length; @@ -3412,13 +4057,31 @@ export async function executeOrchBatch( if (mergeableLaneCount > 0) { batchState.phase = "merging"; // ── TS-009: Persist state on executing→merging transition ── - persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); - onNotify(ORCH_MESSAGES.orchMergeStart(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeableLaneCount), "info"); + persistRuntimeState( + "merge-start", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); + onNotify( + ORCH_MESSAGES.orchMergeStart( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergeableLaneCount, + ), + "info", + ); // TP-040: Emit merge_start event - emitEvent(stateRoot, { - ...buildEngineEventBase("merge_start", batchState.batchId, waveIdx, batchState.phase), - laneCount: mergeableLaneCount, - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("merge_start", batchState.batchId, waveIdx, batchState.phase), + laneCount: mergeableLaneCount, + }, + onEngineEvent, + ); // TP-056: Start merge health monitor during merge phase const mergeHealthMonitor = new MergeHealthMonitor({ @@ -3461,7 +4124,15 @@ export async function executeOrchBatch( batchState.mergeResults.push(mergeResult); // Persist state after merge so dashboard shows wave merge results - persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "merge-complete", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // Emit per-lane merge notifications for (const lr of mergeResult.laneResults) { @@ -3471,10 +4142,23 @@ export async function executeOrchBatch( if (lr.error) { onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error"); } else if (lr.result?.status === "SUCCESS") { - onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info"); + onNotify( + ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), + "info", + ); } else if (lr.result?.status === "CONFLICT_RESOLVED") { - onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info"); - } else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") { + onNotify( + ORCH_MESSAGES.orchMergeLaneConflictResolved( + lr.laneNumber, + lr.result.conflicts.length, + durationSec, + ), + "info", + ); + } else if ( + lr.result?.status === "CONFLICT_UNRESOLVED" || + lr.result?.status === "BUILD_FAILURE" + ) { onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status), "error"); } } @@ -3482,13 +4166,18 @@ export async function executeOrchBatch( // If any lane has mixed outcomes, do not silently discard succeeded work. // Force merge failure handling so state is preserved for manual resolution. if (mixedOutcomeLanes.length > 0) { - const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); + const mixedIds = mixedOutcomeLanes.map((l) => `lane-${l.laneNumber}`).join(", "); const failureReason = `Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` + `Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`; - execLog("merge", `W${waveIdx + 1}`, "mixed-outcome lanes detected — escalating to merge failure handling", { - mixedLaneIds: mixedIds, - }); + execLog( + "merge", + `W${waveIdx + 1}`, + "mixed-outcome lanes detected — escalating to merge failure handling", + { + mixedLaneIds: mixedIds, + }, + ); mergeResult = { ...mergeResult, status: "partial", @@ -3503,33 +4192,53 @@ export async function executeOrchBatch( // Emit overall merge result notification // TP-032 R006-3: Exclude verification_new_failure lanes from success count const mergedCount = mergeResult.laneResults.filter( - r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), + (r) => + !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), ).length; const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000); if (mergeResult.status === "succeeded") { - const { displayWave: mergeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); - onNotify(ORCH_MESSAGES.orchMergeComplete(mergeDisplayWave, mergedCount, mergeTotalSec), "info"); + const { displayWave: mergeDisplayWave } = resolveDisplayWaveNumber( + waveIdx, + roundToTaskWave, + taskLevelWaveCount, + ); + onNotify( + ORCH_MESSAGES.orchMergeComplete(mergeDisplayWave, mergedCount, mergeTotalSec), + "info", + ); // TP-040: Emit merge_success event - emitEvent(stateRoot, { - ...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase), - laneCount: mergedCount, - durationMs: mergeResult.totalDurationMs, - totalWaves: taskLevelWaveCount, - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase), + laneCount: mergedCount, + durationMs: mergeResult.totalDurationMs, + totalWaves: taskLevelWaveCount, + }, + onEngineEvent, + ); } else { onNotify( - ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"), + ORCH_MESSAGES.orchMergeFailed( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergeResult.failedLane ?? 0, + mergeResult.failureReason || "unknown", + ), "error", ); // TP-040: Emit merge_failed event - emitEvent(stateRoot, { - ...buildEngineEventBase("merge_failed", batchState.batchId, waveIdx, batchState.phase), - laneNumber: mergeResult.failedLane ?? undefined, - error: mergeResult.failureReason || "unknown", - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("merge_failed", batchState.batchId, waveIdx, batchState.phase), + laneNumber: mergeResult.failedLane ?? undefined, + error: mergeResult.failureReason || "unknown", + }, + onEngineEvent, + ); // Emit repo-divergence summary when partial is caused by cross-repo outcome differences if (mergeResult.status === "partial") { @@ -3543,9 +4252,17 @@ export async function executeOrchBatch( // Restore phase to executing (may be overridden below by failure handling) batchState.phase = "executing"; // ── TS-009: Persist state after merge (merging→executing) ── - persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "merge-complete", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); } else if (mixedOutcomeLanes.length > 0) { - const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); + const mixedIds = mixedOutcomeLanes.map((l) => `lane-${l.laneNumber}`).join(", "); mergeResult = { waveIndex: waveIdx + 1, status: "partial", @@ -3561,23 +4278,41 @@ export async function executeOrchBatch( allMergeResults.push(mergeResult); batchState.mergeResults.push(mergeResult); onNotify( - ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane, mergeResult.failureReason || "unknown"), + ORCH_MESSAGES.orchMergeFailed( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergeResult.failedLane, + mergeResult.failureReason || "unknown", + ), "error", ); // TP-040 R002: Emit merge_failed for mixed-outcome/no-mergeable-lane path - emitEvent(stateRoot, { - ...buildEngineEventBase("merge_failed", batchState.batchId, waveIdx, batchState.phase), - laneNumber: mergeResult.failedLane, - error: mergeResult.failureReason, - }, onEngineEvent); + emitEvent( + stateRoot, + { + ...buildEngineEventBase("merge_failed", batchState.batchId, waveIdx, batchState.phase), + laneNumber: mergeResult.failedLane, + error: mergeResult.failureReason, + }, + onEngineEvent, + ); } else { // No mergeable lanes and no mixed outcomes (e.g., only skipped tasks) - onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info"); + onNotify( + ORCH_MESSAGES.orchMergeSkipped( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + ), + "info", + ); } } else { // No succeeded tasks — skip merge entirely - onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info"); + onNotify( + ORCH_MESSAGES.orchMergeSkipped( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + ), + "info", + ); } // ── TP-033: Safe-stop on rollback failure ───────────────── @@ -3587,30 +4322,44 @@ export async function executeOrchBatch( if (mergeResult?.rollbackFailed) { // TP-033 R004-2: Include persistence error warning when transaction // record files may be missing, so operator knows to inspect manually - const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; + const hasPersistErrors = + mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; const persistWarning = hasPersistErrors ? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.` : ""; - execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", { - waveIndex: waveIdx, - configPolicy: orchConfig.failure.on_merge_failure, - ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}), - }); + execLog( + "batch", + batchState.batchId, + "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", + { + waveIndex: waveIdx, + configPolicy: orchConfig.failure.on_merge_failure, + ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}), + }, + ); batchState.phase = "paused"; batchState.errors.push( `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` + - `Merge worktree and temp branch preserved for recovery. ` + - `Check transaction records in .pi/verification/ for recovery commands.` + - persistWarning + `Merge worktree and temp branch preserved for recovery. ` + + `Check transaction records in .pi/verification/ for recovery commands.` + + persistWarning, + ); + persistRuntimeState( + "merge-rollback-safe-stop", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, ); - persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); onNotify( `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` + - `Batch force-paused. Merge worktree preserved for manual recovery. ` + - `See .pi/verification/ transaction records for recovery commands.` + - persistWarning, + `Batch force-paused. Merge worktree preserved for manual recovery. ` + + `See .pi/verification/ transaction records for recovery commands.` + + persistWarning, "error", ); @@ -3678,7 +4427,16 @@ export async function executeOrchBatch( selectedBackend, ); }, - persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot), + persist: (trigger) => + persistRuntimeState( + trigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ), log: (message, details) => execLog("batch", batchState.batchId, message, details), notify: (message, level) => onNotify(message, level), updateMergeResult: (result) => { @@ -3691,7 +4449,14 @@ export async function executeOrchBatch( // with accurate classification/attempt data from the retry decision. onRetryAttempt: (decision) => { emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "merge_timeout", decision.currentAttempt, decision.maxAttempts), + ...buildTier0EventBase( + "tier0_recovery_attempt", + batchState.batchId, + waveIdx, + "merge_timeout", + decision.currentAttempt, + decision.maxAttempts, + ), laneNumber: mergeFailedLane, repoId: mergeRepoId, classification: decision.classification, @@ -3704,11 +4469,26 @@ export async function executeOrchBatch( if (retryOutcome.kind === "retry_succeeded") { mergeResult = retryOutcome.mergeResult; batchState.phase = "executing"; - persistRuntimeState("merge-retry-succeeded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "merge-retry-succeeded", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // Emit merge retry success event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "merge_timeout", retryOutcome.lastDecision.currentAttempt, retryOutcome.lastDecision.maxAttempts), + ...buildTier0EventBase( + "tier0_recovery_success", + batchState.batchId, + waveIdx, + "merge_timeout", + retryOutcome.lastDecision.currentAttempt, + retryOutcome.lastDecision.maxAttempts, + ), laneNumber: mergeFailedLane, repoId: mergeRepoId, classification: retryOutcome.classification ?? undefined, @@ -3721,7 +4501,15 @@ export async function executeOrchBatch( mergeResult = retryOutcome.mergeResult; batchState.phase = "paused"; batchState.errors.push(retryOutcome.errorMessage); - persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "merge-rollback-safe-stop", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(retryOutcome.notifyMessage, "error"); // ── TP-076: Emit supervisor alert for merge safe-stop ── @@ -3746,9 +4534,17 @@ export async function executeOrchBatch( }); // Emit merge safe-stop event (treated as exhausted — no further automatic recovery possible) - const mergeSafeStopSuggestion = "Merge rollback failed — batch force-paused for manual recovery. Check .pi/verification/ for recovery commands."; + const mergeSafeStopSuggestion = + "Merge rollback failed — batch force-paused for manual recovery. Check .pi/verification/ for recovery commands."; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "merge_timeout", retryOutcome.lastDecision.currentAttempt, retryOutcome.lastDecision.maxAttempts), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "merge_timeout", + retryOutcome.lastDecision.currentAttempt, + retryOutcome.lastDecision.maxAttempts, + ), laneNumber: mergeFailedLane, repoId: mergeRepoId, classification: retryOutcome.classification ?? undefined, @@ -3756,10 +4552,22 @@ export async function executeOrchBatch( scopeKey: retryOutcome.scopeKey, suggestion: mergeSafeStopSuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "merge_timeout", - retryOutcome.lastDecision.currentAttempt, retryOutcome.lastDecision.maxAttempts, - retryOutcome.errorMessage, [], mergeSafeStopSuggestion, - { laneNumber: mergeFailedLane, repoId: mergeRepoId, classification: retryOutcome.classification ?? undefined, scopeKey: retryOutcome.scopeKey }, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "merge_timeout", + retryOutcome.lastDecision.currentAttempt, + retryOutcome.lastDecision.maxAttempts, + retryOutcome.errorMessage, + [], + mergeSafeStopSuggestion, + { + laneNumber: mergeFailedLane, + repoId: mergeRepoId, + classification: retryOutcome.classification ?? undefined, + scopeKey: retryOutcome.scopeKey, + }, ); preserveWorktreesForResume = true; @@ -3768,7 +4576,8 @@ export async function executeOrchBatch( // TP-033 R006-2: Force paused regardless of on_merge_failure config. // Retry exhaustion takes precedence over config policy. mergeResult = retryOutcome.mergeResult; - const exhaustionMsg = retryOutcome.errorMessage + + const exhaustionMsg = + retryOutcome.errorMessage + ` [${retryOutcome.classification ?? "unknown"} ${retryOutcome.lastDecision.currentAttempt}/${retryOutcome.lastDecision.maxAttempts}, scope=${retryOutcome.scopeKey}]`; execLog("batch", batchState.batchId, `merge retry exhausted — forcing paused`, { @@ -3781,7 +4590,14 @@ export async function executeOrchBatch( // Emit merge retry exhausted event const mergeExhaustedSuggestion = `Merge retry exhausted (${retryOutcome.classification ?? "unknown"}) after ${retryOutcome.lastDecision.currentAttempt} attempt(s). Investigate merge failure and retry manually.`; emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "merge_timeout", retryOutcome.lastDecision.currentAttempt, retryOutcome.lastDecision.maxAttempts), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "merge_timeout", + retryOutcome.lastDecision.currentAttempt, + retryOutcome.lastDecision.maxAttempts, + ), laneNumber: mergeFailedLane, repoId: mergeRepoId, classification: retryOutcome.classification ?? undefined, @@ -3789,15 +4605,35 @@ export async function executeOrchBatch( scopeKey: retryOutcome.scopeKey, suggestion: mergeExhaustedSuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "merge_timeout", - retryOutcome.lastDecision.currentAttempt, retryOutcome.lastDecision.maxAttempts, - exhaustionMsg, [], mergeExhaustedSuggestion, - { laneNumber: mergeFailedLane, repoId: mergeRepoId, classification: retryOutcome.classification ?? undefined, scopeKey: retryOutcome.scopeKey }, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "merge_timeout", + retryOutcome.lastDecision.currentAttempt, + retryOutcome.lastDecision.maxAttempts, + exhaustionMsg, + [], + mergeExhaustedSuggestion, + { + laneNumber: mergeFailedLane, + repoId: mergeRepoId, + classification: retryOutcome.classification ?? undefined, + scopeKey: retryOutcome.scopeKey, + }, ); batchState.phase = "paused"; batchState.errors.push(exhaustionMsg); - persistRuntimeState("merge-retry-exhausted", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "merge-retry-exhausted", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(retryOutcome.notifyMessage, "error"); // ── TP-076: Emit supervisor alert for merge retry exhausted ── @@ -3832,11 +4668,24 @@ export async function executeOrchBatch( ? ` [not retriable: ${retryOutcome.classification}, scope=${retryOutcome.scopeKey}]` : ""; - execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy${classNote}`, policyResult.logDetails); + execLog( + "batch", + batchState.batchId, + `merge failure — applying ${policyResult.policy} policy${classNote}`, + policyResult.logDetails, + ); batchState.phase = policyResult.targetPhase; batchState.errors.push(policyResult.errorMessage + classNote); - persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + policyResult.persistTrigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(policyResult.notifyMessage + classNote, policyResult.notifyLevel); // ── TP-076: Emit supervisor alert for merge failure (no-retry policy) ── @@ -3888,30 +4737,46 @@ export async function executeOrchBatch( let targetBranch = batchState.orchBranch; if (repoId && perRepoRoot !== repoRoot) { try { - targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); - } catch { /* fall back to orchBranch */ } + targetBranch = resolveBaseBranch( + repoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); + } catch { + /* fall back to orchBranch */ + } } return { repoRoot: perRepoRoot, targetBranch }; }, ); ppUnsafeBranches = ppResult.unsafeBranches; - if (ppResult.results.some(r => r.saved)) { - execLog("batch", batchState.batchId, - `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before inter-wave reset`); + if (ppResult.results.some((r) => r.saved)) { + execLog( + "batch", + batchState.batchId, + `preserved partial progress for ${ppResult.results.filter((r) => r.saved).length} failed task(s) before inter-wave reset`, + ); } // Log per-task warnings for failed preservation attempts for (const r of ppResult.results) { if (!r.saved && (r.commitCount > 0 || r.error)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: Failed to preserve partial progress for task ${r.taskId} ` + - `(${r.commitCount} commit(s) at risk on lane branch)`, - { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }); + `(${r.commitCount} commit(s) at risk on lane branch)`, + { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }, + ); } } if (ppUnsafeBranches.size > 0) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: ${ppUnsafeBranches.size} lane branch(es) could not be preserved — skipping reset for those lanes to prevent commit loss`, - { unsafeBranches: [...ppUnsafeBranches] }); + { unsafeBranches: [...ppUnsafeBranches] }, + ); } // TP-028: Stamp task outcomes with partial progress data for persistence applyPartialProgressToOutcomes(ppResult, allTaskOutcomes); @@ -3927,8 +4792,15 @@ export async function executeOrchBatch( let targetBranch = batchState.orchBranch; if (repoId && perRepoRoot !== repoRoot) { try { - targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); - } catch { /* fall back to orchBranch */ } + targetBranch = resolveBaseBranch( + repoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); + } catch { + /* fall back to orchBranch */ + } } return { repoRoot: perRepoRoot, targetBranch }; }, @@ -3937,9 +4809,12 @@ export async function executeOrchBatch( for (const branch of skippedPpResult.unsafeBranches) { ppUnsafeBranches.add(branch); } - if (skippedPpResult.results.some(r => r.saved)) { - execLog("batch", batchState.batchId, - `preserved partial progress for ${skippedPpResult.results.filter(r => r.saved).length} skipped task(s) before inter-wave reset`); + if (skippedPpResult.results.some((r) => r.saved)) { + execLog( + "batch", + batchState.batchId, + `preserved partial progress for ${skippedPpResult.results.filter((r) => r.saved).length} skipped task(s) before inter-wave reset`, + ); } // Stamp skipped task outcomes with partial progress data applyPartialProgressToOutcomes(skippedPpResult, allTaskOutcomes); @@ -3957,10 +4832,18 @@ export async function executeOrchBatch( // TP-029 R006: Track worktrees that failed reset AND removal // so the cleanup gate only fires on true stale state, not // successfully-reset reusable worktrees. - const failedRemovalWorktrees = new Map(); + const failedRemovalWorktrees = new Map< + string, + { repoId: string | undefined; paths: string[] } + >(); for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) { - const existingWorktrees = listWorktrees(resetPrefix, perRepoRoot, resetOpId, batchState.batchId); + const existingWorktrees = listWorktrees( + resetPrefix, + perRepoRoot, + resetOpId, + batchState.batchId, + ); if (existingWorktrees.length === 0) continue; totalResetWorktrees += existingWorktrees.length; @@ -3971,7 +4854,12 @@ export async function executeOrchBatch( targetBranch = batchState.orchBranch; } else { try { - targetBranch = resolveBaseBranch(perRepoId, perRepoRoot, batchState.orchBranch, workspaceConfig); + targetBranch = resolveBaseBranch( + perRepoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); } catch { // If resolution fails, fall back to orchBranch (reset will // fail gracefully and trigger worktree removal) @@ -3983,9 +4871,12 @@ export async function executeOrchBatch( // TP-028: Skip reset for worktrees whose lane branch has // unsaved partial progress (preservation failed with commits) if (ppUnsafeBranches.has(wt.branch)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `skipping worktree reset for lane ${wt.laneNumber} — branch "${wt.branch}" has unsaved partial progress`, - { path: wt.path, branch: wt.branch }); + { path: wt.path, branch: wt.branch }, + ); continue; } @@ -3999,12 +4890,21 @@ export async function executeOrchBatch( // If reset fails, remove this worktree so the next wave can recreate it cleanly. try { removeWorktree(wt, perRepoRoot); - execLog("batch", batchState.batchId, `removed unrecoverable worktree for lane ${wt.laneNumber}`); + execLog( + "batch", + batchState.batchId, + `removed unrecoverable worktree for lane ${wt.laneNumber}`, + ); } catch (removeErr: unknown) { - execLog("batch", batchState.batchId, `removeWorktree failed for lane ${wt.laneNumber}, attempting force cleanup`, { - error: removeErr instanceof Error ? removeErr.message : String(removeErr), - path: wt.path, - }); + execLog( + "batch", + batchState.batchId, + `removeWorktree failed for lane ${wt.laneNumber}, attempting force cleanup`, + { + error: removeErr instanceof Error ? removeErr.message : String(removeErr), + path: wt.path, + }, + ); // Last resort: force-remove the directory and prune git worktree state. forceCleanupWorktree(wt, perRepoRoot, batchState.batchId); // Track this worktree for the cleanup gate — it may still be registered @@ -4021,7 +4921,10 @@ export async function executeOrchBatch( if (totalResetWorktrees > 0) { onNotify( - ORCH_MESSAGES.orchWorktreeReset(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, totalResetWorktrees), + ORCH_MESSAGES.orchWorktreeReset( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + totalResetWorktrees, + ), "info", ); } @@ -4036,9 +4939,9 @@ export async function executeOrchBatch( if (failedRemovalWorktrees.size > 0) { for (const [perRepoRoot, { repoId: perRepoId, paths: failedPaths }] of failedRemovalWorktrees) { const remaining = listWorktrees(resetPrefix, perRepoRoot, resetOpId, batchState.batchId); - const remainingPaths = new Set(remaining.map(wt => wt.path)); + const remainingPaths = new Set(remaining.map((wt) => wt.path)); // Only report worktrees that were targeted for removal but are still registered - const stale = failedPaths.filter(p => remainingPaths.has(p)); + const stale = failedPaths.filter((p) => remainingPaths.has(p)); if (stale.length > 0) { cleanupGateFailures.push({ repoRoot: perRepoRoot, @@ -4066,15 +4969,30 @@ export async function executeOrchBatch( if (cleanupRetryCount < cleanupBudget.maxRetries) { batchState.resilience.retryCountByScope[cleanupScopeKey] = cleanupRetryCount + 1; - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: retrying cleanup gate (attempt ${cleanupRetryCount + 1}/${cleanupBudget.maxRetries})`, - { cleanupScopeKey, staleCount: cleanupGateFailures.reduce((n, f) => n + f.staleWorktrees.length, 0) }, + { + cleanupScopeKey, + staleCount: cleanupGateFailures.reduce((n, f) => n + f.staleWorktrees.length, 0), + }, ); // Emit attempt event - const staleWorktreeCount = cleanupGateFailures.reduce((n, f) => n + f.staleWorktrees.length, 0); + const staleWorktreeCount = cleanupGateFailures.reduce( + (n, f) => n + f.staleWorktrees.length, + 0, + ); emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "cleanup_gate", cleanupRetryCount + 1, cleanupBudget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_attempt", + batchState.batchId, + waveIdx, + "cleanup_gate", + cleanupRetryCount + 1, + cleanupBudget.maxRetries, + ), repoId: null, // wave-scoped: cleanup gate spans all repos classification: `stale_worktrees:${staleWorktreeCount}`, cooldownMs: cleanupBudget.cooldownMs, @@ -4101,8 +5019,8 @@ export async function executeOrchBatch( const retriedGateFailures: CleanupGateRepoFailure[] = []; for (const failure of cleanupGateFailures) { const remaining = listWorktrees(resetPrefix, failure.repoRoot, resetOpId, batchState.batchId); - const remainingPaths = new Set(remaining.map(wt => wt.path)); - const stillStale = failure.staleWorktrees.filter(p => remainingPaths.has(p)); + const remainingPaths = new Set(remaining.map((wt) => wt.path)); + const stillStale = failure.staleWorktrees.filter((p) => remainingPaths.has(p)); if (stillStale.length > 0) { retriedGateFailures.push({ repoRoot: failure.repoRoot, @@ -4113,7 +5031,9 @@ export async function executeOrchBatch( } if (retriedGateFailures.length === 0) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: cleanup gate retry succeeded — all stale worktrees removed`, { cleanupScopeKey }, ); @@ -4124,19 +5044,36 @@ export async function executeOrchBatch( // Emit success event emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "cleanup_gate", cleanupRetryCount + 1, cleanupBudget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_success", + batchState.batchId, + waveIdx, + "cleanup_gate", + cleanupRetryCount + 1, + cleanupBudget.maxRetries, + ), repoId: null, // wave-scoped resolution: `Cleanup gate retry succeeded — all stale worktrees removed at wave ${waveIdx + 1}`, scopeKey: cleanupScopeKey, }); - persistRuntimeState("tier0-cleanup-retry-success", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "tier0-cleanup-retry-success", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // Fall through to continue the wave loop (don't break) } else { // Retry failed — fall through to pausing const gatePolicyResult = computeCleanupGatePolicy(waveIdx, retriedGateFailures); - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `tier0: cleanup gate retry failed — still ${retriedGateFailures.reduce((n, f) => n + f.staleWorktrees.length, 0)} stale worktree(s), pausing batch`, gatePolicyResult.logDetails, ); @@ -4144,24 +5081,47 @@ export async function executeOrchBatch( const stillStaleCount = retriedGateFailures.reduce((n, f) => n + f.staleWorktrees.length, 0); const cleanupRetryError = `Cleanup gate retry failed — ${stillStaleCount} stale worktree(s) remain`; const cleanupRetrySuggestion = `Post-merge cleanup retry did not remove all stale worktrees. Manually remove the remaining ${stillStaleCount} worktree(s) and prune git state.`; - const cleanupRetryAffected = retriedGateFailures.flatMap(f => f.staleWorktrees); + const cleanupRetryAffected = retriedGateFailures.flatMap((f) => f.staleWorktrees); // Emit exhausted event (retry attempted but failed) emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "cleanup_gate", cleanupRetryCount + 1, cleanupBudget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "cleanup_gate", + cleanupRetryCount + 1, + cleanupBudget.maxRetries, + ), repoId: null, // wave-scoped error: cleanupRetryError, scopeKey: cleanupScopeKey, affectedTaskIds: cleanupRetryAffected, suggestion: cleanupRetrySuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "cleanup_gate", cleanupRetryCount + 1, cleanupBudget.maxRetries, - cleanupRetryError, cleanupRetryAffected, cleanupRetrySuggestion, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "cleanup_gate", + cleanupRetryCount + 1, + cleanupBudget.maxRetries, + cleanupRetryError, + cleanupRetryAffected, + cleanupRetrySuggestion, { repoId: null, scopeKey: cleanupScopeKey }, ); batchState.phase = gatePolicyResult.targetPhase; batchState.errors.push(gatePolicyResult.errorMessage); - persistRuntimeState(gatePolicyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + gatePolicyResult.persistTrigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(gatePolicyResult.notifyMessage, gatePolicyResult.notifyLevel); preserveWorktreesForResume = true; break; @@ -4170,28 +5130,56 @@ export async function executeOrchBatch( // Cleanup retry budget exhausted — pause immediately const gatePolicyResult = computeCleanupGatePolicy(waveIdx, cleanupGateFailures); - execLog("batch", batchState.batchId, `cleanup gate failed — pausing batch (retry budget exhausted)`, gatePolicyResult.logDetails); + execLog( + "batch", + batchState.batchId, + `cleanup gate failed — pausing batch (retry budget exhausted)`, + gatePolicyResult.logDetails, + ); // Emit exhausted event (budget already consumed from prior waves) const cleanupBudgetError = `Cleanup gate retry budget exhausted (${cleanupRetryCount}/${cleanupBudget.maxRetries})`; const cleanupBudgetSuggestion = `Cleanup gate retry budget was already consumed. Manually remove stale worktrees and prune git state.`; - const cleanupBudgetAffected = cleanupGateFailures.flatMap(f => f.staleWorktrees); + const cleanupBudgetAffected = cleanupGateFailures.flatMap((f) => f.staleWorktrees); emitTier0Event(stateRoot, { - ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "cleanup_gate", cleanupRetryCount, cleanupBudget.maxRetries), + ...buildTier0EventBase( + "tier0_recovery_exhausted", + batchState.batchId, + waveIdx, + "cleanup_gate", + cleanupRetryCount, + cleanupBudget.maxRetries, + ), repoId: null, // wave-scoped error: cleanupBudgetError, scopeKey: cleanupScopeKey, affectedTaskIds: cleanupBudgetAffected, suggestion: cleanupBudgetSuggestion, }); - emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "cleanup_gate", cleanupRetryCount, cleanupBudget.maxRetries, - cleanupBudgetError, cleanupBudgetAffected, cleanupBudgetSuggestion, + emitTier0Escalation( + stateRoot, + batchState.batchId, + waveIdx, + "cleanup_gate", + cleanupRetryCount, + cleanupBudget.maxRetries, + cleanupBudgetError, + cleanupBudgetAffected, + cleanupBudgetSuggestion, { repoId: null, scopeKey: cleanupScopeKey }, ); batchState.phase = gatePolicyResult.targetPhase; batchState.errors.push(gatePolicyResult.errorMessage); - persistRuntimeState(gatePolicyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + gatePolicyResult.persistTrigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); onNotify(gatePolicyResult.notifyMessage, gatePolicyResult.notifyLevel); preserveWorktreesForResume = true; break; @@ -4212,7 +5200,7 @@ export async function executeOrchBatch( try { const lanesDir = join(piDir, "runtime", batchState.batchId, "lanes"); if (existsSync(lanesDir)) { - const files = readdirSync(lanesDir).filter(f => f.startsWith("lane-") && f.endsWith(".json")); + const files = readdirSync(lanesDir).filter((f) => f.startsWith("lane-") && f.endsWith(".json")); for (const f of files) { try { const snap = JSON.parse(readFileSync(join(lanesDir, f), "utf-8")); @@ -4227,14 +5215,20 @@ export async function executeOrchBatch( cacheWrite: (w.cacheWriteTokens || 0) + (r.cacheWriteTokens || 0), costUsd: (w.costUsd || 0) + (r.costUsd || 0), }); - } catch { /* skip invalid files */ } + } catch { + /* skip invalid files */ + } } } - } catch { /* runtime dir may not exist */ } + } catch { + /* runtime dir may not exist */ + } // Legacy fallback: lane-state-*.json sidecars (pre-V2). try { - const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json")); + const files = readdirSync(piDir).filter( + (f) => f.startsWith("lane-state-") && f.endsWith(".json"), + ); for (const f of files) { try { const raw = readFileSync(join(piDir, f), "utf-8").trim(); @@ -4249,25 +5243,33 @@ export async function executeOrchBatch( costUsd: data.workerCostUsd || 0, }); } - } catch { /* skip invalid files */ } + } catch { + /* skip invalid files */ + } } - } catch { /* .pi dir may not exist */ } + } catch { + /* .pi dir may not exist */ + } // Build per-task summaries from allTaskOutcomes + wave plan const taskSummaries: BatchTaskSummary[] = allTaskOutcomes.map((to) => { // Find which wave and lane this task ran in let wave = 0; for (let wi = 0; wi < wavePlan.length; wi++) { - if (wavePlan[wi].includes(to.taskId)) { wave = wi + 1; break; } + if (wavePlan[wi].includes(to.taskId)) { + wave = wi + 1; + break; + } } - const lane = to.laneNumber - ?? (() => { + const lane = + to.laneNumber ?? + (() => { const laneMatch = to.sessionName?.match(/lane-(\d+)/); return laneMatch ? parseInt(laneMatch[1], 10) : 0; })(); // Compute duration from start/end times - const durationMs = (to.startTime && to.endTime) ? (to.endTime - to.startTime) : 0; + const durationMs = to.startTime && to.endTime ? to.endTime - to.startTime : 0; // TP-116: Resolve tokens from outcome telemetry first; only fallback for legacy outcomes. const tokens = resolveBatchHistoryTaskTokens( @@ -4280,7 +5282,14 @@ export async function executeOrchBatch( // TP-171: Map outcome status to valid BatchTaskSummary status. // Non-terminal statuses ("running", "pending") can appear if batch // was paused/aborted mid-wave. Map them to appropriate history values. - const validStatuses: Set = new Set(["succeeded", "failed", "skipped", "blocked", "stalled", "pending"]); + const validStatuses: Set = new Set([ + "succeeded", + "failed", + "skipped", + "blocked", + "stalled", + "pending", + ]); const historyStatus: BatchTaskSummary["status"] = validStatuses.has(to.status) ? (to.status as BatchTaskSummary["status"]) : "pending"; // "running" or unknown → "pending" in history @@ -4300,7 +5309,7 @@ export async function executeOrchBatch( // TP-147: Ensure ALL tasks from the wave plan are represented in history. // Tasks that never got allocated (blocked by upstream failures, never started) // won't have entries in allTaskOutcomes. Add them with appropriate status. - const coveredTaskIds = new Set(taskSummaries.map(t => t.taskId)); + const coveredTaskIds = new Set(taskSummaries.map((t) => t.taskId)); for (let wi = 0; wi < wavePlan.length; wi++) { for (const taskId of wavePlan[wi]) { if (coveredTaskIds.has(taskId)) continue; @@ -4323,8 +5332,8 @@ export async function executeOrchBatch( // Build per-wave summaries const waveSummaries: BatchWaveSummary[] = wavePlan.map((taskIds, wi) => { - const waveTasks = taskSummaries.filter(t => t.wave === wi + 1); - const mergeResult = batchState.mergeResults.find(mr => mr.waveIndex === wi + 1); + const waveTasks = taskSummaries.filter((t) => t.wave === wi + 1); + const mergeResult = batchState.mergeResults.find((mr) => mr.waveIndex === wi + 1); const waveTokens: TokenCounts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }; for (const t of waveTasks) { waveTokens.input += t.tokens.input; @@ -4357,7 +5366,9 @@ export async function executeOrchBatch( // (phase hasn't been set to "completed" yet at this point in the flow). const historyStatus: "completed" | "partial" | "failed" | "aborted" = batchState.failedTasks > 0 - ? (batchState.succeededTasks > 0 ? "partial" : "failed") + ? batchState.succeededTasks > 0 + ? "partial" + : "failed" : batchState.succeededTasks > 0 ? "completed" : "aborted"; @@ -4367,9 +5378,12 @@ export async function executeOrchBatch( // and log a warning if it diverges from batchState.totalTasks. const actualTotalTasks = taskSummaries.length; if (actualTotalTasks !== batchState.totalTasks) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: totalTasks mismatch — batchState.totalTasks=${batchState.totalTasks}, ` + - `taskSummaries.length=${actualTotalTasks}. Using taskSummaries.length for history.`); + `taskSummaries.length=${actualTotalTasks}. Using taskSummaries.length for history.`, + ); } const summary: BatchHistorySummary = { @@ -4398,18 +5412,29 @@ export async function executeOrchBatch( // TP-031 (R006): This check MUST run before cleanup so that worktrees // survive when failedTasks > 0. Without this, cleanup deletes worktrees // before the batch is marked "paused", breaking resumability. - if (!preserveWorktreesForResume && - ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") && - batchState.failedTasks > 0) { + if ( + !preserveWorktreesForResume && + ((batchState.phase as OrchBatchPhase) === "executing" || + (batchState.phase as OrchBatchPhase) === "merging") && + batchState.failedTasks > 0 + ) { preserveWorktreesForResume = true; - execLog("batch", batchState.batchId, "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume"); + execLog( + "batch", + batchState.batchId, + "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume", + ); } // ── Phase 3: Cleanup ───────────────────────────────────────── const prefix = orchConfig.orchestrator.worktree_prefix; if (preserveWorktreesForResume) { - execLog("batch", batchState.batchId, "skipping final cleanup to preserve worktrees/branches for resume"); + execLog( + "batch", + batchState.batchId, + "skipping final cleanup to preserve worktrees/branches for resume", + ); } else { // Kill lingering Runtime V2 agents BEFORE removing worktrees. // On Windows, lingering processes with cwd inside the worktree can lock @@ -4426,7 +5451,11 @@ export async function executeOrchBatch( let performedAgentCleanup = false; if (lingeringLaneSessions.size > 0) { - execLog("batch", batchState.batchId, `killing ${lingeringLaneSessions.size} lingering lane agent session(s) before cleanup`); + execLog( + "batch", + batchState.batchId, + `killing ${lingeringLaneSessions.size} lingering lane agent session(s) before cleanup`, + ); for (const sessionName of lingeringLaneSessions) { killV2LaneAgents(sessionName, { stateRoot, @@ -4439,7 +5468,11 @@ export async function executeOrchBatch( const killedMergeAgents = killAllMergeAgentsV2(); if (killedMergeAgents > 0) { - execLog("batch", batchState.batchId, `killed ${killedMergeAgents} lingering merge agent(s) before cleanup`); + execLog( + "batch", + batchState.batchId, + `killed ${killedMergeAgents} lingering merge agent(s) before cleanup`, + ); performedAgentCleanup = true; } @@ -4451,18 +5484,25 @@ export async function executeOrchBatch( const piDir = join(stateRoot, ".pi"); try { const sidecarFiles = readdirSync(piDir).filter( - f => f.startsWith("lane-state-") || + (f) => + f.startsWith("lane-state-") || f.startsWith("worker-conversation-") || f.startsWith("merge-result-") || f.startsWith("merge-request-"), ); for (const f of sidecarFiles) { - try { unlinkSync(join(piDir, f)); } catch { /* best effort */ } + try { + unlinkSync(join(piDir, f)); + } catch { + /* best effort */ + } } if (sidecarFiles.length > 0) { execLog("batch", batchState.batchId, `cleaned up ${sidecarFiles.length} sidecar file(s)`); } - } catch { /* .pi dir may not exist */ } + } catch { + /* .pi dir may not exist */ + } // ── TP-028: Preserve partial progress before terminal cleanup ── // Save failed task commits as named branches before worktree removal @@ -4480,25 +5520,38 @@ export async function executeOrchBatch( let targetBranch = batchState.orchBranch; if (repoId && perRepoRoot !== repoRoot) { try { - targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); - } catch { /* fall back to orchBranch */ } + targetBranch = resolveBaseBranch( + repoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); + } catch { + /* fall back to orchBranch */ + } } return { repoRoot: perRepoRoot, targetBranch }; }, ); - if (ppResult.results.some(r => r.saved)) { - execLog("batch", batchState.batchId, - `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before terminal cleanup`); + if (ppResult.results.some((r) => r.saved)) { + execLog( + "batch", + batchState.batchId, + `preserved partial progress for ${ppResult.results.filter((r) => r.saved).length} failed task(s) before terminal cleanup`, + ); } // Log warnings for failed preservation attempts — at terminal cleanup // we cannot skip deletion (batch is ending), but operators need to know // that commits may become unreachable via reflog only. for (const r of ppResult.results) { if (!r.saved && (r.commitCount > 0 || r.error)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: Failed to preserve partial progress for task ${r.taskId} ` + - `(${r.commitCount} commit(s) may become unreachable after cleanup)`, - { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }); + `(${r.commitCount} commit(s) may become unreachable after cleanup)`, + { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }, + ); } } // TP-028: Stamp task outcomes with partial progress data for persistence @@ -4515,22 +5568,35 @@ export async function executeOrchBatch( let targetBranch = batchState.orchBranch; if (repoId && perRepoRoot !== repoRoot) { try { - targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); - } catch { /* fall back to orchBranch */ } + targetBranch = resolveBaseBranch( + repoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); + } catch { + /* fall back to orchBranch */ + } } return { repoRoot: perRepoRoot, targetBranch }; }, ); - if (skippedPpResult.results.some(r => r.saved)) { - execLog("batch", batchState.batchId, - `preserved partial progress for ${skippedPpResult.results.filter(r => r.saved).length} skipped task(s) before terminal cleanup`); + if (skippedPpResult.results.some((r) => r.saved)) { + execLog( + "batch", + batchState.batchId, + `preserved partial progress for ${skippedPpResult.results.filter((r) => r.saved).length} skipped task(s) before terminal cleanup`, + ); } for (const r of skippedPpResult.results) { if (!r.saved && (r.commitCount > 0 || r.error)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: Failed to preserve partial progress for skipped task ${r.taskId} ` + - `(${r.commitCount} commit(s) may become unreachable after cleanup)`, - { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }); + `(${r.commitCount} commit(s) may become unreachable after cleanup)`, + { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }, + ); } } applyPartialProgressToOutcomes(skippedPpResult, allTaskOutcomes); @@ -4551,14 +5617,26 @@ export async function executeOrchBatch( } else { // Secondary repo (workspace mode): resolve the repo's own branch try { - targetBranch = resolveBaseBranch(perRepoId, perRepoRoot, batchState.orchBranch, workspaceConfig); + targetBranch = resolveBaseBranch( + perRepoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); } catch { // Fall back to undefined — skips branch protection // (safe because successfully merged branches were already cleaned) targetBranch = undefined; } } - const removeResult = removeAllWorktrees(prefix, perRepoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig); + const removeResult = removeAllWorktrees( + prefix, + perRepoRoot, + cleanupOpId, + targetBranch, + batchState.batchId, + orchConfig, + ); // Log preserved branches for (const p of removeResult.preserved) { @@ -4573,15 +5651,25 @@ export async function executeOrchBatch( } if (removeResult.failed.length > 0) { - const failedPaths = removeResult.failed.map(f => f.worktree.path).join(", "); - execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.failed.length} failed, ${removeResult.preserved.length} preserved`, { - failedPaths, - repoId: perRepoId ?? "(default)", - }); + const failedPaths = removeResult.failed.map((f) => f.worktree.path).join(", "); + execLog( + "batch", + batchState.batchId, + `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.failed.length} failed, ${removeResult.preserved.length} preserved`, + { + failedPaths, + repoId: perRepoId ?? "(default)", + }, + ); } else if (removeResult.totalAttempted > 0) { - execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.preserved.length} preserved`, { - repoId: perRepoId ?? "(default)", - }); + execLog( + "batch", + batchState.batchId, + `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.preserved.length} preserved`, + { + repoId: perRepoId ?? "(default)", + }, + ); } } @@ -4598,7 +5686,10 @@ export async function executeOrchBatch( for (const lr of mergeResult.laneResults) { // TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup // (their merge commits were rolled back, so the branch is NOT merged) - if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) { + if ( + !lr.error && + (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") + ) { const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig); const ancestorCheck = runGit( ["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch], @@ -4611,14 +5702,24 @@ export async function executeOrchBatch( repoId: lr.repoId ?? "(default)", }); } else { - execLog("batch", batchState.batchId, `warning: failed to delete merged branch ${lr.sourceBranch} — retained for manual cleanup`, { - repoId: lr.repoId ?? "(default)", - }); + execLog( + "batch", + batchState.batchId, + `warning: failed to delete merged branch ${lr.sourceBranch} — retained for manual cleanup`, + { + repoId: lr.repoId ?? "(default)", + }, + ); } } else { - execLog("batch", batchState.batchId, `warning: branch ${lr.sourceBranch} not fully merged into ${lr.targetBranch} — retained`, { - repoId: lr.repoId ?? "(default)", - }); + execLog( + "batch", + batchState.batchId, + `warning: branch ${lr.sourceBranch} not fully merged into ${lr.targetBranch} — retained`, + { + repoId: lr.repoId ?? "(default)", + }, + ); } } } @@ -4633,7 +5734,10 @@ export async function executeOrchBatch( // Determine final batch state. Cast to OrchBatchPhase to bypass control-flow // narrowing — mergeWave() could leave phase as "merging" if an unexpected // throw occurs between setting "merging" and restoring "executing". - if ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") { + if ( + (batchState.phase as OrchBatchPhase) === "executing" || + (batchState.phase as OrchBatchPhase) === "merging" + ) { // Normal completion (not stopped, paused, or aborted) if (batchState.failedTasks > 0) { // TP-031: Default to "paused" so the batch is resumable without --force. @@ -4661,30 +5765,55 @@ export async function executeOrchBatch( // always handles integration. const mergedTaskCount = batchState.succeededTasks; const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed"; - if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) { - if (orchConfig.orchestrator.integration === "supervised" || orchConfig.orchestrator.integration === "auto") { + if ( + isTerminalPhase && + !preserveWorktreesForResume && + batchState.orchBranch && + mergedTaskCount > 0 + ) { + if ( + orchConfig.orchestrator.integration === "supervised" || + orchConfig.orchestrator.integration === "auto" + ) { // TP-043: Supervisor-managed integration modes. The supervisor // agent handles integration after batch_complete event. The engine // does NOT perform legacy fast-forward here — defer to supervisor. - execLog("batch", batchState.batchId, `integration deferred to supervisor (mode: ${orchConfig.orchestrator.integration})`); + execLog( + "batch", + batchState.batchId, + `integration deferred to supervisor (mode: ${orchConfig.orchestrator.integration})`, + ); } else { // Manual mode (default): show integration guidance onNotify( - ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount), + ORCH_MESSAGES.orchIntegrationManual( + batchState.orchBranch, + batchState.baseBranch, + mergedTaskCount, + ), "info", ); } } // ── TS-009: Persist terminal state ── - persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + persistRuntimeState( + "batch-terminal", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discoveryRef, + stateRoot, + ); // ── TP-076: Emit supervisor alert for batch completion ────── if (batchState.phase === "completed" || batchState.phase === "failed") { const batchDurationMs = batchState.endedAt ? batchState.endedAt - batchState.startedAt : 0; - const durationStr = batchDurationMs > 0 - ? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s` - : "unknown"; + const durationStr = + batchDurationMs > 0 + ? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s` + : "unknown"; if (batchState.phase === "completed" && batchState.failedTasks === 0) { emitAlert({ category: "batch-complete", @@ -4724,12 +5853,26 @@ export async function executeOrchBatch( // ── TP-031: Emit diagnostic reports (JSONL + markdown) ── // Non-fatal: errors are logged but never crash batch finalization. - emitDiagnosticReports(assembleDiagnosticInput(orchConfig, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, stateRoot)); + emitDiagnosticReports( + assembleDiagnosticInput( + orchConfig, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + stateRoot, + ), + ); if (batchState.phase === "paused" || batchState.phase === "stopped") { - execLog("batch", batchState.batchId, "batch ended in non-terminal execution state; completion banner suppressed", { - phase: batchState.phase, - }); + execLog( + "batch", + batchState.batchId, + "batch ended in non-terminal execution state; completion banner suppressed", + { + phase: batchState.phase, + }, + ); } else { onNotify( ORCH_MESSAGES.orchBatchComplete( @@ -4768,6 +5911,4 @@ export async function executeOrchBatch( } } - // ── Dashboard Widget (Step 6) ──────────────────────────────────────── - diff --git a/extensions/taskplane/execution.ts b/extensions/taskplane/execution.ts index 6a242f2d..5c48f9a0 100644 --- a/extensions/taskplane/execution.ts +++ b/extensions/taskplane/execution.ts @@ -2,16 +2,60 @@ * Lane execution, monitoring, wave execution loop * @module orch/execution */ -import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync, copyFileSync } from "fs"; +import { + readFileSync, + existsSync, + statSync, + unlinkSync, + mkdirSync, + writeFileSync, + copyFileSync, +} from "fs"; import { access as fsAccess, readFile as fsReadFile, stat as fsStat } from "fs/promises"; import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter } from "path"; import { userInfo } from "os"; -import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts"; -import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, RuntimeLaneSnapshot, SupervisorAlertCallback } from "./types.ts"; +import { + DONE_GRACE_MS, + EXECUTION_POLL_INTERVAL_MS, + ExecutionError, + SESSION_SPAWN_RETRY_MAX, +} from "./types.ts"; +import type { + AllocatedLane, + AllocatedTask, + DependencyGraph, + LaneExecutionResult, + LaneMonitorSnapshot, + LaneTaskOutcome, + LaneTaskStatus, + MonitorState, + MtimeTracker, + OrchestratorConfig, + ParsedTask, + TaskMonitorSnapshot, + WaveExecutionResult, + WorkspaceConfig, + ExecutionUnit, + PacketPaths, + RuntimeAgentId, + RuntimeAgentRole, + RuntimeLaneSnapshot, + SupervisorAlertCallback, +} from "./types.ts"; import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts"; import type { TaskExitDiagnostic } from "./diagnostics.ts"; -import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot, writeLaneSnapshot } from "./process-registry.ts"; +import { + readRegistrySnapshot, + readLaneSnapshot, + isTerminalStatus, + isProcessAlive, + detectOrphans, + markOrphansCrashed, + buildRegistrySnapshot, + writeRegistrySnapshot, + writeLaneSnapshot, +} from "./process-registry.ts"; import { allocateLanes } from "./waves.ts"; import { resolveOperatorId } from "./naming.ts"; import { runGit, runGitWithEnv } from "./git.ts"; @@ -74,7 +118,11 @@ export function execLog( * @returns true if agent is alive * @since TP-112 */ -export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: RuntimeBackend, laneNumber?: number): boolean { +export function isV2AgentAlive( + agentIdOrSessionName: string, + _runtimeBackend?: RuntimeBackend, + laneNumber?: number, +): boolean { // Read the registry from the global state root. // Since this is a pure liveness check, we scan for matching agentId // patterns: direct match, or lane-session + "-worker" suffix. @@ -85,15 +133,24 @@ export function isV2AgentAlive(agentIdOrSessionName: string, _runtimeBackend?: R if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) return true; // Try worker suffix (monitor uses lane session name, registry uses agentId) const workerManifest = agents[`${agentIdOrSessionName}-worker`]; - if (workerManifest && !isTerminalStatus(workerManifest.status) && isProcessAlive(workerManifest.pid)) return true; + if ( + workerManifest && + !isTerminalStatus(workerManifest.status) && + isProcessAlive(workerManifest.pid) + ) + return true; // TP-148: In workspace mode, laneSessionId includes repoId and uses a local // lane number (e.g., "orch-henry-api-lane-1") while the V2 registry uses // global lane numbers without repoId (e.g., "orch-henry-lane-3-worker"). // Fall back to scanning the registry by global lane number when provided. if (laneNumber != null) { for (const agent of Object.values(agents)) { - if (agent.laneNumber === laneNumber && agent.role === "worker" && - !isTerminalStatus(agent.status) && isProcessAlive(agent.pid)) { + if ( + agent.laneNumber === laneNumber && + agent.role === "worker" && + !isTerminalStatus(agent.status) && + isProcessAlive(agent.pid) + ) { return true; } } @@ -109,7 +166,9 @@ let _v2LivenessRegistryCache: import("./process-registry.ts").RuntimeRegistry | * Called at the start of each monitor poll to avoid re-reading the file per-task. * @since TP-112 */ -export function setV2LivenessRegistryCache(registry: import("./process-registry.ts").RuntimeRegistry | null): void { +export function setV2LivenessRegistryCache( + registry: import("./process-registry.ts").RuntimeRegistry | null, +): void { _v2LivenessRegistryCache = registry; } @@ -125,11 +184,11 @@ export function killV2LaneAgents( sessionName: string, options?: { stateRoot?: string; batchId?: string; logContext?: string; laneNumber?: number }, ): void { - const registry = _v2LivenessRegistryCache ?? ( - options?.stateRoot && options?.batchId + const registry = + _v2LivenessRegistryCache ?? + (options?.stateRoot && options?.batchId ? readRegistrySnapshot(options.stateRoot, options.batchId) - : null - ); + : null); if (!registry) return; const agents = registry.agents; @@ -138,25 +197,38 @@ export function killV2LaneAgents( for (const suffix of ["-worker", "-reviewer", ""]) { const key = `${sessionName}${suffix}`; const manifest = agents[key]; - if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid) && !killedPids.has(manifest.pid)) { + if ( + manifest && + !isTerminalStatus(manifest.status) && + isProcessAlive(manifest.pid) && + !killedPids.has(manifest.pid) + ) { try { process.kill(manifest.pid, "SIGTERM"); killedPids.add(manifest.pid); execLog(logContext, key, `killed V2 agent (PID ${manifest.pid})`); - } catch { /* already dead */ } + } catch { + /* already dead */ + } } } // TP-148: Workspace-mode fallback — match by global lane number when // session name lookup misses (repoId/local-vs-global lane mismatch). if (options?.laneNumber != null) { for (const agent of Object.values(agents)) { - if (agent.laneNumber === options.laneNumber && - !isTerminalStatus(agent.status) && isProcessAlive(agent.pid) && !killedPids.has(agent.pid)) { + if ( + agent.laneNumber === options.laneNumber && + !isTerminalStatus(agent.status) && + isProcessAlive(agent.pid) && + !killedPids.has(agent.pid) + ) { try { process.kill(agent.pid, "SIGTERM"); killedPids.add(agent.pid); execLog(logContext, agent.agentId, `killed V2 agent by lane number (PID ${agent.pid})`); - } catch { /* already dead */ } + } catch { + /* already dead */ + } } } } @@ -164,7 +236,6 @@ export function killV2LaneAgents( // ── Async File/Status Helpers (TP-070) ─────────────────────────────── - /** * Async version of readTaskStatusTail — reads STATUS.md tail without * blocking the event loop. @@ -215,10 +286,7 @@ function laneSessionIdOf(lane: Pick): string { * Logs are written under the lane worktree to keep per-lane execution * artifacts colocated with task state and available after failures. */ -export function resolveLaneLogPath( - lane: AllocatedLane, - task: AllocatedTask, -): string { +export function resolveLaneLogPath(lane: AllocatedLane, task: AllocatedTask): string { return join(lane.worktreePath, ".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`); } @@ -227,10 +295,7 @@ export function resolveLaneLogPath( * * Relative paths avoid Windows drive-letter parsing issues in shell redirection. */ -export function resolveLaneLogRelativePath( - lane: AllocatedLane, - task: AllocatedTask, -): string { +export function resolveLaneLogRelativePath(lane: AllocatedLane, task: AllocatedTask): string { return join(".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`).replace(/\\/g, "/"); } @@ -449,7 +514,6 @@ export function resolveTaskDonePath( return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode).donePath; } - /* * Removed in TP-120 while decommissioning the legacy session backend. * @@ -466,7 +530,11 @@ export async function pollUntilTaskComplete( _pauseSignal: { paused: boolean }, _isWorkspaceMode?: boolean, ): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> { - return { status: "failed", exitReason: "Legacy pollUntilTaskComplete removed — use V2 lane-runner", doneFileFound: false }; + return { + status: "failed", + exitReason: "Legacy pollUntilTaskComplete removed — use V2 lane-runner", + doneFileFound: false, + }; } // ── Post-Task Commit ───────────────────────────────────────────────── @@ -485,11 +553,7 @@ export async function pollUntilTaskComplete( * @param task - The task that just completed * @param laneId - Lane identifier for logging */ -function commitTaskArtifacts( - lane: AllocatedLane, - task: AllocatedTask, - laneId: string, -): void { +function commitTaskArtifacts(lane: AllocatedLane, task: AllocatedTask, laneId: string): void { const worktreePath = lane.worktreePath; // Check if there are any uncommitted changes in the worktree @@ -502,7 +566,11 @@ function commitTaskArtifacts( // Stage all changes in the worktree const addResult = runGit(["add", "-A"], worktreePath); if (!addResult.ok) { - execLog(laneId, task.taskId, `post-task stage failed (non-fatal): ${addResult.stderr.slice(0, 200)}`); + execLog( + laneId, + task.taskId, + `post-task stage failed (non-fatal): ${addResult.stderr.slice(0, 200)}`, + ); return; } @@ -514,7 +582,11 @@ function commitTaskArtifacts( if (!commitResult.ok) { // "nothing to commit" is not an error — worker may have already committed if (!commitResult.stderr.includes("nothing to commit")) { - execLog(laneId, task.taskId, `post-task commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`); + execLog( + laneId, + task.taskId, + `post-task commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`, + ); } return; } @@ -524,9 +596,6 @@ function commitTaskArtifacts( }); } - - - // ── STATUS.md Parsing for Worktree ─────────────────────────────────── /** @@ -583,7 +652,10 @@ export function parseWorktreeStatusMd( content = readFileSync(statusPath, "utf-8"); mtime = statSync(statusPath).mtimeMs; } catch (err: unknown) { - return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` }; + return { + parsed: null, + error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}`, + }; } // Parse using same regex patterns as task-runner's parseStatusMd @@ -607,7 +679,7 @@ export function parseWorktreeStatusMd( const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/); if (stepMatch) { if (currentStep) { - const totalChecked = currentStep.checkboxes.filter(c => c).length; + const totalChecked = currentStep.checkboxes.filter((c) => c).length; steps.push({ number: currentStep.number, name: currentStep.name, @@ -641,7 +713,7 @@ export function parseWorktreeStatusMd( } } if (currentStep) { - const totalChecked = currentStep.checkboxes.filter(c => c).length; + const totalChecked = currentStep.checkboxes.filter((c) => c).length; steps.push({ number: currentStep.number, name: currentStep.name, @@ -708,7 +780,10 @@ async function parseStatusMdContent( content = await fsReadFile(statusPath, "utf-8"); mtime = (await fsStat(statusPath)).mtimeMs; } catch (err: unknown) { - return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` }; + return { + parsed: null, + error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}`, + }; } // Parse logic is identical to the sync version @@ -732,7 +807,7 @@ async function parseStatusMdContent( const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/); if (stepMatch) { if (currentStep) { - const totalChecked = currentStep.checkboxes.filter(c => c).length; + const totalChecked = currentStep.checkboxes.filter((c) => c).length; steps.push({ number: currentStep.number, name: currentStep.name, @@ -766,7 +841,7 @@ async function parseStatusMdContent( } } if (currentStep) { - const totalChecked = currentStep.checkboxes.filter(c => c).length; + const totalChecked = currentStep.checkboxes.filter((c) => c).length; steps.push({ number: currentStep.number, name: currentStep.name, @@ -782,7 +857,6 @@ async function parseStatusMdContent( }; } - // ── State Resolution ───────────────────────────────────────────────── /** @@ -829,7 +903,7 @@ export async function resolveTaskMonitorState( // Snapshot not written yet OR snapshot still points to a prior task. // Assume alive initially, but if stale for >30s consult the registry // to avoid indefinite false "running" if the lane-runner died. - const staleMs = snap?.updatedAt ? (now - snap.updatedAt) : 0; + const staleMs = snap?.updatedAt ? now - snap.updatedAt : 0; const trackerAgeMs = now - tracker.firstObservedAt; if (staleMs > 30_000) { // Snapshot hasn't been updated for 30s+ — check registry as fallback. @@ -875,7 +949,7 @@ export async function resolveTaskMonitorState( const trackerAgeMs = now - tracker.firstObservedAt; if ( snap.updatedAt && - (now - snap.updatedAt) > stallTimeoutMs / 2 && + now - snap.updatedAt > stallTimeoutMs / 2 && trackerAgeMs >= 60_000 && !isV2AgentAlive(sessionName, runtimeBackend, v2Context?.laneNumber) ) { @@ -918,13 +992,13 @@ export async function resolveTaskMonitorState( } // Find the current step (first in-progress, or first not-started after last complete) - const inProgress = steps.find(s => s.status === "in-progress"); + const inProgress = steps.find((s) => s.status === "in-progress"); if (inProgress) { currentStepName = inProgress.name; currentStepNumber = inProgress.number; } else { // Find first not-started step - const notStarted = steps.find(s => s.status === "not-started"); + const notStarted = steps.find((s) => s.status === "not-started"); if (notStarted) { currentStepName = notStarted.name; currentStepNumber = notStarted.number; @@ -979,7 +1053,7 @@ export async function resolveTaskMonitorState( sessionAlive && tracker.statusFileSeenOnce && tracker.stallTimerStart !== null && - (now - tracker.stallTimerStart) >= stallTimeoutMs + now - tracker.stallTimerStart >= stallTimeoutMs ) { const stallMinutes = Math.round((now - tracker.stallTimerStart) / 60_000); const stallReason = `STATUS.md unchanged for ${stallMinutes} minutes (threshold: ${Math.round(stallTimeoutMs / 60_000)} min)`; @@ -1052,7 +1126,6 @@ export async function resolveTaskMonitorState( }; } - // ── Core Monitor Loop ──────────────────────────────────────────────── /** @@ -1136,10 +1209,15 @@ export async function monitorLanes( // Build the total task count const tasksTotal = lanes.reduce((sum, lane) => sum + lane.tasks.length, 0); - execLog("monitor", "ALL", `starting monitoring for ${lanes.length} lane(s), ${tasksTotal} task(s)`, { - pollIntervalMs, - stallTimeoutMin: Math.round(stallTimeoutMs / 60_000), - }); + execLog( + "monitor", + "ALL", + `starting monitoring for ${lanes.length} lane(s), ${tasksTotal} task(s)`, + { + pollIntervalMs, + stallTimeoutMin: Math.round(stallTimeoutMs / 60_000), + }, + ); while (true) { const now = Date.now(); @@ -1239,17 +1317,23 @@ export async function monitorLanes( stallTimeoutMs, now, runtimeBackend, - (runtimeBackend === "v2" && batchId) ? { - stateRoot: stateRootForRegistry ?? repoRoot, - batchId, - laneNumber: lane.laneNumber, - } : undefined, + runtimeBackend === "v2" && batchId + ? { + stateRoot: stateRootForRegistry ?? repoRoot, + batchId, + laneNumber: lane.laneNumber, + } + : undefined, ); currentTaskSnapshot = snapshot; // Check if this task just became terminal - if (snapshot.status === "succeeded" || snapshot.status === "failed" || snapshot.status === "stalled") { + if ( + snapshot.status === "succeeded" || + snapshot.status === "failed" || + snapshot.status === "stalled" + ) { terminalTasks.set(task.taskId, snapshot); if (snapshot.status === "succeeded") { completedTasks.push(task.taskId); @@ -1322,8 +1406,12 @@ export async function monitorLanes( // Log summary only on state changes (lane completes or fails) — not every poll const currentStateKey = `${totalDone}/${totalFailed}`; if (currentStateKey !== lastMonitorStateKey) { - const activeLanes = laneSnapshots.filter(l => l.currentTaskId !== null); - execLog("monitor", "ALL", `poll #${pollCount}: ${totalDone}/${tasksTotal} done, ${totalFailed} failed, ${activeLanes.length} active lane(s)`); + const activeLanes = laneSnapshots.filter((l) => l.currentTaskId !== null); + execLog( + "monitor", + "ALL", + `poll #${pollCount}: ${totalDone}/${tasksTotal} done, ${totalFailed} failed, ${activeLanes.length} active lane(s)`, + ); lastMonitorStateKey = currentStateKey; } @@ -1340,12 +1428,12 @@ export async function monitorLanes( } // Wait for next poll cycle - await new Promise(r => setTimeout(r, pollIntervalMs)); + await new Promise((r) => setTimeout(r, pollIntervalMs)); } // Reached here due to pause signal — return current state const now = Date.now(); - const laneSnapshots: LaneMonitorSnapshot[] = lanes.map(lane => ({ + const laneSnapshots: LaneMonitorSnapshot[] = lanes.map((lane) => ({ laneId: lane.laneId, laneNumber: lane.laneNumber, sessionName: laneSessionIdOf(lane), @@ -1354,7 +1442,7 @@ export async function monitorLanes( currentTaskSnapshot: null, completedTasks: [], failedTasks: [], - remainingTasks: lane.tasks.map(t => t.taskId), + remainingTasks: lane.tasks.map((t) => t.taskId), })); setV2LivenessRegistryCache(null); @@ -1370,7 +1458,6 @@ export async function monitorLanes( }; } - // ── Transitive Dependent Computation ───────────────────────────────── /** @@ -1414,7 +1501,6 @@ export function computeTransitiveDependents( return blocked; } - // ── Pre-flight: Commit Untracked Task Files ───────────────────────── /** @@ -1499,29 +1585,31 @@ export function ensureTaskFilesCommitted( try { // Read orch branch tree into temporary index - const readTreeRes = runGitWithEnv( - ["read-tree", orchTip], - repoRoot, - { GIT_INDEX_FILE: tmpIdx }, - ); + const readTreeRes = runGitWithEnv(["read-tree", orchTip], repoRoot, { GIT_INDEX_FILE: tmpIdx }); if (!readTreeRes.ok) { - execLog("wave", `W${waveIndex}`, `orch branch staging: read-tree failed, falling back to HEAD commit`, { - error: readTreeRes.stderr, - }); + execLog( + "wave", + `W${waveIndex}`, + `orch branch staging: read-tree failed, falling back to HEAD commit`, + { + error: readTreeRes.stderr, + }, + ); // Fall through to legacy path } else { // Add task files to temporary index let addFailed = false; for (const folder of foldersToStage) { - const addRes = runGitWithEnv( - ["add", "--", folder], - repoRoot, - { GIT_INDEX_FILE: tmpIdx }, - ); + const addRes = runGitWithEnv(["add", "--", folder], repoRoot, { GIT_INDEX_FILE: tmpIdx }); if (!addRes.ok) { - execLog("wave", `W${waveIndex}`, `orch branch staging: git add failed for ${folder}, falling back`, { - error: addRes.stderr, - }); + execLog( + "wave", + `W${waveIndex}`, + `orch branch staging: git add failed for ${folder}, falling back`, + { + error: addRes.stderr, + }, + ); addFailed = true; break; } @@ -1529,15 +1617,11 @@ export function ensureTaskFilesCommitted( if (!addFailed) { // Write tree from temporary index - const writeTreeRes = runGitWithEnv( - ["write-tree"], - repoRoot, - { GIT_INDEX_FILE: tmpIdx }, - ); + const writeTreeRes = runGitWithEnv(["write-tree"], repoRoot, { GIT_INDEX_FILE: tmpIdx }); if (writeTreeRes.ok) { const tree = writeTreeRes.stdout.trim(); - const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", "); + const taskIds = foldersToStage.map((f) => f.split("/").pop() || f).join(", "); const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`; // Create commit directly on orch branch @@ -1554,14 +1638,23 @@ export function ensureTaskFilesCommitted( ); if (refUpdateRes.ok) { - execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) directly on orch branch`, { - orchBranch, - folders: foldersToStage, - from: orchTip.slice(0, 8), - to: newCommit.slice(0, 8), - }); + execLog( + "wave", + `W${waveIndex}`, + `committed ${foldersToStage.length} task folder(s) directly on orch branch`, + { + orchBranch, + folders: foldersToStage, + from: orchTip.slice(0, 8), + to: newCommit.slice(0, 8), + }, + ); // Clean up temp index and return — no need for legacy path - try { unlinkSync(tmpIdx); } catch { /* best effort */ } + try { + unlinkSync(tmpIdx); + } catch { + /* best effort */ + } return; } execLog("wave", `W${waveIndex}`, `orch branch staging: ref update failed, falling back`, { @@ -1580,12 +1673,21 @@ export function ensureTaskFilesCommitted( } } } catch (err: unknown) { - execLog("wave", `W${waveIndex}`, `orch branch staging: unexpected error, falling back to HEAD commit`, { - error: err instanceof Error ? err.message : String(err), - }); + execLog( + "wave", + `W${waveIndex}`, + `orch branch staging: unexpected error, falling back to HEAD commit`, + { + error: err instanceof Error ? err.message : String(err), + }, + ); } finally { // Always clean up temp index - try { unlinkSync(tmpIdx); } catch { /* best effort */ } + try { + unlinkSync(tmpIdx); + } catch { + /* best effort */ + } } } } @@ -1609,7 +1711,7 @@ export function ensureTaskFilesCommitted( } // Commit - const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", "); + const taskIds = foldersToStage.map((f) => f.split("/").pop() || f).join(", "); const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`; const commitResult = runGit(["commit", "-m", commitMsg], repoRoot); if (!commitResult.ok) { @@ -1622,10 +1724,15 @@ export function ensureTaskFilesCommitted( ); } - execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) to ensure worktree visibility`, { - folders: foldersToStage, - commit: commitResult.stdout.trim().split("\n")[0], - }); + execLog( + "wave", + `W${waveIndex}`, + `committed ${foldersToStage.length} task folder(s) to ensure worktree visibility`, + { + folders: foldersToStage, + commit: commitResult.stdout.trim().split("\n")[0], + }, + ); // Fast-forward (or merge) the orch branch to include the staging commit so // that worktrees—which branch from orchBranch—see the new task files and @@ -1639,10 +1746,7 @@ export function ensureTaskFilesCommitted( const newHead = headRes.stdout.trim(); const orchTip = orchTipRes.stdout.trim(); - const ancestorCheck = runGit( - ["merge-base", "--is-ancestor", orchTip, newHead], - repoRoot, - ); + const ancestorCheck = runGit(["merge-base", "--is-ancestor", orchTip, newHead], repoRoot); if (ancestorCheck.ok) { const ffResult = runGit( @@ -1662,10 +1766,7 @@ export function ensureTaskFilesCommitted( }); } } else { - const mergeTreeRes = runGit( - ["merge-tree", "--write-tree", orchTip, newHead], - repoRoot, - ); + const mergeTreeRes = runGit(["merge-tree", "--write-tree", orchTip, newHead], repoRoot); if (mergeTreeRes.ok) { const mergedTree = mergeTreeRes.stdout.trim().split("\n")[0]; if (/^[0-9a-f]{40}$/i.test(mergedTree)) { @@ -1692,10 +1793,15 @@ export function ensureTaskFilesCommitted( } } } catch (refErr: unknown) { - execLog("wave", `W${waveIndex}`, `warning: orch branch ref update threw unexpectedly (non-fatal)`, { - orchBranch, - error: refErr instanceof Error ? refErr.message : String(refErr), - }); + execLog( + "wave", + `W${waveIndex}`, + `warning: orch branch ref update threw unexpectedly (non-fatal)`, + { + orchBranch, + error: refErr instanceof Error ? refErr.message : String(refErr), + }, + ); } } } @@ -1768,8 +1874,18 @@ export async function executeWave( runtimeBackend?: RuntimeBackend, onSupervisorAlert?: SupervisorAlertCallback, supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous", - reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] }, - workerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null, + reviewerConfig?: { + model?: string; + thinking?: string; + tools?: string; + excludeExtensions?: string[]; + }, + workerConfig?: { + model?: string; + thinking?: string; + tools?: string; + excludeExtensions?: string[]; + } | null, workerExcludeExtensions?: string[], onLaneTerminated?: import("./types.ts").LaneTerminatedCallback, onLaneRespawned?: (laneNumber: number, agentId: string, batchId: string) => void, @@ -1814,7 +1930,15 @@ export async function executeWave( } // ── Stage 1: Allocate lanes ────────────────────────────────── - const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, orchBranch, workspaceConfig); + const allocResult = allocateLanes( + waveTasks, + pending, + config, + repoRoot, + batchId, + orchBranch, + workspaceConfig, + ); if (!allocResult.success) { const errMsg = allocResult.error?.message || "Unknown allocation failure"; @@ -1859,7 +1983,11 @@ export async function executeWave( const isWsMode = !!workspaceConfig; const backend: RuntimeBackend = "v2"; if (runtimeBackend && runtimeBackend !== "v2") { - execLog("wave", `W${waveIndex}`, `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`); + execLog( + "wave", + `W${waveIndex}`, + `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`, + ); } execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)"); @@ -1870,19 +1998,39 @@ export async function executeWave( const snapshotStateRoot = resolveRuntimeStateRoot(repoRoot, wsRoot); for (const lane of lanes) { try { - const snapPath = join(snapshotStateRoot, ".pi", "runtime", batchId, "lanes", `lane-${lane.laneNumber}.json`); + const snapPath = join( + snapshotStateRoot, + ".pi", + "runtime", + batchId, + "lanes", + `lane-${lane.laneNumber}.json`, + ); if (existsSync(snapPath)) unlinkSync(snapPath); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } - const lanePromises = lanes.map(lane => - executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { - ORCH_BATCH_ID: batchId, - TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy, - ...buildWorkerEnv(workerConfig), - ...buildReviewerEnv(reviewerConfig), - ...buildWorkerExcludeEnv(workerExcludeExtensions), - }, onSupervisorAlert, onLaneTerminated, onLaneRespawned), + const lanePromises = lanes.map((lane) => + executeLaneV2( + lane, + config, + repoRoot, + wavePauseSignal, + wsRoot, + isWsMode, + { + ORCH_BATCH_ID: batchId, + TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy, + ...buildWorkerEnv(workerConfig), + ...buildReviewerEnv(reviewerConfig), + ...buildWorkerExcludeEnv(workerExcludeExtensions), + }, + onSupervisorAlert, + onLaneTerminated, + onLaneRespawned, + ), ); // Start monitoring as a sibling async loop @@ -1929,7 +2077,7 @@ export async function executeWave( return { laneNumber: lanes[idx].laneNumber, laneId: lanes[idx].laneId, - tasks: lanes[idx].tasks.map(t => ({ + tasks: lanes[idx].tasks.map((t) => ({ taskId: t.taskId, status: "failed" as LaneTaskStatus, startTime: null, @@ -1947,8 +2095,8 @@ export async function executeWave( // For stop-wave: if any task failed, set pause to prevent next wave if (policy === "stop-wave") { - const hasFailure = laneResults.some(lr => - lr.tasks.some(t => t.status === "failed" || t.status === "stalled"), + const hasFailure = laneResults.some((lr) => + lr.tasks.some((t) => t.status === "failed" || t.status === "stalled"), ); if (hasFailure) { wavePauseSignal.paused = true; @@ -1991,21 +2139,24 @@ export async function executeWave( // Compute blocked tasks for future waves (skip-dependents policy) let blockedTaskIds: string[] = []; if (policy === "skip-dependents" && failedTaskIds.length > 0) { - const blocked = computeTransitiveDependents( - new Set(failedTaskIds), - dependencyGraph, - ); + const blocked = computeTransitiveDependents(new Set(failedTaskIds), dependencyGraph); blockedTaskIds = [...blocked].sort(); if (blockedTaskIds.length > 0) { - execLog("wave", `W${waveIndex}`, `skip-dependents: ${blockedTaskIds.length} task(s) blocked for future waves`, { - blocked: blockedTaskIds.join(","), - }); + execLog( + "wave", + `W${waveIndex}`, + `skip-dependents: ${blockedTaskIds.length} task(s) blocked for future waves`, + { + blocked: blockedTaskIds.join(","), + }, + ); } } // Determine overall wave status - const stoppedEarly = policy === "stop-all" && failedTaskIds.length > 0 - || policy === "stop-wave" && failedTaskIds.length > 0; + const stoppedEarly = + (policy === "stop-all" && failedTaskIds.length > 0) || + (policy === "stop-wave" && failedTaskIds.length > 0); let overallStatus: WaveExecutionResult["overallStatus"]; if (policy === "stop-all" && failedTaskIds.length > 0) { @@ -2085,9 +2236,7 @@ export async function executeWithStopAll( // Check if any task failed if (!abortTriggered) { - const hasFailure = result.tasks.some( - t => t.status === "failed" || t.status === "stalled", - ); + const hasFailure = result.tasks.some((t) => t.status === "failed" || t.status === "stalled"); if (hasFailure) { // First failure detected — trigger stop-all abortTriggered = true; @@ -2095,7 +2244,7 @@ export async function executeWithStopAll( // Determine which task failed first for logging const firstFailed = result.tasks - .filter(t => t.status === "failed" || t.status === "stalled") + .filter((t) => t.status === "failed" || t.status === "stalled") .sort((a, b) => { // Sort by startTime, then by taskId for deterministic tie-break const timeA = a.startTime || 0; @@ -2104,9 +2253,14 @@ export async function executeWithStopAll( return a.taskId.localeCompare(b.taskId); })[0]; - execLog("wave", `W${waveIndex}`, `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, { - session: laneSessionIdOf(lanes[idx]), - }); + execLog( + "wave", + `W${waveIndex}`, + `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, + { + session: laneSessionIdOf(lanes[idx]), + }, + ); // Kill ALL lane sessions immediately for (const lane of lanes) { @@ -2122,7 +2276,11 @@ export async function executeWithStopAll( if (!abortTriggered) { abortTriggered = true; pauseSignal.paused = true; - execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`); + execLog( + "wave", + `W${waveIndex}`, + `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`, + ); for (const lane of lanes) { killV2LaneAgents(laneSessionIdOf(lane), { laneNumber: lane.laneNumber }); } @@ -2132,7 +2290,7 @@ export async function executeWithStopAll( const failedResult: LaneExecutionResult = { laneNumber: lanes[idx].laneNumber, laneId: lanes[idx].laneId, - tasks: lanes[idx].tasks.map(t => ({ + tasks: lanes[idx].tasks.map((t) => ({ taskId: t.taskId, status: "failed" as LaneTaskStatus, startTime: null, @@ -2155,14 +2313,17 @@ export async function executeWithStopAll( await Promise.allSettled(wrappedPromises); // Fill in any null results (shouldn't happen, but defensive) - return results.map((r, idx) => r || { - laneNumber: lanes[idx].laneNumber, - laneId: lanes[idx].laneId, - tasks: [], - overallStatus: "failed" as const, - startTime: Date.now(), - endTime: Date.now(), - }); + return results.map( + (r, idx) => + r || { + laneNumber: lanes[idx].laneNumber, + laneId: lanes[idx].laneId, + tasks: [], + overallStatus: "failed" as const, + startTime: Date.now(), + endTime: Date.now(), + }, + ); } // ── Runtime V2 Bridge Helpers (TP-102) ───────────────────────────────────── @@ -2223,8 +2384,8 @@ export function buildExecutionUnit( throw new ExecutionError( "EXEC_MISSING_TASK_FOLDER", `Cannot build execution unit for task ${task.taskId}: taskFolder is ${taskFolder === "" ? "empty" : "undefined"}. ` + - `This typically means the task's persisted record was not enriched with discovery data. ` + - `Re-run discovery or check that the task exists in the task area.`, + `This typically means the task's persisted record was not enriched with discovery data. ` + + `Re-run discovery or check that the task exists in the task area.`, "execution", task.taskId, ); @@ -2248,18 +2409,17 @@ export function buildExecutionUnit( // the execution repo (cross-repo segment). When they're the same repo, // resolve packet paths inside the worktree so .DONE, STATUS.md etc. are // written to the worktree (not the original repo outside the worktree). - const useAbsolutePacketPath = task.task.packetTaskPath - && packetHomeRepoId !== executionRepoId; + const useAbsolutePacketPath = task.task.packetTaskPath && packetHomeRepoId !== executionRepoId; const packet = useAbsolutePacketPath ? resolvePacketPaths(task.task.packetTaskPath!) : { - promptPath: resolved.taskFolderResolved + "/PROMPT.md", - statusPath: resolved.statusPath, - donePath: resolved.donePath, - reviewsDir: resolved.taskFolderResolved + "/.reviews", - taskFolder: resolved.taskFolderResolved, - }; + promptPath: resolved.taskFolderResolved + "/PROMPT.md", + statusPath: resolved.statusPath, + donePath: resolved.donePath, + reviewsDir: resolved.taskFolderResolved + "/.reviews", + taskFolder: resolved.taskFolderResolved, + }; return { id, @@ -2339,7 +2499,9 @@ function parseAgentFile(filePath: string): { fm: Record; body: s if (m) fm[m[1]] = m[2].trim(); } return { fm, body: raw.slice(fmEnd + 3).trim() }; - } catch { return null; } + } catch { + return null; + } } /** @@ -2357,7 +2519,9 @@ function loadBaseAgentPrompt(agentName: string): string { const def = parseAgentFile(resolved); if (def?.body) return def.body; } - } catch { /* fall through */ } + } catch { + /* fall through */ + } return ""; } @@ -2433,11 +2597,11 @@ function resolveAgentPointerRoot(): string | null { * @returns Composed agent definition, or null if no base and no local file found * @since TP-161 */ -export function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null { - const localPaths = [ - join(cwd, ".pi", "agents", `${name}.md`), - join(cwd, "agents", `${name}.md`), - ]; +export function loadAgentDef( + cwd: string, + name: string, +): { systemPrompt: string; tools: string; model: string } | null { + const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)]; // In workspace mode, add pointer-resolved agent root as fallback const agentRoot = resolveAgentPointerRoot(); @@ -2452,7 +2616,9 @@ export function loadAgentDef(cwd: string, name: string): { systemPrompt: string; if (existsSync(basePath)) { baseDef = parseAgentFile(basePath); } - } catch { /* fall through */ } + } catch { + /* fall through */ + } // Load local override (first found wins) let localDef: { fm: Record; body: string } | null = null; @@ -2487,10 +2653,7 @@ export function loadAgentDef(cwd: string, name: string): { systemPrompt: string; return { systemPrompt: composedPrompt.trim(), tools, model }; } -export function resolveRuntimeStateRoot( - repoRoot: string, - workspaceRoot?: string, -): string { +export function resolveRuntimeStateRoot(repoRoot: string, workspaceRoot?: string): string { return workspaceRoot ?? repoRoot; } @@ -2532,13 +2695,21 @@ function parseJsonArrayEnv(value?: string): string[] { if (!value) return []; try { const parsed = JSON.parse(value); - if (Array.isArray(parsed)) return parsed.filter((v: unknown): v is string => typeof v === "string"); - } catch { /* ignore malformed */ } + if (Array.isArray(parsed)) + return parsed.filter((v: unknown): v is string => typeof v === "string"); + } catch { + /* ignore malformed */ + } return []; } export function buildReviewerEnv( - reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null, + reviewerConfig?: { + model?: string; + thinking?: string; + tools?: string; + excludeExtensions?: string[]; + } | null, ): Record { const env: Record = {}; if (reviewerConfig?.model) env.TASKPLANE_REVIEWER_MODEL = reviewerConfig.model; @@ -2560,7 +2731,12 @@ export function buildReviewerEnv( * @since TP-181 */ export function buildWorkerEnv( - workerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null, + workerConfig?: { + model?: string; + thinking?: string; + tools?: string; + excludeExtensions?: string[]; + } | null, ): Record { const env: Record = {}; if (workerConfig?.model) env.TASKPLANE_WORKER_MODEL = workerConfig.model; @@ -2620,7 +2796,8 @@ export async function executeLaneV2( // The base template (templates/agents/task-worker.md) contains critical behavioral // rules: checkpoint discipline, STATUS.md resume algorithm, review_step instructions. // The local file (.pi/agents/task-worker.md) adds project-specific guidance. - let workerSystemPrompt = "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each."; + let workerSystemPrompt = + "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each."; let workerSegmentPrompt = ""; try { const basePrompt = loadBaseAgentPrompt("task-worker"); @@ -2635,7 +2812,9 @@ export async function executeLaneV2( // Load segment-scoped prompt overlay (appended when isSegmentScoped) const segPrompt = loadBaseAgentPrompt("task-worker-segment"); if (segPrompt) workerSegmentPrompt = segPrompt; - } catch { /* use default */ } + } catch { + /* use default */ + } execLog(laneId, "LANE", `starting Runtime V2 execution of ${lane.tasks.length} task(s)`, { worktree: lane.worktreePath, @@ -2647,16 +2826,26 @@ export async function executeLaneV2( // this lane number is lifted before new alerts begin to flow. if (onLaneRespawned) { try { - onLaneRespawned(lane.laneNumber, buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"), batchId); + onLaneRespawned( + lane.laneNumber, + buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"), + batchId, + ); } catch (err) { - execLog(laneId, "LANE", `lane-respawned callback failed: ${err instanceof Error ? err.message : String(err)}`); + execLog( + laneId, + "LANE", + `lane-respawned callback failed: ${err instanceof Error ? err.message : String(err)}`, + ); } } for (const task of lane.tasks) { const taskSegmentId = task.task.activeSegmentId ?? null; if (shouldSkipRemaining || pauseSignal.paused) { - const reason = pauseSignal.paused ? "Skipped due to pause signal" : "Skipped due to prior task failure in lane"; + const reason = pauseSignal.paused + ? "Skipped due to pause signal" + : "Skipped due to prior task failure in lane"; outcomes.push({ taskId: task.taskId, status: "skipped", @@ -2674,10 +2863,12 @@ export async function executeLaneV2( // Build execution unit const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode); - const rawAutonomy = String(extraEnvVars?.TASKPLANE_SUPERVISOR_AUTONOMY ?? "autonomous").toLowerCase(); + const rawAutonomy = String( + extraEnvVars?.TASKPLANE_SUPERVISOR_AUTONOMY ?? "autonomous", + ).toLowerCase(); const supervisorAutonomy: LaneRunnerConfig["supervisorAutonomy"] = - (rawAutonomy === "interactive" || rawAutonomy === "supervised" || rawAutonomy === "autonomous") - ? rawAutonomy as LaneRunnerConfig["supervisorAutonomy"] + rawAutonomy === "interactive" || rawAutonomy === "supervised" || rawAutonomy === "autonomous" + ? (rawAutonomy as LaneRunnerConfig["supervisorAutonomy"]) : "autonomous"; const laneRunnerConfig: LaneRunnerConfig = { @@ -2701,7 +2892,9 @@ export async function executeLaneV2( reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "", // TP-180: Extension exclusion lists from config workerExcludeExtensions: parseJsonArrayEnv(extraEnvVars?.TASKPLANE_WORKER_EXCLUDE_EXTENSIONS), - reviewerExcludeExtensions: parseJsonArrayEnv(extraEnvVars?.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS), + reviewerExcludeExtensions: parseJsonArrayEnv( + extraEnvVars?.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS, + ), supervisorAutonomy, projectName: config.project?.name || "project", maxIterations: 20, @@ -2811,13 +3004,22 @@ export async function executeLaneV2( progress: null, updatedAt: Date.now(), }; - writeLaneSnapshot(stateRoot, batchId, lane.laneNumber, spawnFailureSnapshot as unknown as Record); + writeLaneSnapshot( + stateRoot, + batchId, + lane.laneNumber, + spawnFailureSnapshot as unknown as Record, + ); } catch (snapErr) { // Best effort — if the snapshot write fails, the monitor's // 30s-staleness fallback (snap with old updatedAt) eventually // kicks in via the registry liveness check. Log so this is // visible in operator diagnostics, but do NOT throw. - execLog(laneId, task.taskId, `spawn-failure snapshot write failed (non-fatal): ${snapErr instanceof Error ? snapErr.message : String(snapErr)}`); + execLog( + laneId, + task.taskId, + `spawn-failure snapshot write failed (non-fatal): ${snapErr instanceof Error ? snapErr.message : String(snapErr)}`, + ); } shouldSkipRemaining = true; @@ -2825,8 +3027,8 @@ export async function executeLaneV2( } const endTime = Date.now(); - const succeeded = outcomes.every(o => o.status === "succeeded"); - const failed = outcomes.some(o => o.status === "failed" || o.status === "stalled"); + const succeeded = outcomes.every((o) => o.status === "succeeded"); + const failed = outcomes.some((o) => o.status === "failed" || o.status === "stalled"); return { laneNumber: lane.laneNumber, @@ -2839,4 +3041,3 @@ export async function executeLaneV2( } // ── /orch Command — Full Execution (Step 5) ───────────────────────── - diff --git a/extensions/taskplane/extension.ts b/extensions/taskplane/extension.ts index af9f70c1..5cd458a0 100644 --- a/extensions/taskplane/extension.ts +++ b/extensions/taskplane/extension.ts @@ -2,27 +2,69 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age import { Type } from "@mariozechner/pi-ai"; import { execSync, execFileSync } from "child_process"; -import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream, renameSync } from "fs"; +import { + writeFileSync, + unlinkSync, + mkdirSync, + existsSync, + readdirSync, + readFileSync, + statSync, + createWriteStream, + renameSync, +} from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { fork, type ChildProcess } from "child_process"; // Direct imports — avoid barrel (index.ts) to prevent loading the entire module graph. // Each import targets the specific module where the symbol is defined. -import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG, FATAL_DISCOVERY_CODES, StateFileError, WorkspaceConfigError, freshOrchBatchState } from "./types.ts"; -import type { AbortMode, ExecutionContext, MonitorState, OrchestratorConfig, PersistedBatchState, TaskRunnerConfig } from "./types.ts"; +import { + DEFAULT_ORCHESTRATOR_CONFIG, + DEFAULT_TASK_RUNNER_CONFIG, + FATAL_DISCOVERY_CODES, + StateFileError, + WorkspaceConfigError, + freshOrchBatchState, +} from "./types.ts"; +import type { + AbortMode, + ExecutionContext, + MonitorState, + OrchestratorConfig, + PersistedBatchState, + TaskRunnerConfig, +} from "./types.ts"; import { ORCH_MESSAGES, computeIntegrateCleanupResult } from "./messages.ts"; import type { IntegrateCleanupRepoFindings } from "./messages.ts"; import { computeWaveAssignments } from "./waves.ts"; import { createOrchWidget, formatDependencyGraph, formatWavePlan } from "./formatting.ts"; -import { deleteBatchState, loadBatchState, saveBatchState, detectOrphanSessions, updateBatchHistoryIntegration } from "./persistence.ts"; -import { deleteStaleBranches, listWorktrees, resolveWorktreeBasePath, formatPreflightResults, runPreflight } from "./worktree.ts"; +import { + deleteBatchState, + loadBatchState, + saveBatchState, + detectOrphanSessions, + updateBatchHistoryIntegration, +} from "./persistence.ts"; +import { + deleteStaleBranches, + listWorktrees, + resolveWorktreeBasePath, + formatPreflightResults, + runPreflight, +} from "./worktree.ts"; import { computeTransitiveDependents, resolveCanonicalTaskPaths } from "./execution.ts"; import { executeOrchBatch } from "./engine.ts"; import { formatDiscoveryResults, runDiscovery } from "./discovery.ts"; import { formatOrchSessions, listOrchSessions } from "./sessions.ts"; import { getCurrentBranch, runGit } from "./git.ts"; -import { hasConfigFiles, resolveConfigRoot, loadOrchestratorConfig, loadSupervisorConfig, loadTaskRunnerConfig } from "./config.ts"; +import { + hasConfigFiles, + resolveConfigRoot, + loadOrchestratorConfig, + loadSupervisorConfig, + loadTaskRunnerConfig, +} from "./config.ts"; import { resolveOperatorId } from "./naming.ts"; import { reconstructAllocatedLanes, resumeOrchBatch } from "./resume.ts"; import { buildExecutionContext } from "./workspace.ts"; @@ -30,9 +72,20 @@ import { openSettingsTui } from "./settings-tui.ts"; import { loadProjectConfig } from "./config-loader.ts"; import { runMigrations } from "./migrations.ts"; import { executeAbort } from "./abort.ts"; -import { serializeWorkspaceConfig, applySerializedState, deserializeWorkspaceConfig } from "./engine-worker.ts"; +import { + serializeWorkspaceConfig, + applySerializedState, + deserializeWorkspaceConfig, +} from "./engine-worker.ts"; import type { EngineWorkerData, WorkerToMainMessage } from "./engine-worker.ts"; -import { cleanupPostIntegrate, formatPostIntegrateCleanup, sweepStaleArtifacts, formatPreflightSweep, rotateSupervisorLogs, formatLogRotation } from "./cleanup.ts"; +import { + cleanupPostIntegrate, + formatPostIntegrateCleanup, + sweepStaleArtifacts, + formatPreflightSweep, + rotateSupervisorLogs, + formatLogRotation, +} from "./cleanup.ts"; import { writeMailboxMessage, readOutbox, @@ -65,7 +118,13 @@ import { presentBatchSummary, resolveModelFromString, } from "./supervisor.ts"; -import type { SupervisorConfig, SupervisorRoutingContext, IntegrationExecutor, CiDeps, SummaryDeps } from "./supervisor.ts"; +import type { + SupervisorConfig, + SupervisorRoutingContext, + IntegrationExecutor, + CiDeps, + SummaryDeps, +} from "./supervisor.ts"; // ── Integrate Args Parsing ──────────────────────────────────────────── @@ -118,7 +177,9 @@ export function parseIntegrateArgs(raw: string | undefined): IntegrateArgs | { e if (hasPr) mode = "pr"; if (positionals.length > 1) { - return { error: `Expected at most one branch argument, got ${positionals.length}: ${positionals.join(", ")}` }; + return { + error: `Expected at most one branch argument, got ${positionals.length}: ${positionals.join(", ")}`, + }; } return { @@ -153,7 +214,10 @@ export function parseResumeArgs(raw: string | undefined): ResumeArgs | { error: if (token === "--force") { force = true; } else if (token === "--help") { - return { error: "Usage: /orch-resume [--force]\n\n --force Resume from stopped or failed state (runs pre-resume diagnostics first)" }; + return { + error: + "Usage: /orch-resume [--force]\n\n --force Resume from stopped or failed state (runs pre-resume diagnostics first)", + }; } else if (token.startsWith("--")) { return { error: `Unknown flag: ${token}\n\nUsage: /orch-resume [--force]` }; } else { @@ -250,13 +314,14 @@ export function resolveIntegrationContext( } } catch (err: unknown) { // Capture the error but don't return yet — user may have provided a branch arg - const msg = err instanceof StateFileError - ? (err.code === "STATE_FILE_IO_ERROR" - ? `Could not read batch state file: ${err.message}` - : err.code === "STATE_FILE_PARSE_ERROR" - ? `Batch state file contains invalid JSON: ${err.message}` - : `Batch state file has invalid schema: ${err.message}`) - : `Unexpected error loading batch state: ${(err as Error).message}`; + const msg = + err instanceof StateFileError + ? err.code === "STATE_FILE_IO_ERROR" + ? `Could not read batch state file: ${err.message}` + : err.code === "STATE_FILE_PARSE_ERROR" + ? `Batch state file contains invalid JSON: ${err.message}` + : `Batch state file has invalid schema: ${err.message}` + : `Unexpected error loading batch state: ${(err as Error).message}`; if (!parsed.orchBranchArg) { return { error: `⚠️ ${msg}\nYou can specify the orch branch directly: /orch-integrate `, @@ -289,7 +354,7 @@ export function resolveIntegrationContext( return { error: `❌ No batch state found and multiple orch branches exist:\n` + - candidates.map(b => ` • ${b}`).join("\n") + + candidates.map((b) => ` • ${b}`).join("\n") + `\n\nSpecify which branch to integrate: /orch-integrate `, severity: "error", }; @@ -458,7 +523,13 @@ export function executeIntegration( let stashed = false; const statusCheck = deps.runGit(["status", "--porcelain"]); if (statusCheck.ok && statusCheck.stdout.trim()) { - deps.runGit(["stash", "push", "--include-untracked", "-m", `orch-integrate-autostash-${batchId}`]); + deps.runGit([ + "stash", + "push", + "--include-untracked", + "-m", + `orch-integrate-autostash-${batchId}`, + ]); stashed = true; } @@ -471,9 +542,10 @@ export function executeIntegration( if (!result.ok) { // TP-052: Include branch protection hint when merge fails - const protectionHint = result.stderr.includes("protected") || result.stderr.includes("permission") - ? `\n\n 💡 If the branch is protected, use --pr to create a pull request.` - : ""; + const protectionHint = + result.stderr.includes("protected") || result.stderr.includes("permission") + ? `\n\n 💡 If the branch is protected, use --pr to create a pull request.` + : ""; return { success: false, integratedLocally: false, @@ -508,7 +580,13 @@ export function executeIntegration( let mergeStashed = false; const mergeStatusCheck = deps.runGit(["status", "--porcelain"]); if (mergeStatusCheck.ok && mergeStatusCheck.stdout.trim()) { - deps.runGit(["stash", "push", "--include-untracked", "-m", `orch-integrate-autostash-${batchId}`]); + deps.runGit([ + "stash", + "push", + "--include-untracked", + "-m", + `orch-integrate-autostash-${batchId}`, + ]); mergeStashed = true; } @@ -520,9 +598,10 @@ export function executeIntegration( if (!result.ok) { // TP-052: Include branch protection hint when merge fails - const mergeProtectionHint = result.stderr.includes("protected") || result.stderr.includes("permission") - ? `\n\n 💡 If the branch is protected, use --pr to create a pull request.` - : ""; + const mergeProtectionHint = + result.stderr.includes("protected") || result.stderr.includes("permission") + ? `\n\n 💡 If the branch is protected, use --pr to create a pull request.` + : ""; return { success: false, integratedLocally: false, @@ -561,14 +640,16 @@ export function executeIntegration( } // Step 2: Create pull request via gh CLI - const prTitle = batchId - ? `Integrate orch batch ${batchId}` - : `Integrate ${orchBranch}`; + const prTitle = batchId ? `Integrate orch batch ${batchId}` : `Integrate ${orchBranch}`; const ghResult = deps.runCommand("gh", [ - "pr", "create", - "--base", currentBranch, - "--head", orchBranch, - "--title", prTitle, + "pr", + "create", + "--base", + currentBranch, + "--head", + orchBranch, + "--title", + prTitle, "--fill", ]); if (!ghResult.ok) { @@ -714,8 +795,10 @@ export function collectRepoCleanupFindings( // 1. Stale lane worktrees — check for any worktrees belonging to this operator+batch try { const wts = listWorktrees(worktreePrefix, repoRoot, opId, batchId); - findings.staleWorktrees = wts.map(wt => wt.path); - } catch { /* best effort — git worktree list may fail in unusual states */ } + findings.staleWorktrees = wts.map((wt) => wt.path); + } catch { + /* best effort — git worktree list may fail in unusual states */ + } // 2. Lane branches — task/{opId}-lane-* and saved/task/{opId}-lane-* try { @@ -723,7 +806,7 @@ export function collectRepoCleanupFindings( if (branchResult.ok && branchResult.stdout.trim()) { findings.staleLaneBranches = branchResult.stdout .split("\n") - .map(b => b.replace(/^\*?\s+/, "").trim()) + .map((b) => b.replace(/^\*?\s+/, "").trim()) .filter(Boolean); } // Also detect saved lane branches (preserved refs from worktree removal) @@ -731,11 +814,13 @@ export function collectRepoCleanupFindings( if (savedBranchResult.ok && savedBranchResult.stdout.trim()) { const savedBranches = savedBranchResult.stdout .split("\n") - .map(b => b.replace(/^\*?\s+/, "").trim()) + .map((b) => b.replace(/^\*?\s+/, "").trim()) .filter(Boolean); findings.staleLaneBranches.push(...savedBranches); } - } catch { /* best effort */ } + } catch { + /* best effort */ + } // 3. Orch branch — check if the specific orch branch still exists // Skip in PR mode where the orch branch is intentionally preserved for the PR. @@ -745,7 +830,9 @@ export function collectRepoCleanupFindings( if (orchCheck.ok) { findings.staleOrchBranches = [orchBranch]; } - } catch { /* best effort */ } + } catch { + /* best effort */ + } } // 4. Autostash entries — same patterns as dropBatchAutostash @@ -766,7 +853,9 @@ export function collectRepoCleanupFindings( } } } - } catch { /* best effort */ } + } catch { + /* best effort */ + } } // 5. Non-empty .worktrees/ containers (subdirectory mode only) @@ -779,7 +868,9 @@ export function collectRepoCleanupFindings( findings.nonEmptyWorktreeContainers = [basePath]; } } - } catch { /* best effort */ } + } catch { + /* best effort */ + } } return findings; @@ -853,8 +944,14 @@ export function validateModelAvailability( agentModels?: { workerModel?: string; reviewerModel?: string }, ): ModelCheckResult[] { const entries: ModelCheckEntry[] = [ - { role: "Worker", modelStr: agentModels?.workerModel ?? (runnerConfig as any).worker?.model ?? "" }, - { role: "Reviewer", modelStr: agentModels?.reviewerModel ?? (runnerConfig as any).reviewer?.model ?? "" }, + { + role: "Worker", + modelStr: agentModels?.workerModel ?? (runnerConfig as any).worker?.model ?? "", + }, + { + role: "Reviewer", + modelStr: agentModels?.reviewerModel ?? (runnerConfig as any).reviewer?.model ?? "", + }, { role: "Merger", modelStr: orchConfig.merge?.model ?? "" }, { role: "Supervisor", modelStr: supervisorConfig.model ?? "" }, ]; @@ -954,7 +1051,7 @@ export function startBatchAsync( } ctx.ui.notify( `❌ Engine crashed with unhandled error: ${errMsg}\n` + - ` Batch ${batchState.batchId} marked as failed.`, + ` Batch ${batchState.batchId} marked as failed.`, "error", ); updateWidget(); @@ -1050,40 +1147,53 @@ export function startBatchInWorker( const wsConfig = wkData.workspaceConfig ? deserializeWorkspaceConfig(wkData.workspaceConfig) : undefined; - const fallbackFn = wkData.mode === "resume" - ? () => resumeOrchBatch( - wkData.orchConfig, - wkData.runnerConfig, - wkData.cwd, - batchState, - (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); }, - (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); }, - wsConfig, - wkData.workspaceRoot, - wkData.agentRoot, - wkData.force ?? false, - onSupervisorAlert ?? null, - wkData.supervisorAutonomy ?? "autonomous", - null, // onLaneTerminated — main-thread fallback path; alerts are local-only - null, // onLaneRespawned — main-thread fallback path; suppression maps stay clear - ) - : () => executeOrchBatch( - wkData.args ?? "", - wkData.orchConfig, - wkData.runnerConfig, - wkData.cwd, - batchState, - (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); }, - (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); }, - wsConfig, - wkData.workspaceRoot, - wkData.agentRoot, - null, // onEngineEvent - onSupervisorAlert ?? null, - wkData.supervisorAutonomy ?? "autonomous", - null, // onLaneTerminated — main-thread fallback path - null, // onLaneRespawned — main-thread fallback path - ); + const fallbackFn = + wkData.mode === "resume" + ? () => + resumeOrchBatch( + wkData.orchConfig, + wkData.runnerConfig, + wkData.cwd, + batchState, + (msg: string, lvl: "info" | "warning" | "error") => { + ctx.ui.notify(msg, lvl); + updateWidget(); + }, + (monState: import("./types.ts").MonitorState) => { + onMonitorUpdate?.(monState); + }, + wsConfig, + wkData.workspaceRoot, + wkData.agentRoot, + wkData.force ?? false, + onSupervisorAlert ?? null, + wkData.supervisorAutonomy ?? "autonomous", + null, // onLaneTerminated — main-thread fallback path; alerts are local-only + null, // onLaneRespawned — main-thread fallback path; suppression maps stay clear + ) + : () => + executeOrchBatch( + wkData.args ?? "", + wkData.orchConfig, + wkData.runnerConfig, + wkData.cwd, + batchState, + (msg: string, lvl: "info" | "warning" | "error") => { + ctx.ui.notify(msg, lvl); + updateWidget(); + }, + (monState: import("./types.ts").MonitorState) => { + onMonitorUpdate?.(monState); + }, + wsConfig, + wkData.workspaceRoot, + wkData.agentRoot, + null, // onEngineEvent + onSupervisorAlert ?? null, + wkData.supervisorAutonomy ?? "autonomous", + null, // onLaneTerminated — main-thread fallback path + null, // onLaneRespawned — main-thread fallback path + ); startBatchAsync(fallbackFn, batchState, ctx, updateWidget, onTerminal); return null; } @@ -1095,7 +1205,9 @@ export function startBatchInWorker( let stderrBatchId = toSafeBatchId(batchState.batchId || pendingBatchId); let stderrLogPath = join(telemetryDir, `${stderrBatchId}-engine-worker-stderr.log`); let stderrLogStream = createWriteStream(stderrLogPath, { flags: "a" }); - stderrLogStream.on("error", () => { /* non-fatal: telemetry stream */ }); + stderrLogStream.on("error", () => { + /* non-fatal: telemetry stream */ + }); let stderrTailBuffer = ""; const appendStderr = (chunk: Buffer | string) => { @@ -1128,7 +1240,9 @@ export function startBatchInWorker( stderrBatchId = resolvedBatchId; stderrLogPath = nextPath; stderrLogStream = createWriteStream(stderrLogPath, { flags: "a" }); - stderrLogStream.on("error", () => { /* non-fatal: telemetry stream */ }); + stderrLogStream.on("error", () => { + /* non-fatal: telemetry stream */ + }); }; const readStderrTail = (lineCount = 25): string => { @@ -1138,7 +1252,9 @@ export function startBatchInWorker( if (!content) { try { if (existsSync(stderrLogPath)) content = readFileSync(stderrLogPath, "utf-8"); - } catch { /* fallback: empty */ } + } catch { + /* fallback: empty */ + } } const lines = content.split(/\r?\n/).filter(Boolean); if (lines.length === 0) return "(no stderr output captured)"; @@ -1221,8 +1337,8 @@ export function startBatchInWorker( } ctx.ui.notify( `❌ Engine crashed with unhandled error${sourceLabel}: ${msg.message}\n` + - (stackLine ? ` ${stackLine}\n` : "") + - ` Batch ${batchState.batchId} marked as failed.`, + (stackLine ? ` ${stackLine}\n` : "") + + ` Batch ${batchState.batchId} marked as failed.`, "error", ); // Alert supervisor — this is the PRIMARY notification path for engine @@ -1241,20 +1357,27 @@ export function startBatchInWorker( ` - orch_status() to inspect state\n` + ` - orch_resume(force=true) to retry from last checkpoint`, context: { - batchProgress: batchState.totalTasks > 0 ? { - succeededTasks: batchState.succeededTasks, - failedTasks: batchState.failedTasks, - skippedTasks: batchState.skippedTasks, - blockedTasks: batchState.blockedTasks, - totalTasks: batchState.totalTasks, - currentWave: batchState.currentWaveIndex + 1, - totalWaves: batchState.taskLevelWaveCount ?? batchState.totalWaves, - } : undefined, + batchProgress: + batchState.totalTasks > 0 + ? { + succeededTasks: batchState.succeededTasks, + failedTasks: batchState.failedTasks, + skippedTasks: batchState.skippedTasks, + blockedTasks: batchState.blockedTasks, + totalTasks: batchState.totalTasks, + currentWave: batchState.currentWaveIndex + 1, + totalWaves: batchState.taskLevelWaveCount ?? batchState.totalWaves, + } + : undefined, }, }); // Persist failed state to disk so dashboard/resume see it. // The engine-worker is dead and can't persist — we must do it here. - try { saveBatchState(JSON.stringify(batchState, null, 2), wkData.cwd); } catch { /* best effort */ } + try { + saveBatchState(JSON.stringify(batchState, null, 2), wkData.cwd); + } catch { + /* best effort */ + } updateWidget(); break; } @@ -1270,8 +1393,7 @@ export function startBatchInWorker( batchState.errors.push(`Engine process error: ${err.message}`); } ctx.ui.notify( - `❌ Engine process error: ${err.message}\n` + - ` Batch ${batchState.batchId} marked as failed.`, + `❌ Engine process error: ${err.message}\n` + ` Batch ${batchState.batchId} marked as failed.`, "error", ); updateWidget(); @@ -1287,15 +1409,18 @@ export function startBatchInWorker( ` - orch_status() to inspect state\n` + ` - orch_resume(force=true) to retry from last checkpoint`, context: { - batchProgress: batchState.totalTasks > 0 ? { - succeededTasks: batchState.succeededTasks, - failedTasks: batchState.failedTasks, - skippedTasks: batchState.skippedTasks, - blockedTasks: batchState.blockedTasks, - totalTasks: batchState.totalTasks, - currentWave: batchState.currentWaveIndex + 1, - totalWaves: batchState.taskLevelWaveCount ?? batchState.totalWaves, - } : undefined, + batchProgress: + batchState.totalTasks > 0 + ? { + succeededTasks: batchState.succeededTasks, + failedTasks: batchState.failedTasks, + skippedTasks: batchState.skippedTasks, + blockedTasks: batchState.blockedTasks, + totalTasks: batchState.totalTasks, + currentWave: batchState.currentWaveIndex + 1, + totalWaves: batchState.taskLevelWaveCount ?? batchState.totalWaves, + } + : undefined, }, }); settle(); @@ -1316,10 +1441,7 @@ export function startBatchInWorker( batchState.endedAt = Date.now(); batchState.errors.push(`Engine process exited with code ${code}`); } - ctx.ui.notify( - `❌ Engine process exited unexpectedly (code ${code}).`, - "error", - ); + ctx.ui.notify(`❌ Engine process exited unexpectedly (code ${code}).`, "error"); updateWidget(); // ── TP-076: Alert supervisor about unexpected engine exit ── onSupervisorAlert?.({ @@ -1333,19 +1455,26 @@ export function startBatchInWorker( ` - orch_status() to inspect state\n` + ` - orch_resume(force=true) to retry from last checkpoint`, context: { - batchProgress: batchState.totalTasks > 0 ? { - succeededTasks: batchState.succeededTasks, - failedTasks: batchState.failedTasks, - skippedTasks: batchState.skippedTasks, - blockedTasks: batchState.blockedTasks, - totalTasks: batchState.totalTasks, - currentWave: batchState.currentWaveIndex + 1, - totalWaves: batchState.taskLevelWaveCount ?? batchState.totalWaves, - } : undefined, + batchProgress: + batchState.totalTasks > 0 + ? { + succeededTasks: batchState.succeededTasks, + failedTasks: batchState.failedTasks, + skippedTasks: batchState.skippedTasks, + blockedTasks: batchState.blockedTasks, + totalTasks: batchState.totalTasks, + currentWave: batchState.currentWaveIndex + 1, + totalWaves: batchState.taskLevelWaveCount ?? batchState.totalWaves, + } + : undefined, }, }); // Persist failed state to disk (engine is dead, can't persist itself) - try { saveBatchState(JSON.stringify(batchState, null, 2), wkData.cwd); } catch { /* best effort */ } + try { + saveBatchState(JSON.stringify(batchState, null, 2), wkData.cwd); + } catch { + /* best effort */ + } } settle(); }); @@ -1370,7 +1499,11 @@ export function startBatchInWorker( * * @since TP-043 R002 */ -export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateRoot?: string): IntegrationExecutor { +export function buildIntegrationExecutor( + repoRoot: string, + opId?: string, + stateRoot?: string, +): IntegrationExecutor { return (mode, context) => { // Ensure we're on the base branch before integrating const currentBranch = getCurrentBranch(repoRoot); @@ -1409,16 +1542,24 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateR } }, deleteBatchState: () => { - try { deleteBatchState(stateRoot ?? repoRoot); } catch { /* best effort */ } + try { + deleteBatchState(stateRoot ?? repoRoot); + } catch { + /* best effort */ + } }, }; const effectiveStateRoot = stateRoot ?? repoRoot; const result = withPreservedBatchHistory(effectiveStateRoot, () => - executeIntegration(mode as IntegrateMode, { - ...context, - currentBranch: context.baseBranch, - }, deps), + executeIntegration( + mode as IntegrateMode, + { + ...context, + currentBranch: context.baseBranch, + }, + deps, + ), ); // TP-051: Clean up stale task/* and saved/* branches after successful integration. @@ -1428,18 +1569,24 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateR try { deleteStaleBranches(repoRoot, opId, context.batchId); dropBatchAutostash(repoRoot, context.batchId); - } catch { /* best effort — don't fail integration for cleanup errors */ } + } catch { + /* best effort — don't fail integration for cleanup errors */ + } // TP-065: Post-integrate artifact cleanup (Layer 1). // Also runs on the supervisor auto-integration path. try { cleanupPostIntegrate(stateRoot ?? repoRoot, context.batchId); - } catch { /* best effort — don't fail integration for cleanup errors */ } + } catch { + /* best effort — don't fail integration for cleanup errors */ + } // TP-179: Write integratedAt to batch history before state is gone try { updateBatchHistoryIntegration(stateRoot ?? repoRoot, context.batchId, Date.now()); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } return result; @@ -1479,7 +1626,11 @@ export function buildCiDeps(repoRoot: string, stateRoot?: string): CiDeps { }, runGit: (gitArgs: string[]) => runGit(gitArgs, repoRoot), deleteBatchState: () => { - try { deleteBatchState(stateRoot ?? repoRoot); } catch { /* best effort */ } + try { + deleteBatchState(stateRoot ?? repoRoot); + } catch { + /* best effort */ + } }, }; } @@ -1617,16 +1768,16 @@ export function detectOrchState(deps: OrchStateDetectionDeps): OrchStateDetectio // Covers the case where batch-state.json was deleted but an orch branch remains. const orchBranches = deps.listOrchBranches(); if (orchBranches.length > 0) { - const branchList = orchBranches.map(b => `\`${b}\``).join(", "); + const branchList = orchBranches.map((b) => `\`${b}\``).join(", "); return { state: "completed-batch", orchBranch: orchBranches[0], contextMessage: orchBranches.length === 1 ? `I found an orch branch (${branchList}) that hasn't been integrated yet. ` + - `Want me to integrate it, or would you like to start fresh?` + `Want me to integrate it, or would you like to start fresh?` : `I found ${orchBranches.length} orch branches (${branchList}) that haven't been integrated. ` + - `Would you like to integrate one, or start fresh?`, + `Would you like to integrate one, or start fresh?`, }; } @@ -1702,7 +1853,7 @@ export default function (pi: ExtensionAPI) { if (terminatedLanes.size === 0 && terminatedAgents.size === 0) return; process.stderr.write( `[taskplane:zombie-filter] cleared termination filter (reason: ${reason}, ` + - `lanes=${terminatedLanes.size}, agents=${terminatedAgents.size})\n`, + `lanes=${terminatedLanes.size}, agents=${terminatedAgents.size})\n`, ); terminatedLanes.clear(); terminatedAgents.clear(); @@ -1755,7 +1906,8 @@ export default function (pi: ExtensionAPI) { const ctx = alert.context; if (!ctx) return false; if (typeof ctx.laneNumber === "number" && terminatedLanes.has(ctx.laneNumber)) return true; - if (typeof ctx.agentId === "string" && ctx.agentId && terminatedAgents.has(ctx.agentId)) return true; + if (typeof ctx.agentId === "string" && ctx.agentId && terminatedAgents.has(ctx.agentId)) + return true; return false; }; @@ -1792,8 +1944,10 @@ export default function (pi: ExtensionAPI) { // ── Command Guard ──────────────────────────────────────────────── function getExecCtxInitErrorMessage(): string { - return execCtxInitError ?? - "❌ Orchestrator not initialized. Startup failed before execution context was created.\nRestart the session after fixing configuration/setup issues."; + return ( + execCtxInitError ?? + "❌ Orchestrator not initialized. Startup failed before execution context was created.\nRestart the session after fixing configuration/setup issues." + ); } /** @@ -1833,13 +1987,19 @@ export default function (pi: ExtensionAPI) { const detection = detectOrchState({ hasConfig: () => hasConfigFiles(resolvedConfigRoot), loadBatchState: () => { - try { return loadBatchState(stateRoot); } - catch { return null; } + try { + return loadBatchState(stateRoot); + } catch { + return null; + } }, listOrchBranches: () => { const result = runGit(["branch", "--list", "orch/*"], repoRoot); return result.ok - ? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean) + ? result.stdout + .split("\n") + .map((b) => b.replace(/^\*?\s+/, "").trim()) + .filter(Boolean) : []; }, countPendingTasks: () => { @@ -1851,7 +2011,9 @@ export default function (pi: ExtensionAPI) { workspaceConfig: execCtx.workspaceConfig, }); return discovery.pending.size; - } catch { return 0; } + } catch { + return 0; + } }, }); @@ -1859,7 +2021,7 @@ export default function (pi: ExtensionAPI) { if (detection.state === "active-batch") { ctx.ui.notify( `🔀 ${detection.contextMessage}\n\n` + - `Use /orch-status for full details, or /orch-pause to pause.`, + `Use /orch-status for full details, or /orch-pause to pause.`, "info", ); return; @@ -1905,15 +2067,15 @@ export default function (pi: ExtensionAPI) { if (!args?.trim()) { ctx.ui.notify( "Usage: /orch-plan [--refresh]\n\n" + - "Shows the execution plan (tasks, waves, lane assignments)\n" + - "without actually executing anything.\n\n" + - "Options:\n" + - " --refresh Force re-scan of areas (bypass dependency cache)\n\n" + - "Examples:\n" + - " /orch-plan all\n" + - " /orch-plan time-off notifications\n" + - " /orch-plan docs/task-management/domains/time-off/tasks\n" + - " /orch-plan all --refresh", + "Shows the execution plan (tasks, waves, lane assignments)\n" + + "without actually executing anything.\n\n" + + "Options:\n" + + " --refresh Force re-scan of areas (bypass dependency cache)\n\n" + + "Examples:\n" + + " /orch-plan all\n" + + " /orch-plan time-off notifications\n" + + " /orch-plan docs/task-management/domains/time-off/tasks\n" + + " /orch-plan all --refresh", "info", ); return; @@ -1927,7 +2089,7 @@ export default function (pi: ExtensionAPI) { if (!cleanArgs) { ctx.ui.notify( "Usage: /orch-plan [--refresh]\n" + - "Error: target argument required (e.g., 'all', area name, or path)", + "Error: target argument required (e.g., 'all', area name, or path)", "error", ); return; @@ -1951,7 +2113,10 @@ export default function (pi: ExtensionAPI) { useDependencyCache: orchConfig.dependencies.cache, workspaceConfig: execCtx!.workspaceConfig, }); - ctx.ui.notify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info"); + ctx.ui.notify( + formatDiscoveryResults(discovery), + discovery.errors.length > 0 ? "warning" : "info", + ); // Check for fatal errors const fatalCodes = new Set(FATAL_DISCOVERY_CODES); @@ -1967,14 +2132,12 @@ export default function (pi: ExtensionAPI) { "info", ); } - const hasStrictErrors = fatalErrors.some( - (e) => e.code === "TASK_ROUTING_STRICT", - ); + const hasStrictErrors = fatalErrors.some((e) => e.code === "TASK_ROUTING_STRICT"); if (hasStrictErrors) { ctx.ui.notify( "💡 Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" + - " Add a `## Execution Target` section with `Repo: ` to each task's PROMPT.md.\n" + - " To disable strict routing, set `routing.strict: false` in workspace config.", + " Add a `## Execution Target` section with `Repo: ` to each task's PROMPT.md.\n" + + " To disable strict routing, set `routing.strict: false` in workspace config.", "info", ); } @@ -1987,23 +2150,13 @@ export default function (pi: ExtensionAPI) { } // ── Section 3: Dependency Graph ────────────────────────── - ctx.ui.notify( - formatDependencyGraph(discovery.pending, discovery.completed), - "info", - ); + ctx.ui.notify(formatDependencyGraph(discovery.pending, discovery.completed), "info"); // ── Section 4: Waves + Estimate ────────────────────────── // Uses computeWaveAssignments pipeline only — NO re-parsing - const waveResult = computeWaveAssignments( - discovery.pending, - discovery.completed, - orchConfig, - { - workspaceRepoIds: execCtx!.workspaceConfig - ? execCtx!.workspaceConfig.repos.keys() - : undefined, - }, - ); + const waveResult = computeWaveAssignments(discovery.pending, discovery.completed, orchConfig, { + workspaceRepoIds: execCtx!.workspaceConfig ? execCtx!.workspaceConfig.repos.keys() : undefined, + }); ctx.ui.notify( formatWavePlan(waveResult, orchConfig.assignment.size_weights), @@ -2029,12 +2182,16 @@ export default function (pi: ExtensionAPI) { * * @since TP-061 */ - async function doOrchStart(target: string, ctx: ExtensionContext): Promise<{ message: string; error?: boolean }> { + async function doOrchStart( + target: string, + ctx: ExtensionContext, + ): Promise<{ message: string; error?: boolean }> { // Target validation const trimmedTarget = target?.trim(); if (!trimmedTarget) { return { - message: "❌ Target is required. Use \"all\" to run all pending tasks, or specify a task area name or path.", + message: + '❌ Target is required. Use "all" to run all pending tasks, or specify a task area name or path.', error: true, }; } @@ -2046,8 +2203,12 @@ export default function (pi: ExtensionAPI) { // Skip if a batch is already active to avoid swapping config mid-run. const _activePhase = orchBatchState.phase; // Treat paused as active — config must not change for a resumable batch - const _isActiveBatch = _activePhase === "executing" || _activePhase === "launching" - || _activePhase === "merging" || _activePhase === "planning" || _activePhase === "paused"; + const _isActiveBatch = + _activePhase === "executing" || + _activePhase === "launching" || + _activePhase === "merging" || + _activePhase === "planning" || + _activePhase === "paused"; if (!_isActiveBatch) { try { // Build everything into temporaries first, then commit atomically @@ -2055,10 +2216,7 @@ export default function (pi: ExtensionAPI) { const freshCtx = buildExecutionContext(ctx.cwd, loadOrchestratorConfig, loadTaskRunnerConfig); let freshSupervisor: SupervisorConfig; try { - freshSupervisor = loadSupervisorConfig( - freshCtx.repoRoot, - freshCtx.pointer?.configRoot, - ); + freshSupervisor = loadSupervisorConfig(freshCtx.repoRoot, freshCtx.pointer?.configRoot); } catch { freshSupervisor = { ...DEFAULT_SUPERVISOR_CONFIG }; } @@ -2089,7 +2247,7 @@ export default function (pi: ExtensionAPI) { } if (migrationResult.errors.length > 0) { ctx.ui.notify( - `⚠️ Migration warnings:\n${migrationResult.errors.map(e => ` ⚠ ${e.id}: ${e.error}`).join("\n")}`, + `⚠️ Migration warnings:\n${migrationResult.errors.map((e) => ` ⚠ ${e.id}: ${e.error}`).join("\n")}`, "warning", ); } @@ -2103,7 +2261,12 @@ export default function (pi: ExtensionAPI) { } // Prevent concurrent batch execution - if (orchBatchState.phase !== "idle" && orchBatchState.phase !== "completed" && orchBatchState.phase !== "failed" && orchBatchState.phase !== "stopped") { + if ( + orchBatchState.phase !== "idle" && + orchBatchState.phase !== "completed" && + orchBatchState.phase !== "failed" && + orchBatchState.phase !== "stopped" + ) { return { message: `⚠️ A batch is already ${orchBatchState.phase} (${orchBatchState.batchId}). Use /orch-pause to pause or wait for completion.`, error: true, @@ -2113,10 +2276,7 @@ export default function (pi: ExtensionAPI) { const { repoRoot } = execCtx; // Orphan detection - const orphanResult = detectOrphanSessions( - orchConfig.orchestrator.sessionPrefix, - repoRoot, - ); + const orphanResult = detectOrphanSessions(orchConfig.orchestrator.sessionPrefix, repoRoot); switch (orphanResult.recommendedAction) { case "resume": { @@ -2124,7 +2284,11 @@ export default function (pi: ExtensionAPI) { const phase = orphanResult.loadedState?.phase ?? ""; const hasOrphans = orphanResult.orphanSessions.length > 0; if (!hasOrphans && !resumablePhases.includes(phase)) { - try { deleteBatchState(repoRoot); } catch { /* best effort */ } + try { + deleteBatchState(repoRoot); + } catch { + /* best effort */ + } ctx.ui.notify( `🧹 Cleared non-resumable stale batch (${orphanResult.loadedState?.batchId}, phase=${phase}). Starting fresh.`, "info", @@ -2136,7 +2300,11 @@ export default function (pi: ExtensionAPI) { case "abort-orphans": return { message: orphanResult.userMessage, error: true }; case "cleanup-stale": - try { deleteBatchState(repoRoot); } catch { /* best effort */ } + try { + deleteBatchState(repoRoot); + } catch { + /* best effort */ + } if (orphanResult.userMessage) { ctx.ui.notify(orphanResult.userMessage, "info"); } @@ -2158,14 +2326,23 @@ export default function (pi: ExtensionAPI) { workerModel: fullConfig.taskRunner.worker.model || "", reviewerModel: fullConfig.taskRunner.reviewer.model || "", }; - } catch { /* fall through */ } - const modelResults = validateModelAvailability(orchConfig, runnerConfig, supervisorConfig, ctx, agentModels); - const modelFailures = modelResults.filter(r => r.status === "not-found"); + } catch { + /* fall through */ + } + const modelResults = validateModelAvailability( + orchConfig, + runnerConfig, + supervisorConfig, + ctx, + agentModels, + ); + const modelFailures = modelResults.filter((r) => r.status === "not-found"); ctx.ui.notify(formatModelValidation(modelResults), modelFailures.length > 0 ? "error" : "info"); if (modelFailures.length > 0) { return { - message: `❌ Cannot start batch — ${modelFailures.length} model(s) not found: ` + - modelFailures.map(f => `${f.role} (${f.modelStr})`).join(", ") + + message: + `❌ Cannot start batch — ${modelFailures.length} model(s) not found: ` + + modelFailures.map((f) => `${f.role} (${f.modelStr})`).join(", ") + `.\n\nFix the model configuration and try again.`, error: true, }; @@ -2175,11 +2352,16 @@ export default function (pi: ExtensionAPI) { // This is a lightweight synchronous check before launching the async engine. let pendingTaskCount = 0; try { - const preDiscovery = runDiscovery(trimmedTarget, runnerConfig.task_areas, execCtx.workspaceRoot, { - dependencySource: orchConfig.dependencies.source, - useDependencyCache: orchConfig.dependencies.cache, - workspaceConfig: execCtx.workspaceConfig, - }); + const preDiscovery = runDiscovery( + trimmedTarget, + runnerConfig.task_areas, + execCtx.workspaceRoot, + { + dependencySource: orchConfig.dependencies.source, + useDependencyCache: orchConfig.dependencies.cache, + workspaceConfig: execCtx.workspaceConfig, + }, + ); pendingTaskCount = preDiscovery.pending.size; if (pendingTaskCount === 0) { return { @@ -2222,13 +2404,15 @@ export default function (pi: ExtensionAPI) { ctx, updateOrchWidget, (monState: MonitorState) => { - const changed = !latestMonitorState || + const changed = + !latestMonitorState || latestMonitorState.totalDone !== monState.totalDone || latestMonitorState.totalFailed !== monState.totalFailed || - latestMonitorState.lanes.some((l, i) => - l.currentTaskId !== monState.lanes[i]?.currentTaskId || - l.currentStep !== monState.lanes[i]?.currentStep || - l.completedChecks !== monState.lanes[i]?.completedChecks, + latestMonitorState.lanes.some( + (l, i) => + l.currentTaskId !== monState.lanes[i]?.currentTaskId || + l.currentStep !== monState.lanes[i]?.currentStep || + l.completedChecks !== monState.lanes[i]?.completedChecks, ); latestMonitorState = monState; if (changed) updateOrchWidget(); @@ -2239,17 +2423,14 @@ export default function (pi: ExtensionAPI) { const sDeps: SummaryDeps = { opId, diagnostics: orchBatchState.diagnostics ?? null, - mergeResults: (orchBatchState.mergeResults || []).map(mr => ({ + mergeResults: (orchBatchState.mergeResults || []).map((mr) => ({ waveIndex: mr.waveIndex, status: mr.status, failedLane: mr.failedLane, failureReason: mr.failureReason, })), }; - if ( - orchBatchState.phase === "completed" && - (mode === "supervised" || mode === "auto") - ) { + if (orchBatchState.phase === "completed" && (mode === "supervised" || mode === "auto")) { triggerSupervisorIntegration( pi, supervisorState, @@ -2262,47 +2443,54 @@ export default function (pi: ExtensionAPI) { ); return; } - if ( - (mode === "supervised" || mode === "auto") && - orchBatchState.phase !== "completed" - ) { + if ((mode === "supervised" || mode === "auto") && orchBatchState.phase !== "completed") { pi.sendMessage( { customType: "supervisor-integration-skipped", - content: [{ - type: "text", - text: - `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` + - `Integration skipped — only completed batches are eligible.\n` + - `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`, - }], + content: [ + { + type: "text", + text: + `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` + + `Integration skipped — only completed batches are eligible.\n` + + `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`, + }, + ], display: `Integration skipped — batch ${orchBatchState.phase}`, }, { triggerTurn: false }, ); } - presentBatchSummary(pi, orchBatchState, execCtx!.workspaceRoot, opId, orchBatchState.diagnostics, sDeps.mergeResults); - const postBatchContext: SupervisorRoutingContext = orchBatchState.phase === "completed" - ? { - routingState: "completed-batch", - contextMessage: - `Batch **${orchBatchState.batchId}** completed — ` + - `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` + - `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` + - `Would you like me to integrate it, or would you prefer to review first?\n\n` + - `You can also:\n` + - `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` + - `• Create new tasks for the next batch\n` + - `• Run a health check`, - } - : { - routingState: "no-tasks", - contextMessage: - `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` + - `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` + - `${orchBatchState.skippedTasks} skipped.\n\n` + - `What would you like to do next?`, - }; + presentBatchSummary( + pi, + orchBatchState, + execCtx!.workspaceRoot, + opId, + orchBatchState.diagnostics, + sDeps.mergeResults, + ); + const postBatchContext: SupervisorRoutingContext = + orchBatchState.phase === "completed" + ? { + routingState: "completed-batch", + contextMessage: + `Batch **${orchBatchState.batchId}** completed — ` + + `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` + + `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` + + `Would you like me to integrate it, or would you prefer to review first?\n\n` + + `You can also:\n` + + `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` + + `• Create new tasks for the next batch\n` + + `• Run a health check`, + } + : { + routingState: "no-tasks", + contextMessage: + `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` + + `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` + + `${orchBatchState.skippedTasks} skipped.\n\n` + + `What would you like to do next?`, + }; transitionToRoutingMode(pi, supervisorState, postBatchContext); }, // ── TP-076: Supervisor alert handler — injects alerts as user messages ── @@ -2312,7 +2500,7 @@ export default function (pi: ExtensionAPI) { if (isAlertSuppressed(alert)) { process.stderr.write( `[taskplane:zombie-filter] dropped alert (category=${alert.category}, ` + - `lane=${alert.context?.laneNumber ?? "?"}, agent=${alert.context?.agentId ?? "?"})\n`, + `lane=${alert.context?.laneNumber ?? "?"}, agent=${alert.context?.agentId ?? "?"})\n`, ); return; } @@ -2323,7 +2511,7 @@ export default function (pi: ExtensionAPI) { if (!ipcBatchIdMatches(info.batchId)) { process.stderr.write( `[taskplane:zombie-filter] ignored stale lane-terminated IPC ` + - `(incoming batchId=${info.batchId}, current=${orchBatchState.batchId})\n`, + `(incoming batchId=${info.batchId}, current=${orchBatchState.batchId})\n`, ); return; } @@ -2331,7 +2519,7 @@ export default function (pi: ExtensionAPI) { if (info.agentId) terminatedAgents.set(info.agentId, info.terminatedAt); process.stderr.write( `[taskplane:zombie-filter] lane ${info.laneNumber} (${info.agentId}) terminated ` + - `(reason: ${info.reason}); ${terminatedLanes.size} lane(s) suppressed\n`, + `(reason: ${info.reason}); ${terminatedLanes.size} lane(s) suppressed\n`, ); }, // TP-187 (#538): Lane-respawned handler. @@ -2339,7 +2527,7 @@ export default function (pi: ExtensionAPI) { if (!ipcBatchIdMatches(incomingBatchId)) { process.stderr.write( `[taskplane:zombie-filter] ignored stale lane-respawned IPC ` + - `(incoming batchId=${incomingBatchId}, current=${orchBatchState.batchId})\n`, + `(incoming batchId=${incomingBatchId}, current=${orchBatchState.batchId})\n`, ); return; } @@ -2360,7 +2548,8 @@ export default function (pi: ExtensionAPI) { ); return { - message: `🚀 Batch launching (target: "${trimmedTarget}", ${pendingTaskCount} pending task${pendingTaskCount === 1 ? "" : "s"}). ` + + message: + `🚀 Batch launching (target: "${trimmedTarget}", ${pendingTaskCount} pending task${pendingTaskCount === 1 ? "" : "s"}). ` + `Batch ID will be assigned during planning. ` + `The engine is running asynchronously — use orch_status() to check progress.`, }; @@ -2373,13 +2562,19 @@ export default function (pi: ExtensionAPI) { } function buildTaskSegmentProgressLabel( - task: { taskId: string; segmentIds?: string[]; activeSegmentId?: string | null; status?: string } | undefined, - segments: Array<{ taskId: string; segmentId: string; status: string; repoId: string }> | undefined, + task: + | { taskId: string; segmentIds?: string[]; activeSegmentId?: string | null; status?: string } + | undefined, + segments: + | Array<{ taskId: string; segmentId: string; status: string; repoId: string }> + | undefined, preferredSegmentId?: string | null, ): string | null { if (!task || !Array.isArray(task.segmentIds) || task.segmentIds.length <= 1) return null; - const segmentIds = task.segmentIds.filter(segmentId => typeof segmentId === "string" && segmentId.trim().length > 0); + const segmentIds = task.segmentIds.filter( + (segmentId) => typeof segmentId === "string" && segmentId.trim().length > 0, + ); if (segmentIds.length <= 1) return null; const bySegmentId = new Map(); @@ -2391,10 +2586,11 @@ export default function (pi: ExtensionAPI) { let activeSegmentId = task.activeSegmentId ?? preferredSegmentId ?? null; if (!activeSegmentId || !segmentIds.includes(activeSegmentId)) { - activeSegmentId = segmentIds.find((segmentId) => { - const status = bySegmentId.get(segmentId)?.status; - return !["succeeded", "failed", "stalled", "skipped"].includes(status || "pending"); - }) || segmentIds[segmentIds.length - 1]; + activeSegmentId = + segmentIds.find((segmentId) => { + const status = bySegmentId.get(segmentId)?.status; + return !["succeeded", "failed", "stalled", "skipped"].includes(status || "pending"); + }) || segmentIds[segmentIds.length - 1]; } const index = Math.max(0, segmentIds.indexOf(activeSegmentId)); @@ -2432,7 +2628,9 @@ export default function (pi: ExtensionAPI) { ]; const segmentRecords = diskState.segments || []; - const multiSegmentTasks = (diskState.tasks || []).filter((task) => Array.isArray(task.segmentIds) && task.segmentIds.length > 1); + const multiSegmentTasks = (diskState.tasks || []).filter( + (task) => Array.isArray(task.segmentIds) && task.segmentIds.length > 1, + ); if (multiSegmentTasks.length > 0) { const byStatus = { succeeded: segmentRecords.filter((segment) => segment.status === "succeeded").length, @@ -2448,18 +2646,26 @@ export default function (pi: ExtensionAPI) { if (byStatus.pending > 0) segParts.push(`${byStatus.pending} pending`); if (byStatus.skipped > 0) segParts.push(`${byStatus.skipped} skipped`); if (byStatus.stalled > 0) segParts.push(`${byStatus.stalled} stalled`); - lines.push(` Segments: ${segParts.join(", ")} (${multiSegmentTasks.length} multi-segment task(s))`); + lines.push( + ` Segments: ${segParts.join(", ")} (${multiSegmentTasks.length} multi-segment task(s))`, + ); } const sortedDiskLanes = [...(diskState.lanes || [])].sort((a, b) => a.laneNumber - b.laneNumber); if (sortedDiskLanes.length > 0) { lines.push(" Lanes:"); for (const laneRec of sortedDiskLanes) { - const laneTasks = (diskState.tasks || []).filter((task) => task.laneNumber === laneRec.laneNumber); + const laneTasks = (diskState.tasks || []).filter( + (task) => task.laneNumber === laneRec.laneNumber, + ); const runningTask = laneTasks.find((task) => task.status === "running"); const activeTask = runningTask || laneTasks[laneTasks.length - 1]; const taskLabel = activeTask ? `${activeTask.taskId} (${activeTask.status})` : "idle"; - const segmentLabel = buildTaskSegmentProgressLabel(activeTask, segmentRecords, activeTask?.activeSegmentId ?? null); + const segmentLabel = buildTaskSegmentProgressLabel( + activeTask, + segmentRecords, + activeTask?.activeSegmentId ?? null, + ); const segmentPart = segmentLabel ? ` · ${segmentLabel}` : ""; const repoPart = laneRec.repoId ? ` · repo: ${laneRec.repoId}` : ""; lines.push(` - Lane ${laneRec.laneNumber}: ${taskLabel}${segmentPart}${repoPart}`); @@ -2486,7 +2692,12 @@ export default function (pi: ExtensionAPI) { const segmentRecords = orchBatchState.segments || []; const multiSegmentTaskCount = orchBatchState.currentLanes.reduce((count, laneRec) => { - return count + laneRec.tasks.filter((task) => Array.isArray(task.task.segmentIds) && task.task.segmentIds.length > 1).length; + return ( + count + + laneRec.tasks.filter( + (task) => Array.isArray(task.task.segmentIds) && task.task.segmentIds.length > 1, + ).length + ); }, 0); if (multiSegmentTaskCount > 0) { const byStatus = { @@ -2503,14 +2714,18 @@ export default function (pi: ExtensionAPI) { if (byStatus.pending > 0) segParts.push(`${byStatus.pending} pending`); if (byStatus.skipped > 0) segParts.push(`${byStatus.skipped} skipped`); if (byStatus.stalled > 0) segParts.push(`${byStatus.stalled} stalled`); - lines.push(` Segments: ${segParts.join(", ")} (${multiSegmentTaskCount} multi-segment task(s))`); + lines.push( + ` Segments: ${segParts.join(", ")} (${multiSegmentTaskCount} multi-segment task(s))`, + ); } if (orchBatchState.currentLanes.length > 0) { lines.push(" Lanes:"); const sortedLanes = [...orchBatchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber); for (const laneRec of sortedLanes) { - const monLane = latestMonitorState?.lanes.find((laneState) => laneState.laneNumber === laneRec.laneNumber); + const monLane = latestMonitorState?.lanes.find( + (laneState) => laneState.laneNumber === laneRec.laneNumber, + ); const currentTaskId = monLane?.currentTaskId || laneRec.tasks[0]?.taskId; const allocatedTask = currentTaskId ? laneRec.tasks.find((task) => task.taskId === currentTaskId) @@ -2540,7 +2755,12 @@ export default function (pi: ExtensionAPI) { * Core logic for orch-pause. Returns a status message string. */ function doOrchPause(): string { - if (orchBatchState.phase === "idle" || orchBatchState.phase === "completed" || orchBatchState.phase === "failed" || orchBatchState.phase === "stopped") { + if ( + orchBatchState.phase === "idle" || + orchBatchState.phase === "completed" || + orchBatchState.phase === "failed" || + orchBatchState.phase === "stopped" + ) { return ORCH_MESSAGES.pauseNoBatch(); } if (orchBatchState.phase === "paused" || orchBatchState.pauseSignal.paused) { @@ -2558,7 +2778,10 @@ export default function (pi: ExtensionAPI) { * The actual batch resume runs asynchronously via startBatchInWorker (TP-071). * Returns null if execCtx is missing (caller must handle). */ - function doOrchResume(force: boolean, ctx: ExtensionContext): { message: string; error?: boolean } { + function doOrchResume( + force: boolean, + ctx: ExtensionContext, + ): { message: string; error?: boolean } { if (!execCtx) { return { message: getExecCtxInitErrorMessage(), @@ -2567,7 +2790,12 @@ export default function (pi: ExtensionAPI) { } // Prevent resume if a batch is actively running - if (orchBatchState.phase === "launching" || orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") { + if ( + orchBatchState.phase === "launching" || + orchBatchState.phase === "executing" || + orchBatchState.phase === "merging" || + orchBatchState.phase === "planning" + ) { return { message: `⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`, error: true, @@ -2615,17 +2843,14 @@ export default function (pi: ExtensionAPI) { const sDeps: SummaryDeps = { opId, diagnostics: orchBatchState.diagnostics ?? null, - mergeResults: (orchBatchState.mergeResults || []).map(mr => ({ + mergeResults: (orchBatchState.mergeResults || []).map((mr) => ({ waveIndex: mr.waveIndex, status: mr.status, failedLane: mr.failedLane, failureReason: mr.failureReason, })), }; - if ( - orchBatchState.phase === "completed" && - (mode === "supervised" || mode === "auto") - ) { + if (orchBatchState.phase === "completed" && (mode === "supervised" || mode === "auto")) { triggerSupervisorIntegration( pi, supervisorState, @@ -2638,47 +2863,54 @@ export default function (pi: ExtensionAPI) { ); return; } - if ( - (mode === "supervised" || mode === "auto") && - orchBatchState.phase !== "completed" - ) { + if ((mode === "supervised" || mode === "auto") && orchBatchState.phase !== "completed") { pi.sendMessage( { customType: "supervisor-integration-skipped", - content: [{ - type: "text", - text: - `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` + - `Integration skipped — only completed batches are eligible.\n` + - `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`, - }], + content: [ + { + type: "text", + text: + `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` + + `Integration skipped — only completed batches are eligible.\n` + + `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`, + }, + ], display: `Integration skipped — batch ${orchBatchState.phase}`, }, { triggerTurn: false }, ); } - presentBatchSummary(pi, orchBatchState, execCtx!.workspaceRoot, opId, orchBatchState.diagnostics, sDeps.mergeResults); - const postBatchContext: SupervisorRoutingContext = orchBatchState.phase === "completed" - ? { - routingState: "completed-batch", - contextMessage: - `Batch **${orchBatchState.batchId}** completed — ` + - `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` + - `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` + - `Would you like me to integrate it, or would you prefer to review first?\n\n` + - `You can also:\n` + - `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` + - `• Create new tasks for the next batch\n` + - `• Run a health check`, - } - : { - routingState: "no-tasks", - contextMessage: - `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` + - `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` + - `${orchBatchState.skippedTasks} skipped.\n\n` + - `What would you like to do next?`, - }; + presentBatchSummary( + pi, + orchBatchState, + execCtx!.workspaceRoot, + opId, + orchBatchState.diagnostics, + sDeps.mergeResults, + ); + const postBatchContext: SupervisorRoutingContext = + orchBatchState.phase === "completed" + ? { + routingState: "completed-batch", + contextMessage: + `Batch **${orchBatchState.batchId}** completed — ` + + `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` + + `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` + + `Would you like me to integrate it, or would you prefer to review first?\n\n` + + `You can also:\n` + + `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` + + `• Create new tasks for the next batch\n` + + `• Run a health check`, + } + : { + routingState: "no-tasks", + contextMessage: + `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` + + `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` + + `${orchBatchState.skippedTasks} skipped.\n\n` + + `What would you like to do next?`, + }; transitionToRoutingMode(pi, supervisorState, postBatchContext); }, // ── TP-076: Supervisor alert handler — injects alerts as user messages ── @@ -2688,7 +2920,7 @@ export default function (pi: ExtensionAPI) { if (isAlertSuppressed(alert)) { process.stderr.write( `[taskplane:zombie-filter] dropped alert (category=${alert.category}, ` + - `lane=${alert.context?.laneNumber ?? "?"}, agent=${alert.context?.agentId ?? "?"})\n`, + `lane=${alert.context?.laneNumber ?? "?"}, agent=${alert.context?.agentId ?? "?"})\n`, ); return; } @@ -2699,7 +2931,7 @@ export default function (pi: ExtensionAPI) { if (!ipcBatchIdMatches(info.batchId)) { process.stderr.write( `[taskplane:zombie-filter] ignored stale lane-terminated IPC ` + - `(incoming batchId=${info.batchId}, current=${orchBatchState.batchId})\n`, + `(incoming batchId=${info.batchId}, current=${orchBatchState.batchId})\n`, ); return; } @@ -2707,7 +2939,7 @@ export default function (pi: ExtensionAPI) { if (info.agentId) terminatedAgents.set(info.agentId, info.terminatedAt); process.stderr.write( `[taskplane:zombie-filter] lane ${info.laneNumber} (${info.agentId}) terminated ` + - `(reason: ${info.reason}); ${terminatedLanes.size} lane(s) suppressed\n`, + `(reason: ${info.reason}); ${terminatedLanes.size} lane(s) suppressed\n`, ); }, // TP-187 (#538): Lane-respawned handler. @@ -2715,7 +2947,7 @@ export default function (pi: ExtensionAPI) { if (!ipcBatchIdMatches(incomingBatchId)) { process.stderr.write( `[taskplane:zombie-filter] ignored stale lane-respawned IPC ` + - `(incoming batchId=${incomingBatchId}, current=${orchBatchState.batchId})\n`, + `(incoming batchId=${incomingBatchId}, current=${orchBatchState.batchId})\n`, ); return; } @@ -2753,10 +2985,16 @@ export default function (pi: ExtensionAPI) { const abortSignalFile = join(stateRoot, ".pi", "orch-abort-signal"); try { mkdirSync(join(stateRoot, ".pi"), { recursive: true }); - writeFileSync(abortSignalFile, `abort requested at ${new Date().toISOString()} (mode: ${mode})`, "utf-8"); + writeFileSync( + abortSignalFile, + `abort requested at ${new Date().toISOString()} (mode: ${mode})`, + "utf-8", + ); messages.push(" ✓ Abort signal file written (.pi/orch-abort-signal)"); } catch (err) { - messages.push(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`); + messages.push( + ` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`, + ); } // Step 2: Set pause signal and forward to worker @@ -2776,7 +3014,8 @@ export default function (pi: ExtensionAPI) { } } - const hasActiveBatch = orchBatchState.phase !== "idle" && + const hasActiveBatch = + orchBatchState.phase !== "idle" && orchBatchState.phase !== "completed" && orchBatchState.phase !== "failed" && orchBatchState.phase !== "stopped"; @@ -2790,11 +3029,13 @@ export default function (pi: ExtensionAPI) { messages.push( ` Batch state: in-memory=${hasActiveBatch ? orchBatchState.phase : "none"}, ` + - `persisted=${persistedState ? persistedState.batchId : "none"}`, + `persisted=${persistedState ? persistedState.batchId : "none"}`, ); if (!hasActiveBatch && !persistedState) { - try { unlinkSync(abortSignalFile); } catch {} + try { + unlinkSync(abortSignalFile); + } catch {} return ORCH_MESSAGES.abortNoBatch(); } @@ -2820,15 +3061,32 @@ export default function (pi: ExtensionAPI) { updateOrchWidget(); messages.push(" ✓ In-memory batch state set to 'stopped'"); } catch (err) { - messages.push(` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`); + messages.push( + ` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`, + ); } - messages.push(` Found ${abortResult.sessionsFound} session target(s) matching prefix "${prefix}-"`); + messages.push( + ` Found ${abortResult.sessionsFound} session target(s) matching prefix "${prefix}-"`, + ); if (mode === "graceful") { const forceKilled = Math.max(0, abortResult.sessionsKilled - abortResult.gracefulExits); - messages.push(ORCH_MESSAGES.abortGracefulComplete(batchId, abortResult.gracefulExits, forceKilled, Math.round(abortResult.durationMs / 1000))); + messages.push( + ORCH_MESSAGES.abortGracefulComplete( + batchId, + abortResult.gracefulExits, + forceKilled, + Math.round(abortResult.durationMs / 1000), + ), + ); } else { - messages.push(ORCH_MESSAGES.abortHardComplete(batchId, abortResult.sessionsKilled, Math.round(abortResult.durationMs / 1000))); + messages.push( + ORCH_MESSAGES.abortHardComplete( + batchId, + abortResult.sessionsKilled, + Math.round(abortResult.durationMs / 1000), + ), + ); } if (!abortResult.stateDeleted) { @@ -2842,11 +3100,13 @@ export default function (pi: ExtensionAPI) { } // Step 7: Clean up abort signal file - try { unlinkSync(abortSignalFile); } catch {} + try { + unlinkSync(abortSignalFile); + } catch {} messages.push( `🏁 Abort (${mode}) complete for batch ${batchId}. ` + - `Worktrees and branches are preserved for inspection.`, + `Worktrees and branches are preserved for inspection.`, ); return messages.join("\n"); @@ -2894,15 +3154,15 @@ export default function (pi: ExtensionAPI) { drainedAgents++; drainedMessages += n; } - } catch { /* per-agent drain best-effort */ } + } catch { + /* per-agent drain best-effort */ + } } messages.push( ` ✓ Drained on-disk outboxes (${drainedMessages} message(s) across ${drainedAgents} agent(s))`, ); } catch (err) { - messages.push( - ` ⚠ Drain failed: ${err instanceof Error ? err.message : String(err)}`, - ); + messages.push(` ⚠ Drain failed: ${err instanceof Error ? err.message : String(err)}`); } } else { messages.push(" — No active batch state; outbox drain skipped"); @@ -2967,9 +3227,9 @@ export default function (pi: ExtensionAPI) { } // Find the task - const taskRecord = state.tasks.find(t => t.taskId === taskId); + const taskRecord = state.tasks.find((t) => t.taskId === taskId); if (!taskRecord) { - const knownIds = state.tasks.map(t => t.taskId).join(", "); + const knownIds = state.tasks.map((t) => t.taskId).join(", "); return `❌ Task "${taskId}" not found in batch ${state.batchId}.\nKnown tasks: ${knownIds || "(none)"}`; } @@ -3004,7 +3264,10 @@ export default function (pi: ExtensionAPI) { } } if (orchBatchState.dependencyGraph && orchBatchState.batchId === state.batchId) { - const newBlocked = computeTransitiveDependents(remainingFailures, orchBatchState.dependencyGraph); + const newBlocked = computeTransitiveDependents( + remainingFailures, + orchBatchState.dependencyGraph, + ); state.blockedTaskIds = [...newBlocked].sort(); state.blockedTasks = newBlocked.size; } else if (remainingFailures.size === 0) { @@ -3040,13 +3303,16 @@ export default function (pi: ExtensionAPI) { updateOrchWidget(); - const resumeHint = state.phase === "stopped" - ? "Use orch_resume(force=true) to re-execute the batch." - : "Use orch_resume() to re-execute the batch."; - return `✅ Task "${taskId}" reset to pending for re-execution.\n` + + const resumeHint = + state.phase === "stopped" + ? "Use orch_resume(force=true) to re-execute the batch." + : "Use orch_resume() to re-execute the batch."; + return ( + `✅ Task "${taskId}" reset to pending for re-execution.\n` + ` Previous status: ${prevStatus}\n` + ` Batch phase: ${state.phase} | Failed: ${state.failedTasks}/${state.totalTasks}\n` + - ` ${resumeHint}`; + ` ${resumeHint}` + ); } /** @@ -3077,14 +3343,18 @@ export default function (pi: ExtensionAPI) { } // Find the task - const taskRecord = state.tasks.find(t => t.taskId === taskId); + const taskRecord = state.tasks.find((t) => t.taskId === taskId); if (!taskRecord) { - const knownIds = state.tasks.map(t => t.taskId).join(", "); + const knownIds = state.tasks.map((t) => t.taskId).join(", "); return `❌ Task "${taskId}" not found in batch ${state.batchId}.\nKnown tasks: ${knownIds || "(none)"}`; } // Validate: only failed, stalled, or pending tasks can be skipped - if (taskRecord.status !== "failed" && taskRecord.status !== "stalled" && taskRecord.status !== "pending") { + if ( + taskRecord.status !== "failed" && + taskRecord.status !== "stalled" && + taskRecord.status !== "pending" + ) { return `❌ Cannot skip task "${taskId}" — current status is "${taskRecord.status}". Only failed, stalled, or pending tasks can be skipped.`; } @@ -3117,7 +3387,10 @@ export default function (pi: ExtensionAPI) { // Use in-memory dependency graph if available (batch IDs must match) if (orchBatchState.dependencyGraph && orchBatchState.batchId === state.batchId) { - const newBlocked = computeTransitiveDependents(remainingFailures, orchBatchState.dependencyGraph); + const newBlocked = computeTransitiveDependents( + remainingFailures, + orchBatchState.dependencyGraph, + ); // Find tasks that were blocked but are now unblocked for (const id of prevBlocked) { @@ -3194,7 +3467,11 @@ export default function (pi: ExtensionAPI) { * 4. Clears the failed merge entry and sets phase to "paused" * 5. `orch_resume()` re-runs the merge using real git merge logic */ - function doOrchForceMerge(waveIndex: number | undefined, skipFailed: boolean, ctx: ExtensionContext): string { + function doOrchForceMerge( + waveIndex: number | undefined, + skipFailed: boolean, + ctx: ExtensionContext, + ): string { // Reject while engine is actively running const activePhases = new Set(["launching", "executing", "merging", "planning"]); if (activePhases.has(orchBatchState.phase)) { @@ -3218,8 +3495,10 @@ export default function (pi: ExtensionAPI) { // Force-merge is a recovery action for non-running failed/paused batches. const resumablePhases = new Set(["paused", "stopped", "failed"]); if (!resumablePhases.has(state.phase)) { - return `❌ Cannot force merge when batch phase is "${state.phase}". ` + - `Force merge is only valid for paused/stopped/failed batches.`; + return ( + `❌ Cannot force merge when batch phase is "${state.phase}". ` + + `Force merge is only valid for paused/stopped/failed batches.` + ); } // Determine target wave index (0-based). Default to currentWaveIndex. @@ -3253,8 +3532,10 @@ export default function (pi: ExtensionAPI) { // Only allow force merge for mixed-outcome failures (partial status). // Other failures (conflicts, build failures, repo divergence) need different resolution. if (mergeEntry.status !== "partial") { - return `❌ Wave ${targetWave} merge failed with status "${mergeEntry.status}": ${mergeEntry.failureReason || "unknown reason"}.\n` + - `Force merge only applies to mixed-outcome lanes (partial). This failure needs manual resolution.`; + return ( + `❌ Wave ${targetWave} merge failed with status "${mergeEntry.status}": ${mergeEntry.failureReason || "unknown reason"}.\n` + + `Force merge only applies to mixed-outcome lanes (partial). This failure needs manual resolution.` + ); } const failureReason = mergeEntry.failureReason || ""; @@ -3264,9 +3545,11 @@ export default function (pi: ExtensionAPI) { failureReasonLower.includes("mixed-outcome") || failureReasonLower.includes("automatic partial-branch merge is disabled"); if (!isMixedOutcomePartial) { - return `❌ Wave ${targetWave} has partial merge status, but the failure reason does not match mixed-outcome lanes.\n` + + return ( + `❌ Wave ${targetWave} has partial merge status, but the failure reason does not match mixed-outcome lanes.\n` + `Reason: ${failureReason || "unknown"}\n` + - `Force merge is only valid for the mixed-outcome lane guard. Resolve this merge failure manually.`; + `Force merge is only valid for the mixed-outcome lane guard. Resolve this merge failure manually.` + ); } // Collect tasks in the target wave @@ -3275,7 +3558,7 @@ export default function (pi: ExtensionAPI) { const succeededInWave: string[] = []; for (const taskId of waveTasks) { - const task = state.tasks.find(t => t.taskId === taskId); + const task = state.tasks.find((t) => t.taskId === taskId); if (!task) continue; if (task.status === "failed" || task.status === "stalled") { failedInWave.push(taskId); @@ -3292,7 +3575,7 @@ export default function (pi: ExtensionAPI) { const skippedTasks: string[] = []; if (skipFailed && failedInWave.length > 0) { for (const taskId of failedInWave) { - const task = state.tasks.find(t => t.taskId === taskId); + const task = state.tasks.find((t) => t.taskId === taskId); if (!task) continue; const prevStatus = task.status; task.status = "skipped"; @@ -3310,13 +3593,16 @@ export default function (pi: ExtensionAPI) { // Recompute blocked tasks if dependency graph is available const remainingFailures = new Set(); for (const t of state.tasks) { - if ((t.status === "failed" || t.status === "stalled")) { + if (t.status === "failed" || t.status === "stalled") { remainingFailures.add(t.taskId); } } if (orchBatchState.dependencyGraph && orchBatchState.batchId === state.batchId) { - const newBlocked = computeTransitiveDependents(remainingFailures, orchBatchState.dependencyGraph); + const newBlocked = computeTransitiveDependents( + remainingFailures, + orchBatchState.dependencyGraph, + ); state.blockedTaskIds = [...newBlocked].sort(); state.blockedTasks = newBlocked.size; } else if (remainingFailures.size === 0) { @@ -3325,8 +3611,10 @@ export default function (pi: ExtensionAPI) { state.blockedTasks = 0; } } else if (!skipFailed && failedInWave.length > 0) { - return `❌ Wave ${targetWave} has ${failedInWave.length} failed task(s): ${failedInWave.join(", ")}.\n` + - `Use skipFailed=true to skip them, or use orch_skip_task to skip them individually first.`; + return ( + `❌ Wave ${targetWave} has ${failedInWave.length} failed task(s): ${failedInWave.join(", ")}.\n` + + `Use skipFailed=true to skip them, or use orch_skip_task to skip them individually first.` + ); } // Clear the failed merge result so resume will re-attempt the merge. @@ -3338,7 +3626,9 @@ export default function (pi: ExtensionAPI) { state.phase = "paused"; // Clear merge-related errors - state.errors = state.errors.filter(e => !e.includes("mixed") && !e.includes("merge") && !e.includes("Merge")); + state.errors = state.errors.filter( + (e) => !e.includes("mixed") && !e.includes("merge") && !e.includes("Merge"), + ); state.lastError = null; // Update timestamp @@ -3372,7 +3662,9 @@ export default function (pi: ExtensionAPI) { lines.push(` Skipped tasks (were failed): ${skippedTasks.join(", ")}`); } - lines.push(` Batch phase: paused | Failed: ${state.failedTasks}, Skipped: ${state.skippedTasks ?? 0} / ${state.totalTasks} total`); + lines.push( + ` Batch phase: paused | Failed: ${state.failedTasks}, Skipped: ${state.skippedTasks ?? 0} / ${state.totalTasks} total`, + ); const resumeHint = "Use orch_resume() to re-run the merge with failed tasks skipped."; lines.push(` ${resumeHint}`); @@ -3410,7 +3702,10 @@ export default function (pi: ExtensionAPI) { listOrchBranches: () => { const result = runGit(["branch", "--list", "orch/*"], repoRoot); return result.ok - ? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean) + ? result.stdout + .split("\n") + .map((b) => b.replace(/^\*?\s+/, "").trim()) + .filter(Boolean) : []; }, orchBranchExists: (branch: string) => { @@ -3423,7 +3718,8 @@ export default function (pi: ExtensionAPI) { return { message: resolution.error, error: severity !== "info" }; } - const { orchBranch, baseBranch, batchId, currentBranch, notices } = resolution as IntegrationContext; + const { orchBranch, baseBranch, batchId, currentBranch, notices } = + resolution as IntegrationContext; const outputLines: string[] = []; let hasWarning = false; @@ -3439,8 +3735,8 @@ export default function (pi: ExtensionAPI) { hasWarning = true; outputLines.push( `⚠️ Branch \`${baseBranch}\` has branch protection rules enabled.\n` + - `Direct merges may be blocked by your repository settings.\n\n` + - `Recommended: use \`/orch-integrate --pr\` to create a pull request instead.`, + `Direct merges may be blocked by your repository settings.\n\n` + + `Recommended: use \`/orch-integrate --pr\` to create a pull request instead.`, ); } } @@ -3452,24 +3748,21 @@ export default function (pi: ExtensionAPI) { ); const commitsAhead = revListResult.ok ? revListResult.stdout.trim() : "?"; - const diffStatResult = runGit( - ["diff", "--stat", `${currentBranch}...${orchBranch}`], - repoRoot, - ); + const diffStatResult = runGit(["diff", "--stat", `${currentBranch}...${orchBranch}`], repoRoot); const diffSummary = diffStatResult.ok ? diffStatResult.stdout.trim() : "(unable to compute diff)"; outputLines.push( `🔀 Integration Summary\n` + - `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + - ` Orch branch: ${orchBranch}\n` + - ` Target: ${currentBranch}\n` + - ` Commits: ${commitsAhead} ahead\n` + - ` Mode: ${parsed.mode === "ff" ? "fast-forward" : parsed.mode === "merge" ? "merge commit" : "pull request"}\n` + - (batchId ? ` Batch: ${batchId}\n` : "") + - (parsed.force ? ` ⚠ Force: branch safety check skipped\n` : "") + - `\n` + - (diffSummary ? `${diffSummary}\n` : "") + - `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + + ` Orch branch: ${orchBranch}\n` + + ` Target: ${currentBranch}\n` + + ` Commits: ${commitsAhead} ahead\n` + + ` Mode: ${parsed.mode === "ff" ? "fast-forward" : parsed.mode === "merge" ? "merge commit" : "pull request"}\n` + + (batchId ? ` Batch: ${batchId}\n` : "") + + (parsed.force ? ` ⚠ Force: branch safety check skipped\n` : "") + + `\n` + + (diffSummary ? `${diffSummary}\n` : "") + + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, ); // Execute integration @@ -3479,7 +3772,10 @@ export default function (pi: ExtensionAPI) { if (wsConfig) { for (const [repoId, repoConf] of wsConfig.repos) { - const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${resolvedOrchBranch}`], repoConf.path); + const branchCheck = runGit( + ["rev-parse", "--verify", `refs/heads/${resolvedOrchBranch}`], + repoConf.path, + ); if (branchCheck.ok) { reposToIntegrate.push({ id: repoId, root: repoConf.path }); } @@ -3493,7 +3789,10 @@ export default function (pi: ExtensionAPI) { const repoMessages: string[] = []; for (const repo of reposToIntegrate) { - const preCountResult = runGit(["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], repo.root); + const preCountResult = runGit( + ["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], + repo.root, + ); const repoCommitsBefore = preCountResult.ok ? parseInt(preCountResult.stdout) || 0 : 0; const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, { @@ -3516,11 +3815,16 @@ export default function (pi: ExtensionAPI) { }; } }, - deleteBatchState: () => { /* handled once after all repos */ }, + deleteBatchState: () => { + /* handled once after all repos */ + }, }); if (!integrationResult.success) { - return { ok: false as const, error: `❌ Integration failed in ${repo.id}:\n${integrationResult.error}` }; + return { + ok: false as const, + error: `❌ Integration failed in ${repo.id}:\n${integrationResult.error}`, + }; } totalCommits += repoCommitsBefore; @@ -3554,17 +3858,24 @@ export default function (pi: ExtensionAPI) { const branchCleanupLines: string[] = []; for (const repo of allRepos) { const branchCleanup = deleteStaleBranches(repo.root, opId, batchId); - const totalDeleted = branchCleanup.deletedTaskBranches.length + branchCleanup.deletedSavedBranches.length; + const totalDeleted = + branchCleanup.deletedTaskBranches.length + branchCleanup.deletedSavedBranches.length; if (totalDeleted > 0 || branchCleanup.failedDeletes.length > 0) { const label = repo.id === "(default)" ? "" : ` (${repo.id})`; if (branchCleanup.deletedTaskBranches.length > 0) { - branchCleanupLines.push(` 🗑️ Deleted ${branchCleanup.deletedTaskBranches.length} task branch(es)${label}`); + branchCleanupLines.push( + ` 🗑️ Deleted ${branchCleanup.deletedTaskBranches.length} task branch(es)${label}`, + ); } if (branchCleanup.deletedSavedBranches.length > 0) { - branchCleanupLines.push(` 🗑️ Deleted ${branchCleanup.deletedSavedBranches.length} saved branch(es)${label}`); + branchCleanupLines.push( + ` 🗑️ Deleted ${branchCleanup.deletedSavedBranches.length} saved branch(es)${label}`, + ); } if (branchCleanup.failedDeletes.length > 0) { - branchCleanupLines.push(` ⚠️ Failed to delete ${branchCleanup.failedDeletes.length} branch(es)${label}: ${branchCleanup.failedDeletes.join(", ")}`); + branchCleanupLines.push( + ` ⚠️ Failed to delete ${branchCleanup.failedDeletes.length} branch(es)${label}: ${branchCleanup.failedDeletes.join(", ")}`, + ); } } } @@ -3576,8 +3887,13 @@ export default function (pi: ExtensionAPI) { const repoFindings: IntegrateCleanupRepoFindings[] = []; for (const repo of allRepos) { const findings = collectRepoCleanupFindings( - repo.root, repo.id === "(default)" ? undefined : repo.id, - opId, batchId, orchPrefix, resolvedOrchBranch, orchConfig, + repo.root, + repo.id === "(default)" ? undefined : repo.id, + opId, + batchId, + orchPrefix, + resolvedOrchBranch, + orchConfig, { skipOrchBranch }, ); repoFindings.push(findings); @@ -3590,10 +3906,18 @@ export default function (pi: ExtensionAPI) { // TP-179: Write integratedAt to batch history before deleting state if (batchId) { - try { updateBatchHistoryIntegration(stateRoot, batchId, Date.now()); } catch { /* best effort */ } + try { + updateBatchHistoryIntegration(stateRoot, batchId, Date.now()); + } catch { + /* best effort */ + } } - try { deleteBatchState(stateRoot); } catch { /* best effort */ } + try { + deleteBatchState(stateRoot); + } catch { + /* best effort */ + } // ── TP-065: Post-integrate artifact cleanup (Layer 1) ──── // Delete batch-specific telemetry and merge result files. @@ -3601,7 +3925,11 @@ export default function (pi: ExtensionAPI) { if (batchId) { try { const artifactCleanup = cleanupPostIntegrate(stateRoot, batchId); - const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted + artifactCleanup.mailboxDirsDeleted; + const totalCleaned = + artifactCleanup.telemetryFilesDeleted + + artifactCleanup.mergeFilesDeleted + + artifactCleanup.promptFilesDeleted + + artifactCleanup.mailboxDirsDeleted; if (totalCleaned > 0) { const cleanupParts = [ `${artifactCleanup.telemetryFilesDeleted} telemetry file(s)`, @@ -3611,9 +3939,7 @@ export default function (pi: ExtensionAPI) { if (artifactCleanup.mailboxDirsDeleted > 0) { cleanupParts.push(`${artifactCleanup.mailboxDirsDeleted} mailbox dir(s)`); } - outputLines.push( - `🧹 Cleaned up ${cleanupParts.join(", ")} for batch ${batchId}`, - ); + outputLines.push(`🧹 Cleaned up ${cleanupParts.join(", ")} for batch ${batchId}`); } if (artifactCleanup.warnings.length > 0) { hasWarning = true; @@ -3635,7 +3961,14 @@ export default function (pi: ExtensionAPI) { const deps = supervisorState.pendingSummaryDeps; supervisorState.pendingSummaryDeps = null; if (supervisorState.batchStateRef && supervisorState.stateRoot) { - presentBatchSummary(pi, supervisorState.batchStateRef, supervisorState.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults); + presentBatchSummary( + pi, + supervisorState.batchStateRef, + supervisorState.stateRoot, + deps.opId, + deps.diagnostics, + deps.mergeResults, + ); } deactivateSupervisor(pi, supervisorState); } @@ -3656,7 +3989,8 @@ export default function (pi: ExtensionAPI) { handler: async (_args, ctx) => { const result = doOrchPause(); // Determine notification level from result content - const level = result.includes("No batch") || result.includes("already paused") ? "warning" : "info"; + const level = + result.includes("No batch") || result.includes("already paused") ? "warning" : "info"; ctx.ui.notify(result, level); }, }); @@ -3689,7 +4023,7 @@ export default function (pi: ExtensionAPI) { // Top-level catch: ensure the user ALWAYS sees something ctx.ui.notify( `❌ Abort failed with error: ${err instanceof Error ? err.message : String(err)}\n` + - ` Stack: ${err instanceof Error ? err.stack : "N/A"}`, + ` Stack: ${err instanceof Error ? err.stack : "N/A"}`, "error", ); } @@ -3702,15 +4036,15 @@ export default function (pi: ExtensionAPI) { if (!args?.trim()) { ctx.ui.notify( "Usage: /orch-deps [--refresh] [--task ]\n\n" + - "Shows the dependency graph for tasks in the specified areas.\n\n" + - "Options:\n" + - " --refresh Force re-scan of areas (bypass dependency cache)\n" + - " --task Show dependencies for a single task only\n\n" + - "Examples:\n" + - " /orch-deps all\n" + - " /orch-deps all --task TO-014\n" + - " /orch-deps time-off --refresh\n" + - " /orch-deps all --task COMP-006 --refresh", + "Shows the dependency graph for tasks in the specified areas.\n\n" + + "Options:\n" + + " --refresh Force re-scan of areas (bypass dependency cache)\n" + + " --task Show dependencies for a single task only\n\n" + + "Examples:\n" + + " /orch-deps all\n" + + " /orch-deps all --task TO-014\n" + + " /orch-deps time-off --refresh\n" + + " /orch-deps all --task COMP-006 --refresh", "info", ); return; @@ -3737,7 +4071,7 @@ export default function (pi: ExtensionAPI) { if (!cleanArgs) { ctx.ui.notify( "Usage: /orch-deps [--refresh] [--task ]\n" + - "Error: target argument required (e.g., 'all', area name, or path)", + "Error: target argument required (e.g., 'all', area name, or path)", "error", ); return; @@ -3763,11 +4097,7 @@ export default function (pi: ExtensionAPI) { // Show dependency graph (full or filtered) if (discovery.pending.size > 0) { ctx.ui.notify( - formatDependencyGraph( - discovery.pending, - discovery.completed, - filterTaskId, - ), + formatDependencyGraph(discovery.pending, discovery.completed, filterTaskId), "info", ); } @@ -3793,8 +4123,8 @@ export default function (pi: ExtensionAPI) { if (supervisorState.active) { ctx.ui.notify( "✅ This session is already the active supervisor.\n\n" + - ` Session: ${supervisorState.lockSessionId}\n` + - ` Batch: ${supervisorState.batchId || orchBatchState.batchId}`, + ` Session: ${supervisorState.lockSessionId}\n` + + ` Batch: ${supervisorState.batchId || orchBatchState.batchId}`, "info", ); return; @@ -3805,10 +4135,7 @@ export default function (pi: ExtensionAPI) { switch (lockResult.status) { case "no-active-batch": - ctx.ui.notify( - "No active batch to supervise.\n\nStart a batch with /orch first.", - "info", - ); + ctx.ui.notify("No active batch to supervise.\n\nStart a batch with /orch first.", "info"); return; case "no-lockfile": @@ -3819,17 +4146,14 @@ export default function (pi: ExtensionAPI) { const summary = buildTakeoverSummary(stateRoot, batchState); const reason = lockResult.status === "stale" - ? (isProcessAlive(lockResult.lock.pid) + ? isProcessAlive(lockResult.lock.pid) ? `Previous supervisor (PID ${lockResult.lock.pid}) has a stale heartbeat (last: ${lockResult.lock.heartbeat}).` - : `Previous supervisor (PID ${lockResult.lock.pid}) process is dead.`) + : `Previous supervisor (PID ${lockResult.lock.pid}) process is dead.` : lockResult.status === "corrupt" ? "Found a corrupt supervisor lockfile." : "No supervisor lockfile found."; - ctx.ui.notify( - `🔄 **${reason}** Activating supervisor.\n\n` + summary, - "info", - ); + ctx.ui.notify(`🔄 **${reason}** Activating supervisor.\n\n` + summary, "info"); // Populate orchBatchState from persisted state orchBatchState.batchId = batchState.batchId; @@ -3870,10 +4194,10 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify( `⚡ **Forcing supervisor takeover from PID ${lock.pid}.**\n\n` + - ` Previous session: ${lock.sessionId}\n` + - ` Previous heartbeat: ${lock.heartbeat}\n\n` + - `The other session will yield on its next heartbeat check.\n\n` + - summary, + ` Previous session: ${lock.sessionId}\n` + + ` Previous heartbeat: ${lock.heartbeat}\n\n` + + `The other session will yield on its next heartbeat check.\n\n` + + summary, "warning", ); @@ -3919,19 +4243,19 @@ export default function (pi: ExtensionAPI) { if (args?.trim() === "--help" || args?.trim() === "-h") { ctx.ui.notify( "Usage: /orch-integrate [] [--merge] [--pr] [--force]\n\n" + - "Integrate a completed orch batch into your working branch.\n\n" + - "Modes:\n" + - " (default) Fast-forward merge (cleanest history)\n" + - " --merge Create a real merge commit\n" + - " --pr Push orch branch and create a pull request\n\n" + - "Options:\n" + - " --force Skip branch safety check\n" + - " Orch branch name (auto-detected from batch state if omitted)\n\n" + - "Examples:\n" + - " /orch-integrate Auto-detect and fast-forward\n" + - " /orch-integrate --merge Auto-detect with merge commit\n" + - " /orch-integrate orch/op-abc123 --pr Specific branch, create PR\n" + - " /orch-integrate --force Skip branch safety check", + "Integrate a completed orch batch into your working branch.\n\n" + + "Modes:\n" + + " (default) Fast-forward merge (cleanest history)\n" + + " --merge Create a real merge commit\n" + + " --pr Push orch branch and create a pull request\n\n" + + "Options:\n" + + " --force Skip branch safety check\n" + + " Orch branch name (auto-detected from batch state if omitted)\n\n" + + "Examples:\n" + + " /orch-integrate Auto-detect and fast-forward\n" + + " /orch-integrate --merge Auto-detect with merge commit\n" + + " /orch-integrate orch/op-abc123 --pr Specific branch, create PR\n" + + " /orch-integrate --force Skip branch safety check", "info", ); return; @@ -3965,7 +4289,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error checking status: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error checking status: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -3992,7 +4321,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error pausing batch: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error pausing batch: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4014,9 +4348,11 @@ export default function (pi: ExtensionAPI) { "The resume happens asynchronously — the tool returns immediately with a status message.", ], parameters: Type.Object({ - force: Type.Optional(Type.Boolean({ - description: "Resume from stopped or failed state (default: false)", - })), + force: Type.Optional( + Type.Boolean({ + description: "Resume from stopped or failed state (default: false)", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4024,7 +4360,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result.message }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error resuming batch: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error resuming batch: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4047,9 +4388,11 @@ export default function (pi: ExtensionAPI) { "Worktrees and branches are preserved for inspection after abort.", ], parameters: Type.Object({ - hard: Type.Optional(Type.Boolean({ - description: "Hard abort — immediate kill without grace period (default: false)", - })), + hard: Type.Optional( + Type.Boolean({ + description: "Hard abort — immediate kill without grace period (default: false)", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4057,7 +4400,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error aborting batch: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error aborting batch: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4100,7 +4448,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error during supervisor takeover: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error during supervisor takeover: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4124,16 +4477,21 @@ export default function (pi: ExtensionAPI) { "If the target branch has protection rules, prefer mode='pr'.", ], parameters: Type.Object({ - mode: Type.Optional(Type.Union( - [Type.Literal("fast-forward"), Type.Literal("merge"), Type.Literal("pr")], - { description: 'Integration mode (default: "fast-forward")' }, - )), - force: Type.Optional(Type.Boolean({ - description: "Skip branch safety check (default: false)", - })), - branch: Type.Optional(Type.String({ - description: "Orch branch name (auto-detected from batch state if omitted)", - })), + mode: Type.Optional( + Type.Union([Type.Literal("fast-forward"), Type.Literal("merge"), Type.Literal("pr")], { + description: 'Integration mode (default: "fast-forward")', + }), + ), + force: Type.Optional( + Type.Boolean({ + description: "Skip branch safety check (default: false)", + }), + ), + branch: Type.Optional( + Type.String({ + description: "Orch branch name (auto-detected from batch state if omitted)", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4150,7 +4508,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result.message }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error integrating batch: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error integrating batch: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4161,7 +4524,7 @@ export default function (pi: ExtensionAPI) { name: "orch_start", label: "Start Batch", description: - "Start a new orchestration batch. Target is \"all\" to run all pending tasks, " + + 'Start a new orchestration batch. Target is "all" to run all pending tasks, ' + "a task area name, a directory path, or one or more PROMPT.md paths. " + "The batch runs asynchronously — use orch_status() to monitor progress.", promptSnippet: "orch_start(target) — start a new batch", @@ -4169,15 +4532,16 @@ export default function (pi: ExtensionAPI) { "Call orch_start to begin executing pending tasks as a batch.", 'Use target="all" to run all pending tasks.', "Specify a task area name to run all pending tasks in that area.", - "Specify a PROMPT.md path to run a single task: target=\"taskplane-tasks/TP-101/PROMPT.md\"", - "Specify multiple space-separated PROMPT.md paths to run specific tasks: target=\"path/TP-001/PROMPT.md path/TP-002/PROMPT.md\"", + 'Specify a PROMPT.md path to run a single task: target="taskplane-tasks/TP-101/PROMPT.md"', + 'Specify multiple space-separated PROMPT.md paths to run specific tasks: target="path/TP-001/PROMPT.md path/TP-002/PROMPT.md"', "Cannot start if a batch is already running — check orch_status() first.", "The batch runs asynchronously. The tool returns immediately with an ACK.", "After starting, use orch_status() to track progress.", ], parameters: Type.Object({ target: Type.String({ - description: 'Target to run: "all" for all pending tasks, a task area name, a directory path, or one or more PROMPT.md paths (space-separated)', + description: + 'Target to run: "all" for all pending tasks, a task area name, a directory path, or one or more PROMPT.md paths (space-separated)', }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -4186,7 +4550,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result.message }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error starting batch: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error starting batch: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4219,7 +4588,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error retrying task: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error retrying task: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4252,7 +4626,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error skipping task: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error skipping task: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4268,7 +4647,8 @@ export default function (pi: ExtensionAPI) { "Force merge a wave that was rejected due to mixed-outcome lanes (succeeded and failed tasks " + "on the same lane). Updates the merge result to 'succeeded' so the batch can continue. " + "Optionally skips failed tasks in the wave.", - promptSnippet: "orch_force_merge(waveIndex?, skipFailed?) — force merge a wave with mixed results", + promptSnippet: + "orch_force_merge(waveIndex?, skipFailed?) — force merge a wave with mixed results", promptGuidelines: [ "Call orch_force_merge when a wave merge was rejected because lanes had both succeeded and failed tasks.", "The batch must be paused, stopped, or failed with a 'partial' merge result for the target wave.", @@ -4278,12 +4658,17 @@ export default function (pi: ExtensionAPI) { "waveIndex is 0-based. Omit it to target the current wave.", ], parameters: Type.Object({ - waveIndex: Type.Optional(Type.Number({ - description: "0-based wave index to force merge. Defaults to the current wave.", - })), - skipFailed: Type.Optional(Type.Boolean({ - description: "If true, automatically skip all failed tasks in the wave before merging. Defaults to false.", - })), + waveIndex: Type.Optional( + Type.Number({ + description: "0-based wave index to force merge. Defaults to the current wave.", + }), + ), + skipFailed: Type.Optional( + Type.Boolean({ + description: + "If true, automatically skip all failed tasks in the wave before merging. Defaults to false.", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4291,7 +4676,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error force merging: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error force merging: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4306,7 +4696,8 @@ export default function (pi: ExtensionAPI) { description: "Send a steering message to a running agent (worker, reviewer, or merger). " + "The message is delivered into the agent's LLM context at the next turn boundary.", - promptSnippet: "send_agent_message(to, content, type?) — send steering message to a running agent", + promptSnippet: + "send_agent_message(to, content, type?) — send steering message to a running agent", promptGuidelines: [ "Call send_agent_message to course-correct a running agent (worker, reviewer, or merger).", "The 'to' parameter must be a valid agent session name from the current batch.", @@ -4321,10 +4712,12 @@ export default function (pi: ExtensionAPI) { content: Type.String({ description: "Message content (max 4KB). Concise directive for the agent.", }), - type: Type.Optional(Type.Union( - [Type.Literal("steer"), Type.Literal("query"), Type.Literal("abort"), Type.Literal("info")], - { description: 'Message type (default: "steer")' }, - )), + type: Type.Optional( + Type.Union( + [Type.Literal("steer"), Type.Literal("query"), Type.Literal("abort"), Type.Literal("info")], + { description: 'Message type (default: "steer")' }, + ), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4332,7 +4725,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error sending message: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error sending message: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4346,7 +4744,8 @@ export default function (pi: ExtensionAPI) { const registry = readRegistrySnapshot(stateRoot, state.batchId); if (registry) { for (const manifest of Object.values(registry.agents)) { - if (manifest.role !== "worker" && manifest.role !== "reviewer" && manifest.role !== "merger") continue; + if (manifest.role !== "worker" && manifest.role !== "reviewer" && manifest.role !== "merger") + continue; if (isTerminalStatus(manifest.status) || !registryIsProcessAlive(manifest.pid)) continue; ids.add(manifest.agentId); } @@ -4375,7 +4774,12 @@ export default function (pi: ExtensionAPI) { * * @since TP-089 */ - function doSendAgentMessage(to: string, content: string, messageType: string, ctx: ExtensionContext): string { + function doSendAgentMessage( + to: string, + content: string, + messageType: string, + ctx: ExtensionContext, + ): string { const stateRoot = resolveToolStateRoot(ctx); // Validate message type (outbound allowlist: steer, query, abort, info) @@ -4455,11 +4859,13 @@ export default function (pi: ExtensionAPI) { contentPreview: content.slice(0, 200), broadcast: false, }); - return `✅ Message sent to \`${to}\` (batch ${state.batchId})\n` + + return ( + `✅ Message sent to \`${to}\` (batch ${state.batchId})\n` + `- **ID:** ${msg.id}\n` + `- **Type:** ${messageType}\n` + `- **Size:** ${Buffer.byteLength(content, "utf8")} bytes\n` + - `Message will be delivered at the agent's next turn boundary.`; + `Message will be delivered at the agent's next turn boundary.` + ); } catch (err) { return `❌ Failed to write message: ${err instanceof Error ? err.message : String(err)}`; } @@ -4474,7 +4880,8 @@ export default function (pi: ExtensionAPI) { "Read reply and escalation messages from agents (non-consuming). " + "Returns pending and already-acked outbox messages from a specific agent or all agents. " + "Messages are never removed by reading — this is a durable history view.", - promptSnippet: "read_agent_replies(from?) \u2014 read replies/escalations from agents (read-only, non-consuming)", + promptSnippet: + "read_agent_replies(from?) \u2014 read replies/escalations from agents (read-only, non-consuming)", promptGuidelines: [ "Call read_agent_replies to check if any agent has sent a reply or escalation.", "Omit 'from' to read replies from all agents.", @@ -4482,9 +4889,11 @@ export default function (pi: ExtensionAPI) { "This is non-consuming: replies remain visible after reading (pending + acked history).", ], parameters: Type.Object({ - from: Type.Optional(Type.String({ - description: "Agent ID to read replies from (omit for all agents)", - })), + from: Type.Optional( + Type.String({ + description: "Agent ID to read replies from (omit for all agents)", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4492,7 +4901,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error reading replies: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error reading replies: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4515,13 +4929,19 @@ export default function (pi: ExtensionAPI) { // so replies from agents no longer active are still visible. const agentIds = from ? [from] - : [...new Set([ - ...collectKnownAgentIds(stateRoot, state), - ...discoverMailboxAgentIds(stateRoot, state.batchId), - ])]; + : [ + ...new Set([ + ...collectKnownAgentIds(stateRoot, state), + ...discoverMailboxAgentIds(stateRoot, state.batchId), + ]), + ]; // TP-091: read full outbox history (pending + processed) for durable visibility - const allEntries: Array<{ agentId: string; message: import("./types.ts").MailboxMessage; acked: boolean }> = []; + const allEntries: Array<{ + agentId: string; + message: import("./types.ts").MailboxMessage; + acked: boolean; + }> = []; for (const agentId of agentIds) { const history = readOutboxHistory(stateRoot, state.batchId, agentId); for (const entry of history) { @@ -4569,10 +4989,11 @@ export default function (pi: ExtensionAPI) { content: Type.String({ description: "Message content (max 4KB)", }), - type: Type.Optional(Type.Union( - [Type.Literal("steer"), Type.Literal("info"), Type.Literal("abort")], - { description: 'Message type (default: "info")' }, - )), + type: Type.Optional( + Type.Union([Type.Literal("steer"), Type.Literal("info"), Type.Literal("abort")], { + description: 'Message type (default: "info")', + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4580,7 +5001,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error broadcasting: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error broadcasting: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4618,7 +5044,10 @@ export default function (pi: ExtensionAPI) { retryAfterMs: b.check.retryAfterMs, }); } - const preview = blocked.slice(0, 5).map(b => `${b.agentId} (${Math.ceil((b.check.retryAfterMs ?? 0) / 1000)}s)`).join(", "); + const preview = blocked + .slice(0, 5) + .map((b) => `${b.agentId} (${Math.ceil((b.check.retryAfterMs ?? 0) / 1000)}s)`) + .join(", "); return `⏳ Broadcast rate limited for ${blocked.length}/${recipients.length} agent(s): ${preview}${blocked.length > 5 ? " ..." : ""}`; } @@ -4640,12 +5069,14 @@ export default function (pi: ExtensionAPI) { contentPreview: content.slice(0, 200), broadcast: true, }); - return `✅ Broadcast sent (batch ${state.batchId})\n` + + return ( + `✅ Broadcast sent (batch ${state.batchId})\n` + `- **ID:** ${msg.id}\n` + `- **Type:** ${messageType}\n` + `- **Recipients:** ${recipients.length}\n` + `- **Size:** ${Buffer.byteLength(content, "utf8")} bytes\n` + - `Message will be delivered to all agents at their next turn boundary.`; + `Message will be delivered to all agents at their next turn boundary.` + ); } catch (err) { return `❌ Failed to broadcast: ${err instanceof Error ? err.message : String(err)}`; } @@ -4655,7 +5086,10 @@ export default function (pi: ExtensionAPI) { return execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? context.cwd; } - function resolveLaneRepoRootForTools(laneRec: PersistedBatchState["lanes"][number], stateRoot: string): string { + function resolveLaneRepoRootForTools( + laneRec: PersistedBatchState["lanes"][number], + stateRoot: string, + ): string { if (execCtx?.workspaceConfig && laneRec.repoId) { const repo = execCtx.workspaceConfig.repos[laneRec.repoId]; if (repo?.path) return repo.path; @@ -4672,16 +5106,19 @@ export default function (pi: ExtensionAPI) { "Read STATUS.md and telemetry for a running agent's lane. " + "Returns current step, checkbox progress, context %, cost, tool count, and elapsed time. " + "If lane is omitted, returns status for all active lanes.", - promptSnippet: "read_agent_status(lane?) — read STATUS.md + context % + cost from a running agent", + promptSnippet: + "read_agent_status(lane?) — read STATUS.md + context % + cost from a running agent", promptGuidelines: [ "Call read_agent_status to check on a specific lane's worker progress.", "Omit lane to get a summary of all active lanes.", "Returns: current step, checked/total items, context %, cost, elapsed.", ], parameters: Type.Object({ - lane: Type.Optional(Type.Number({ - description: "Lane number to check (omit for all lanes)", - })), + lane: Type.Optional( + Type.Number({ + description: "Lane number to check (omit for all lanes)", + }), + ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { try { @@ -4689,7 +5126,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error reading agent status: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error reading agent status: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4707,9 +5149,7 @@ export default function (pi: ExtensionAPI) { const state = loadBatchState(stateRoot); if (!state) return "❌ No batch state found."; - const targetLanes = lane != null - ? state.lanes.filter(l => l.laneNumber === lane) - : state.lanes; + const targetLanes = lane != null ? state.lanes.filter((l) => l.laneNumber === lane) : state.lanes; if (targetLanes.length === 0) { return lane != null @@ -4722,8 +5162,8 @@ export default function (pi: ExtensionAPI) { for (const laneRec of targetLanes) { // Find current task for this lane - const laneTasks = state.tasks.filter(t => t.laneNumber === laneRec.laneNumber); - const runningTask = laneTasks.find(t => t.status === "running"); + const laneTasks = state.tasks.filter((t) => t.laneNumber === laneRec.laneNumber); + const runningTask = laneTasks.find((t) => t.status === "running"); const currentTask = runningTask || laneTasks[laneTasks.length - 1]; lines.push(`### Lane ${laneRec.laneNumber} — ${laneRec.laneSessionId}`); @@ -4731,11 +5171,17 @@ export default function (pi: ExtensionAPI) { if (currentTask) { lines.push(`**Task:** ${currentTask.taskId} (${currentTask.status})`); - const segmentLabel = buildTaskSegmentProgressLabel(currentTask, state.segments || [], currentTask.activeSegmentId ?? null); + const segmentLabel = buildTaskSegmentProgressLabel( + currentTask, + state.segments || [], + currentTask.activeSegmentId ?? null, + ); if (segmentLabel) lines.push(`**Segment:** ${segmentLabel}`); if (currentTask.activeSegmentId) lines.push(`**Segment ID:** ${currentTask.activeSegmentId}`); - const packetHomeRepo = typeof currentTask.packetRepoId === "string" ? currentTask.packetRepoId : ""; - const effectiveTaskRepo = currentTask.resolvedRepoId || currentTask.repoId || laneRec.repoId || ""; + const packetHomeRepo = + typeof currentTask.packetRepoId === "string" ? currentTask.packetRepoId : ""; + const effectiveTaskRepo = + currentTask.resolvedRepoId || currentTask.repoId || laneRec.repoId || ""; if (packetHomeRepo && packetHomeRepo !== effectiveTaskRepo) { lines.push(`**Packet Home Repo:** ${packetHomeRepo}`); } @@ -4764,9 +5210,11 @@ export default function (pi: ExtensionAPI) { if (stepMatch) lines.push(`**Step:** ${stepMatch[1].trim()}`); if (statusMatch) lines.push(`**Step Status:** ${statusMatch[1].trim()}`); - if (total > 0) lines.push(`**Progress:** ${checked}/${total} (${Math.round((checked / total) * 100)}%)`); + if (total > 0) + lines.push(`**Progress:** ${checked}/${total} (${Math.round((checked / total) * 100)}%)`); if (iterMatch) lines.push(`**Iteration:** ${iterMatch[1]}`); - if (reviewMatch && Number.parseInt(reviewMatch[1], 10) > 0) lines.push(`**Reviews:** ${reviewMatch[1]}`); + if (reviewMatch && Number.parseInt(reviewMatch[1], 10) > 0) + lines.push(`**Reviews:** ${reviewMatch[1]}`); } } } catch { @@ -4787,7 +5235,8 @@ export default function (pi: ExtensionAPI) { if (ls.workerToolCount) parts.push(`tools: ${ls.workerToolCount}`); if (ls.workerElapsed) parts.push(`elapsed: ${Math.round(ls.workerElapsed / 1000)}s`); if (ls.workerStatus) parts.push(`worker: ${ls.workerStatus}`); - if (ls.reviewerStatus && ls.reviewerStatus !== "idle") parts.push(`reviewer: ${ls.reviewerStatus}`); + if (ls.reviewerStatus && ls.reviewerStatus !== "idle") + parts.push(`reviewer: ${ls.reviewerStatus}`); if (parts.length > 0) lines.push(`**Telemetry:** ${parts.join(" · ")}`); } } catch { @@ -4822,7 +5271,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error triggering wrap-up: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error triggering wrap-up: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4839,11 +5293,11 @@ export default function (pi: ExtensionAPI) { const state = loadBatchState(stateRoot); if (!state) return "❌ No batch state found."; - const laneRec = state.lanes.find(l => l.laneNumber === lane); + const laneRec = state.lanes.find((l) => l.laneNumber === lane); if (!laneRec) return `❌ Lane ${lane} not found in batch ${state.batchId}.`; // Find running task for this lane - const runningTask = state.tasks.find(t => t.laneNumber === lane && t.status === "running"); + const runningTask = state.tasks.find((t) => t.laneNumber === lane && t.status === "running"); if (!runningTask) return `❌ No running task on lane ${lane}.`; // Resolve task folder in the worktree using canonical path resolver @@ -4866,9 +5320,11 @@ export default function (pi: ExtensionAPI) { const dir = dirname(wrapUpPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(wrapUpPath, `wrap-up signal for ${runningTask.taskId}\n`, "utf-8"); - return `✅ Wrap-up signal written for **${runningTask.taskId}** on lane ${lane}.\n` + + return ( + `✅ Wrap-up signal written for **${runningTask.taskId}** on lane ${lane}.\n` + `Path: \`${wrapUpPath}\`\n` + - `The worker will finish its current step and exit gracefully.`; + `The worker will finish its current step and exit gracefully.` + ); } catch (err) { return `❌ Failed to write wrap-up file: ${err instanceof Error ? err.message : String(err)}`; } @@ -4877,8 +5333,7 @@ export default function (pi: ExtensionAPI) { pi.registerTool({ name: "read_lane_logs", label: "Read Lane Logs", - description: - "Read stderr/crash logs for a specific lane from .pi/telemetry/ directory.", + description: "Read stderr/crash logs for a specific lane from .pi/telemetry/ directory.", promptSnippet: "read_lane_logs(lane) — read stderr/crash logs for a lane", promptGuidelines: [ "Call read_lane_logs to read crash/error logs from a lane's stderr capture.", @@ -4895,7 +5350,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error reading lane logs: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error reading lane logs: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -4912,7 +5372,7 @@ export default function (pi: ExtensionAPI) { const state = loadBatchState(stateRoot); if (!state) return "❌ No batch state found."; - const laneRec = state.lanes.find(l => l.laneNumber === lane); + const laneRec = state.lanes.find((l) => l.laneNumber === lane); if (!laneRec) return `❌ Lane ${lane} not found in batch ${state.batchId}.`; const telemetryDir = join(stateRoot, ".pi", "telemetry"); @@ -4923,14 +5383,16 @@ export default function (pi: ExtensionAPI) { try { if (existsSync(telemetryDir)) { const allStderr = readdirSync(telemetryDir) - .filter(f => f.endsWith("-stderr.log")) - .filter(f => f.includes(`-lane-${lane}-worker`)); - const batchScoped = allStderr.filter(f => f.includes(`-${state.batchId}-`)); + .filter((f) => f.endsWith("-stderr.log")) + .filter((f) => f.includes(`-lane-${lane}-worker`)); + const batchScoped = allStderr.filter((f) => f.includes(`-${state.batchId}-`)); const candidates = (batchScoped.length > 0 ? batchScoped : allStderr) - .map(name => { + .map((name) => { const absPath = join(telemetryDir, name); let mtime = 0; - try { mtime = statSync(absPath).mtimeMs; } catch {} + try { + mtime = statSync(absPath).mtimeMs; + } catch {} return { name, mtime }; }) .sort((a, b) => b.mtime - a.mtime); @@ -4955,12 +5417,14 @@ export default function (pi: ExtensionAPI) { try { if (existsSync(telemetryDir)) { const files = readdirSync(telemetryDir) - .filter(f => f.endsWith("-worker-exit.json")) - .filter(f => f.includes(`-lane-${lane}-`)); - const batchScoped = files.filter(f => f.includes(`-${state.batchId}-`)); + .filter((f) => f.endsWith("-worker-exit.json")) + .filter((f) => f.includes(`-lane-${lane}-`)); + const batchScoped = files.filter((f) => f.includes(`-${state.batchId}-`)); exitFiles.push(...(batchScoped.length > 0 ? batchScoped : files)); } - } catch { /* directory not readable */ } + } catch { + /* directory not readable */ + } const lines: string[] = []; lines.push(`📜 **Lane ${lane} Logs** — batch ${state.batchId}\n`); @@ -4969,9 +5433,7 @@ export default function (pi: ExtensionAPI) { if (stderrPath && existsSync(stderrPath)) { try { const content = readFileSync(stderrPath, "utf-8"); - const truncated = content.length > 5000 - ? "...\n" + content.slice(-5000) - : content; + const truncated = content.length > 5000 ? "...\n" + content.slice(-5000) : content; lines.push("### Stderr Log"); lines.push("```"); lines.push(truncated.trim()); @@ -4981,16 +5443,20 @@ export default function (pi: ExtensionAPI) { lines.push("Stderr log found but unreadable."); } } else { - lines.push(`No stderr log found for lane ${lane} (pattern: \`*-lane-${lane}-worker-stderr.log\`).`); + lines.push( + `No stderr log found for lane ${lane} (pattern: \`*-lane-${lane}-worker-stderr.log\`).`, + ); } // Read most recent exit diagnostic if (exitFiles.length > 0) { const latestExit = exitFiles - .map(name => { + .map((name) => { const absPath = join(telemetryDir, name); let mtime = 0; - try { mtime = statSync(absPath).mtimeMs; } catch {} + try { + mtime = statSync(absPath).mtimeMs; + } catch {} return { name, mtime }; }) .sort((a, b) => b.mtime - a.mtime)[0]?.name; @@ -5003,7 +5469,9 @@ export default function (pi: ExtensionAPI) { if (exitData.errorMessage) lines.push(`**Error:** ${exitData.errorMessage}`); if (exitData.durationSec) lines.push(`**Duration:** ${exitData.durationSec}s`); lines.push(""); - } catch { /* skip malformed exit file */ } + } catch { + /* skip malformed exit file */ + } } } @@ -5015,7 +5483,8 @@ export default function (pi: ExtensionAPI) { label: "List Active Agents", description: "List all active Runtime V2 agents with their role, lane, task, status, and elapsed time.", - promptSnippet: "list_active_agents() — show active Runtime V2 agents with role, lane, task, status, elapsed", + promptSnippet: + "list_active_agents() — show active Runtime V2 agents with role, lane, task, status, elapsed", promptGuidelines: [ "Call list_active_agents to see all running agent sessions.", "Shows: session name, role (worker/reviewer/merger/supervisor), lane, task, context %, elapsed.", @@ -5027,7 +5496,12 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text" as const, text: result }], details: undefined }; } catch (err) { return { - content: [{ type: "text" as const, text: `Error listing agents: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text" as const, + text: `Error listing agents: ${err instanceof Error ? err.message : String(err)}`, + }, + ], details: undefined, }; } @@ -5053,10 +5527,12 @@ export default function (pi: ExtensionAPI) { return "❌ No active agents found (Runtime V2 registry is empty)."; } - // ── TP-106: Registry-based agent list formatter ──────────────── - function formatRegistryAgents(registry: import("./types.ts").RuntimeRegistry, _batchState: PersistedBatchState | null): string { + function formatRegistryAgents( + registry: import("./types.ts").RuntimeRegistry, + _batchState: PersistedBatchState | null, + ): string { const agents = Object.values(registry.agents); if (agents.length === 0) return "❌ No agents in registry."; @@ -5099,13 +5575,14 @@ export default function (pi: ExtensionAPI) { // Build everything into temporaries first, then commit atomically // so a partial failure doesn't leave mixed-generation state. try { - const freshCtx = buildExecutionContext(reloadCwd, loadOrchestratorConfig, loadTaskRunnerConfig); + const freshCtx = buildExecutionContext( + reloadCwd, + loadOrchestratorConfig, + loadTaskRunnerConfig, + ); let freshSupervisor: SupervisorConfig; try { - freshSupervisor = loadSupervisorConfig( - freshCtx.repoRoot, - freshCtx.pointer?.configRoot, - ); + freshSupervisor = loadSupervisorConfig(freshCtx.repoRoot, freshCtx.pointer?.configRoot); } catch { freshSupervisor = { ...DEFAULT_SUPERVISOR_CONFIG }; } @@ -5117,10 +5594,7 @@ export default function (pi: ExtensionAPI) { } catch { // Non-fatal — config was saved to disk but live reload failed. // Existing in-memory config is preserved unchanged. - ctx.ui.notify( - "⚠️ Saved to disk but live reload failed. Restart to apply.", - "warn", - ); + ctx.ui.notify("⚠️ Saved to disk but live reload failed. Restart to apply.", "warn"); } }); } catch (err: any) { @@ -5161,17 +5635,13 @@ export default function (pi: ExtensionAPI) { // and must surface loudly so the user fixes it. const setupError = err.code === "WORKSPACE_SETUP_REQUIRED"; execCtxInitError = setupError - ? ( - `❌ Orchestrator startup blocked [${err.code}]\n\n` + + ? `❌ Orchestrator startup blocked [${err.code}]\n\n` + `${err.message}\n\n` + `Orchestrator commands are disabled until this setup issue is resolved.` - ) - : ( - `❌ Workspace configuration error [${err.code}]\n\n` + + : `❌ Workspace configuration error [${err.code}]\n\n` + `${err.message}\n\n` + `Fix the workspace config at .pi/taskplane-workspace.yaml (or taskplane-config.json workspace section), then restart.\n` + - `Orchestrator commands are disabled until this is resolved.` - ); + `Orchestrator commands are disabled until this is resolved.`; if (setupError) { // Soft-fail: no notify, quiet status line. @@ -5201,10 +5671,7 @@ export default function (pi: ExtensionAPI) { // established pattern — all config loading after buildExecutionContext // uses the resolved execution context paths. try { - supervisorConfig = loadSupervisorConfig( - execCtx.repoRoot, - execCtx.pointer?.configRoot, - ); + supervisorConfig = loadSupervisorConfig(execCtx.repoRoot, execCtx.pointer?.configRoot); } catch { // Non-fatal — use defaults if supervisor config fails to load supervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG }; @@ -5260,17 +5727,17 @@ export default function (pi: ExtensionAPI) { const summary = buildTakeoverSummary(stateRoot, batchState); const reason = lockResult.status === "stale" - ? (isProcessAlive(lockResult.lock.pid) + ? isProcessAlive(lockResult.lock.pid) ? `Previous supervisor (PID ${lockResult.lock.pid}) has a stale heartbeat (last: ${lockResult.lock.heartbeat}). Process may be hung.` - : `Previous supervisor (PID ${lockResult.lock.pid}) process is dead.`) + : `Previous supervisor (PID ${lockResult.lock.pid}) process is dead.` : lockResult.status === "corrupt" ? "Found a corrupt supervisor lockfile (treating as stale)." : "No supervisor lockfile found for the active batch."; ctx.ui.notify( `🔄 **Active batch detected — ${reason}**\n\n` + - `Taking over supervisor duties for batch ${batchState.batchId}.\n\n` + - summary, + `Taking over supervisor duties for batch ${batchState.batchId}.\n\n` + + summary, "info", ); @@ -5313,13 +5780,13 @@ export default function (pi: ExtensionAPI) { const batchState = lockResult.batchState; ctx.ui.notify( `⚠️ **Another supervisor is already monitoring batch ${batchState.batchId}.**\n\n` + - ` PID: ${lock.pid}\n` + - ` Session: ${lock.sessionId}\n` + - ` Started: ${lock.startedAt}\n` + - ` Last heartbeat: ${lock.heartbeat}\n\n` + - `To force takeover, run \`/orch-takeover\`.\n` + - `The other session will yield on its next heartbeat.\n\n` + - `Otherwise, use the other terminal or the dashboard to monitor the batch.`, + ` PID: ${lock.pid}\n` + + ` Session: ${lock.sessionId}\n` + + ` Started: ${lock.startedAt}\n` + + ` Last heartbeat: ${lock.heartbeat}\n\n` + + `To force takeover, run \`/orch-takeover\`.\n` + + `The other session will yield on its next heartbeat.\n\n` + + `Otherwise, use the other terminal or the dashboard to monitor the batch.`, "warning", ); @@ -5340,17 +5807,17 @@ export default function (pi: ExtensionAPI) { // Notify user of available commands ctx.ui.notify( "Task Orchestrator ready\n\n" + - `Mode: ${modeLabel}\n` + - `Runtime: V2 default (configured spawn_mode: ${orchConfig.orchestrator.spawn_mode})\n` + - `Config: ${orchConfig.orchestrator.max_lanes} lanes, ` + - `${orchConfig.dependencies.source} deps\n` + - `Areas: ${areaCount} registered\n\n` + - "/orch Start batch execution\n" + - "/orch-plan Preview execution plan\n" + - "/orch-deps Show dependency graph\n" + - "/orch-sessions List orchestrator sessions\n" + - "/orch-takeover Force supervisor takeover\n" + - "/orch-integrate Integrate orch branch into working branch", + `Mode: ${modeLabel}\n` + + `Runtime: V2 default (configured spawn_mode: ${orchConfig.orchestrator.spawn_mode})\n` + + `Config: ${orchConfig.orchestrator.max_lanes} lanes, ` + + `${orchConfig.dependencies.source} deps\n` + + `Areas: ${areaCount} registered\n\n` + + "/orch Start batch execution\n" + + "/orch-plan Preview execution plan\n" + + "/orch-deps Show dependency graph\n" + + "/orch-sessions List orchestrator sessions\n" + + "/orch-takeover Force supervisor takeover\n" + + "/orch-integrate Integrate orch branch into working branch", "info", ); @@ -5420,13 +5887,13 @@ async function checkForUpdate(ctx: ExtensionContext): Promise { const response = await fetch("https://registry.npmjs.org/taskplane/latest", { signal: controller.signal, - headers: { "Accept": "application/json" }, + headers: { Accept: "application/json" }, }); clearTimeout(timeout); if (!response.ok) return; - const data = await response.json() as { version?: string }; + const data = (await response.json()) as { version?: string }; const latestVersion = data.version; if (!latestVersion) return; @@ -5434,9 +5901,9 @@ async function checkForUpdate(ctx: ExtensionContext): Promise { if (latestVersion !== installedVersion && isNewerVersion(latestVersion, installedVersion)) { ctx.ui.notify( `\n` + - ` Update Available\n` + - ` New version ${latestVersion} is available (installed: ${installedVersion}).\n` + - ` Run: pi update\n`, + ` Update Available\n` + + ` New version ${latestVersion} is available (installed: ${installedVersion}).\n` + + ` Run: pi update\n`, "info", ); } @@ -5459,4 +5926,3 @@ function isNewerVersion(a: string, b: string): boolean { } return false; } - diff --git a/extensions/taskplane/formatting.ts b/extensions/taskplane/formatting.ts index 3a4e58b4..ae008215 100644 --- a/extensions/taskplane/formatting.ts +++ b/extensions/taskplane/formatting.ts @@ -6,7 +6,16 @@ import { join } from "path"; import { truncateToWidth } from "@mariozechner/pi-tui"; import { parseDependencyReference } from "./discovery.ts"; -import type { LaneAssignment, MonitorState, OrchBatchRuntimeState, OrchDashboardViewModel, OrchLaneCardData, OrchSummaryCounts, ParsedTask, WaveComputationResult } from "./types.ts"; +import type { + LaneAssignment, + MonitorState, + OrchBatchRuntimeState, + OrchDashboardViewModel, + OrchLaneCardData, + OrchSummaryCounts, + ParsedTask, + WaveComputationResult, +} from "./types.ts"; import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts"; // ── Wave Output Formatting ─────────────────────────────────────────── @@ -30,9 +39,7 @@ export function formatDependencyGraph( const lines: string[] = []; // Sort tasks deterministically by ID - const sortedTasks = [...pending.values()].sort((a, b) => - a.taskId.localeCompare(b.taskId), - ); + const sortedTasks = [...pending.values()].sort((a, b) => a.taskId.localeCompare(b.taskId)); // Build downstream index: taskID → tasks that depend on it const downstream = new Map(); @@ -130,14 +137,8 @@ export function formatDependencyGraph( const dependents = (downstream.get(target) || []).sort(); if (dependents.length > 0) { hasDownstream = true; - const status = completed.has(target) - ? "✅" - : pending.has(target) - ? "⏳" - : "❓"; - lines.push( - ` ${target} ${status} ← ${dependents.join(", ")}`, - ); + const status = completed.has(target) ? "✅" : pending.has(target) ? "⏳" : "❓"; + lines.push(` ${target} ${status} ← ${dependents.join(", ")}`); } } if (!hasDownstream) { @@ -146,9 +147,7 @@ export function formatDependencyGraph( // Section 3: Independent tasks (no deps, nothing depends on them) const independentTasks = sortedTasks.filter( - (t) => - t.dependencies.length === 0 && - !(downstream.get(t.taskId)?.length), + (t) => t.dependencies.length === 0 && !downstream.get(t.taskId)?.length, ); if (independentTasks.length > 0) { lines.push(""); @@ -206,7 +205,7 @@ export function formatWavePlan( lines.push( `🌊 Execution Plan: ${result.waves.length} wave(s), ` + - `${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`, + `${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`, ); lines.push(""); @@ -225,46 +224,31 @@ export function formatWavePlan( const parallel = laneCount > 1 ? "parallel" : "serial"; lines.push( - ` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` + - `${laneCount} lane(s) [${parallel}]`, + ` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` + `${laneCount} lane(s) [${parallel}]`, ); // Calculate wave duration: critical path = max lane duration let maxLaneDuration = 0; // Sort lanes deterministically by lane number - const sortedLanes = [...laneGroups.entries()].sort( - (a, b) => a[0] - b[0], - ); + const sortedLanes = [...laneGroups.entries()].sort((a, b) => a[0] - b[0]); for (const [lane, assignments] of sortedLanes) { // Sort tasks within lane by task ID for deterministic output - const sortedAssignments = [...assignments].sort((a, b) => - a.taskId.localeCompare(b.taskId), - ); - const taskList = sortedAssignments - .map((a) => `${a.taskId} [${a.task.size}]`) - .join(", "); + const sortedAssignments = [...assignments].sort((a, b) => a.taskId.localeCompare(b.taskId)); + const taskList = sortedAssignments.map((a) => `${a.taskId} [${a.task.size}]`).join(", "); const laneDuration = sortedAssignments.reduce( - (sum, a) => - sum + getTaskDurationMinutes(a.task.size, sizeWeights), + (sum, a) => sum + getTaskDurationMinutes(a.task.size, sizeWeights), 0, ); if (laneDuration > maxLaneDuration) maxLaneDuration = laneDuration; - const serialNote = - sortedAssignments.length > 1 ? " (serial)" : ""; - lines.push( - ` Lane ${lane}: ${taskList}${serialNote} ` + - `[est. ${laneDuration} min]`, - ); + const serialNote = sortedAssignments.length > 1 ? " (serial)" : ""; + lines.push(` Lane ${lane}: ${taskList}${serialNote} ` + `[est. ${laneDuration} min]`); } // Critical path for this wave totalEstimate += maxLaneDuration; - lines.push( - ` ⏱ Wave duration: ${maxLaneDuration} min ` + - `(critical path: longest lane)`, - ); + lines.push(` ⏱ Wave duration: ${maxLaneDuration} min ` + `(critical path: longest lane)`); lines.push(""); } @@ -273,17 +257,15 @@ export function formatWavePlan( lines.push(`📊 Total estimated duration: ${totalEstimate} min (~${totalHours} hours)`); lines.push( ` Duration model: S=${SIZE_DURATION_MINUTES["S"]}m, ` + - `M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`, + `M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`, ); lines.push( - " Critical path: sum of per-wave bottleneck lanes " + - "(waves sequential, lanes parallel)", + " Critical path: sum of per-wave bottleneck lanes " + "(waves sequential, lanes parallel)", ); return lines.join("\n"); } - // ── Summary Helpers ────────────────────────────────────────────────── /** @@ -315,7 +297,10 @@ export function computeOrchSummaryCounts( const failed = batchState.failedTasks; const blocked = batchState.blockedTasks; const total = batchState.totalTasks; - const queued = Math.max(0, total - completed - failed - blocked - stalled - running - batchState.skippedTasks); + const queued = Math.max( + 0, + total - completed - failed - blocked - stalled - running - batchState.skippedTasks, + ); return { completed, running, queued, failed, blocked, stalled, total }; } @@ -360,9 +345,10 @@ export function buildDashboardViewModel( const summary = computeOrchSummaryCounts(batchState, monitorState); const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt); - const waveProgress = batchState.totalWaves > 0 - ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}` - : "0/0"; + const waveProgress = + batchState.totalWaves > 0 + ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}` + : "0/0"; // Build lane cards from monitor state (if available) or current lanes const laneCards: OrchLaneCardData[] = []; @@ -372,15 +358,16 @@ export function buildDashboardViewModel( // lanes, but monitorState may still hold wave N's data until the first // poll of wave N+1's monitor. Detect this mismatch by checking whether // the monitor's lane numbers match the current allocation. - const monitorIsFresh = monitorState && monitorState.lanes.length > 0 && ( + const monitorIsFresh = + monitorState && + monitorState.lanes.length > 0 && // If no current allocation, monitor data is the best we have // (covers terminal phases like completed/failed/stopped) - batchState.currentLanes.length === 0 || - // If allocated lanes exist, verify monitor lanes match them - monitorState.lanes.some(ml => - batchState.currentLanes.some(cl => cl.laneNumber === ml.laneNumber), - ) - ); + (batchState.currentLanes.length === 0 || + // If allocated lanes exist, verify monitor lanes match them + monitorState.lanes.some((ml) => + batchState.currentLanes.some((cl) => cl.laneNumber === ml.laneNumber), + )); // TP-170: Build a laneNumber → AllocatedLane index for identity reconciliation. // In workspace mode, the monitor’s sessionName (e.g., "orch-henry-api-lane-1") @@ -438,7 +425,11 @@ export function buildDashboardViewModel( totalChecked: snap?.totalChecked || 0, totalItems: snap?.totalItems || 0, completedTasks: lane.completedTasks.length, - totalLaneTasks: lane.completedTasks.length + lane.failedTasks.length + lane.remainingTasks.length + (lane.currentTaskId ? 1 : 0), + totalLaneTasks: + lane.completedTasks.length + + lane.failedTasks.length + + lane.remainingTasks.length + + (lane.currentTaskId ? 1 : 0), status, stallReason: snap?.stallReason || null, }); @@ -468,7 +459,7 @@ export function buildDashboardViewModel( // Determine attach hint let attachHint = ""; - const aliveLane = laneCards.find(l => l.sessionAlive && l.status === "running"); + const aliveLane = laneCards.find((l) => l.sessionAlive && l.status === "running"); if (aliveLane) { attachHint = `Use /orch-sessions to inspect active lane sessions (${aliveLane.sessionName})`; } else if (laneCards.length > 0) { @@ -513,19 +504,29 @@ export function buildDashboardViewModel( */ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: any): string[] { const w = colWidth - 2; // inner width (excluding │ borders) - const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s; + const trunc = (s: string, max: number) => (s.length > max ? s.slice(0, max - 3) + "..." : s); // Status icon and color - const statusIcon = card.status === "succeeded" ? "✓" - : card.status === "running" ? "●" - : card.status === "failed" ? "✗" - : card.status === "stalled" ? "⚠" - : "○"; - const statusColor = card.status === "succeeded" ? "success" - : card.status === "running" ? "accent" - : card.status === "failed" ? "error" - : card.status === "stalled" ? "warning" - : "dim"; + const statusIcon = + card.status === "succeeded" + ? "✓" + : card.status === "running" + ? "●" + : card.status === "failed" + ? "✗" + : card.status === "stalled" + ? "⚠" + : "○"; + const statusColor = + card.status === "succeeded" + ? "success" + : card.status === "running" + ? "accent" + : card.status === "failed" + ? "error" + : card.status === "stalled" + ? "warning" + : "dim"; // Line 1: Session name (e.g., "⎡orch-lane-1⎤") const sessionLabel = `⎡${card.sessionName}⎤`; @@ -535,9 +536,11 @@ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: // Line 2: Status + current task const taskInfo = card.currentTaskId ? `${statusIcon} ${card.currentTaskId}` - : card.status === "succeeded" ? `${statusIcon} done` - : card.status === "failed" ? `${statusIcon} failed` - : `${statusIcon} idle`; + : card.status === "succeeded" + ? `${statusIcon} done` + : card.status === "failed" + ? `${statusIcon} failed` + : `${statusIcon} idle`; const taskStr = theme.fg(statusColor, trunc(taskInfo, w)); const taskVis = Math.min(taskInfo.length, w); @@ -627,22 +630,35 @@ export function createOrchWidget( // ── Phase-specific rendering ────────────────── const phaseIcon = - vm.phase === "launching" ? "◌" - : vm.phase === "planning" ? "◌" - : vm.phase === "executing" ? "●" - : vm.phase === "merging" ? "🔀" - : vm.phase === "paused" ? "⏸" - : vm.phase === "stopped" ? "⛔" - : vm.phase === "completed" ? "✓" - : vm.phase === "failed" ? "✗" - : "○"; + vm.phase === "launching" + ? "◌" + : vm.phase === "planning" + ? "◌" + : vm.phase === "executing" + ? "●" + : vm.phase === "merging" + ? "🔀" + : vm.phase === "paused" + ? "⏸" + : vm.phase === "stopped" + ? "⛔" + : vm.phase === "completed" + ? "✓" + : vm.phase === "failed" + ? "✗" + : "○"; const phaseColor = - vm.phase === "executing" ? "accent" - : vm.phase === "merging" ? "accent" - : vm.phase === "completed" ? "success" - : vm.phase === "failed" || vm.phase === "stopped" ? "error" - : vm.phase === "paused" ? "warning" - : "dim"; + vm.phase === "executing" + ? "accent" + : vm.phase === "merging" + ? "accent" + : vm.phase === "completed" + ? "success" + : vm.phase === "failed" || vm.phase === "stopped" + ? "error" + : vm.phase === "paused" + ? "warning" + : "dim"; // Header: phase icon + batch ID + wave + elapsed const header = @@ -656,10 +672,7 @@ export function createOrchWidget( // ── Planning state ──────────────────────────── if (vm.phase === "planning") { - lines.push(truncateToWidth( - theme.fg("dim", " ◌ Planning batch..."), - width, - )); + lines.push(truncateToWidth(theme.fg("dim", " ◌ Planning batch..."), width)); return lines; } @@ -683,18 +696,24 @@ export function createOrchWidget( // ── Summary counts line ─────────────────────── const countParts: string[] = []; if (vm.summary.completed > 0) countParts.push(theme.fg("success", `${vm.summary.completed} ✓`)); - if (vm.summary.running > 0) countParts.push(theme.fg("accent", `${vm.summary.running} running`)); + if (vm.summary.running > 0) + countParts.push(theme.fg("accent", `${vm.summary.running} running`)); if (vm.summary.queued > 0) countParts.push(theme.fg("dim", `${vm.summary.queued} queued`)); if (vm.summary.failed > 0) countParts.push(theme.fg("error", `${vm.summary.failed} ✗`)); - if (vm.summary.blocked > 0) countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`)); - if (vm.summary.stalled > 0) countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`)); + if (vm.summary.blocked > 0) + countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`)); + if (vm.summary.stalled > 0) + countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`)); if (countParts.length > 0) { lines.push(truncateToWidth(" " + countParts.join(theme.fg("dim", " · ")), width)); } lines.push(""); // ── Lane cards ───────────────────────────────── - if (vm.laneCards.length > 0 && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) { + if ( + vm.laneCards.length > 0 && + (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused") + ) { const arrowWidth = 3; const minCardWidth = 18; const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth))); @@ -703,7 +722,7 @@ export function createOrchWidget( for (let rowStart = 0; rowStart < vm.laneCards.length; rowStart += cols) { const rowCards = vm.laneCards.slice(rowStart, rowStart + cols); - const rendered = rowCards.map(c => renderLaneCard(c, colWidth, theme)); + const rendered = rowCards.map((c) => renderLaneCard(c, colWidth, theme)); if (rendered.length > 0) { const cardHeight = rendered[0].length; @@ -721,47 +740,41 @@ export function createOrchWidget( // ── Terminal states (completed/failed/stopped) ── if (vm.phase === "completed") { - lines.push(truncateToWidth( - theme.fg("success", " ✅ Batch complete"), - width, - )); + lines.push(truncateToWidth(theme.fg("success", " ✅ Batch complete"), width)); } else if (vm.phase === "failed") { - lines.push(truncateToWidth( - theme.fg("error", " ❌ Batch failed"), - width, - )); + lines.push(truncateToWidth(theme.fg("error", " ❌ Batch failed"), width)); for (const err of vm.errors.slice(0, 3)) { - lines.push(truncateToWidth( - theme.fg("error", ` ${err.slice(0, 80)}`), - width, - )); + lines.push(truncateToWidth(theme.fg("error", ` ${err.slice(0, 80)}`), width)); } } else if (vm.phase === "stopped") { - lines.push(truncateToWidth( - theme.fg("error", ` ⛔ Stopped by ${vm.failurePolicy || "policy"}`), - width, - )); + lines.push( + truncateToWidth(theme.fg("error", ` ⛔ Stopped by ${vm.failurePolicy || "policy"}`), width), + ); } else if (vm.phase === "merging") { lines.push(""); - lines.push(truncateToWidth( - theme.fg("accent", ` 🔀 Merging lane branches into ${vm.orchBranch || "orch branch"}...`), - width, - )); + lines.push( + truncateToWidth( + theme.fg("accent", ` 🔀 Merging lane branches into ${vm.orchBranch || "orch branch"}...`), + width, + ), + ); } else if (vm.phase === "paused") { lines.push(""); - lines.push(truncateToWidth( - theme.fg("warning", " ⏸ Batch paused — lanes will stop after current tasks"), - width, - )); + lines.push( + truncateToWidth( + theme.fg("warning", " ⏸ Batch paused — lanes will stop after current tasks"), + width, + ), + ); } // ── Footer: attach hint ─────────────────────── - if (vm.attachHint && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) { + if ( + vm.attachHint && + (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused") + ) { lines.push(""); - lines.push(truncateToWidth( - theme.fg("dim", ` 💡 ${vm.attachHint}`), - width, - )); + lines.push(truncateToWidth(theme.fg("dim", ` 💡 ${vm.attachHint}`), width)); } return lines; @@ -770,4 +783,3 @@ export function createOrchWidget( }; }; } - diff --git a/extensions/taskplane/git.ts b/extensions/taskplane/git.ts index 93022f60..62d23050 100644 --- a/extensions/taskplane/git.ts +++ b/extensions/taskplane/git.ts @@ -4,7 +4,6 @@ */ import { execFileSync } from "child_process"; - // ── Branch Helpers ─────────────────────────────────────────────────── /** @@ -87,4 +86,3 @@ export function runGitWithEnv( }; } } - diff --git a/extensions/taskplane/lane-runner.ts b/extensions/taskplane/lane-runner.ts index 11b864e4..551cd52f 100644 --- a/extensions/taskplane/lane-runner.ts +++ b/extensions/taskplane/lane-runner.ts @@ -41,10 +41,7 @@ import { } from "./agent-host.ts"; import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts"; -import { - appendAgentEvent, - writeLaneSnapshot, -} from "./process-registry.ts"; +import { appendAgentEvent, writeLaneSnapshot } from "./process-registry.ts"; import { readOutbox, @@ -94,7 +91,7 @@ export function getStepsForRepoId( ): Set { const stepNumbers = new Set(); for (const step of stepSegmentMap) { - if (step.segments.some(seg => seg.repoId === repoId)) { + if (step.segments.some((seg) => seg.repoId === repoId)) { stepNumbers.add(step.stepNumber); } } @@ -131,7 +128,10 @@ export function getSegmentCheckboxes( const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep; // Find the segment header within this step - const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); + const segHeaderPattern = new RegExp( + `^####\\s+Segment:\\s*${repoId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, + "m", + ); const segMatch = stepContent.match(segHeaderPattern); if (!segMatch || segMatch.index === undefined) return null; @@ -326,13 +326,22 @@ export async function executeTaskV2( // This closes the race window where the monitor sees .DONE before lane-runner // can suppress it at segment end. For non-final segments, .DONE must not exist // at any point during execution. - const isNonFinalAtStart = segmentId != null - && Array.isArray(unit.task.segmentIds) - && unit.task.segmentIds.length > 1 - && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId; + const isNonFinalAtStart = + segmentId != null && + Array.isArray(unit.task.segmentIds) && + unit.task.segmentIds.length > 1 && + unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId; if (isNonFinalAtStart && existsSync(donePath)) { - try { unlinkSync(donePath); } catch { /* best effort */ } - logExecution(statusPath, "Segment start", `Removed stale .DONE before non-final segment ${segmentId}`); + try { + unlinkSync(donePath); + } catch { + /* best effort */ + } + logExecution( + statusPath, + "Segment start", + `Removed stale .DONE before non-final segment ${segmentId}`, + ); } // ── 2. Iteration loop ─────────────────────────────────────────── @@ -346,20 +355,35 @@ export async function executeTaskV2( // TP-174: Build segment context once for emitSnapshot calls. // Available outside the loop so it can be passed to makeResult too. const snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null = - (segmentId && unit.task.stepSegmentMap && config.repoId) + segmentId && unit.task.stepSegmentMap && config.repoId ? (() => { - const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId); - return repoSteps.size > 0 - ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId } - : null; - })() + const repoSteps = getStepsForRepoId(unit.task.stepSegmentMap!, config.repoId); + return repoSteps.size > 0 + ? { stepSegmentMap: unit.task.stepSegmentMap!, repoId: config.repoId } + : null; + })() : null; for (let iter = 0; iter < config.maxIterations; iter++) { if (pauseSignal.paused) { logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`); - return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime, - "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx); + return makeResult( + taskId, + segmentId, + workerAgentId, + "skipped", + startTime, + "Paused by user", + false, + totalIterations, + cumulativeCostUsd, + cumulativeTokens, + config, + statusPath, + reviewerStatePath, + undefined, + snapshotSegmentCtx, + ); } // Determine remaining steps @@ -370,39 +394,41 @@ export async function executeTaskV2( // Use config.repoId (structured identity) instead of parsing opaque segmentId. const stepSegmentMap = unit.task.stepSegmentMap; const currentRepoId = segmentId ? config.repoId : null; - const rawRepoStepNumbers = (stepSegmentMap && currentRepoId) - ? getStepsForRepoId(stepSegmentMap, currentRepoId) - : null; + const rawRepoStepNumbers = + stepSegmentMap && currentRepoId ? getStepsForRepoId(stepSegmentMap, currentRepoId) : null; // TP-174 legacy fallback: If no steps have segments for this repoId // (multi-segment task without explicit markers, where all checkboxes // are assigned to the fallback/packet repo), disable segment filtering. - const repoStepNumbers = (rawRepoStepNumbers && rawRepoStepNumbers.size > 0) - ? rawRepoStepNumbers - : null; + const repoStepNumbers = + rawRepoStepNumbers && rawRepoStepNumbers.size > 0 ? rawRepoStepNumbers : null; // TP-174: Read STATUS.md content once for segment-scoped checks const iterStatusContent = readFileSync(statusPath, "utf-8"); - const remainingSteps = parsed.steps.filter(step => { + const remainingSteps = parsed.steps.filter((step) => { // TP-174: When segment-scoped, only show steps that have work for this repoId if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false; // TP-174: Use segment-scoped completion check in segment mode if (repoStepNumbers && currentRepoId) { return !isSegmentComplete(iterStatusContent, step.number, currentRepoId); } - const ss = currentStatus.steps.find(s => s.number === step.number); + const ss = currentStatus.steps.find((s) => s.number === step.number); return !isStepComplete(ss); }); if (remainingSteps.length === 0) break; // All done totalIterations++; - updateStatusField(statusPath, "Current Step", `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`); + updateStatusField( + statusPath, + "Current Step", + `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`, + ); updateStatusField(statusPath, "Iteration", `${totalIterations}`); // Mark first incomplete step as in-progress const firstStep = remainingSteps[0]; - const firstStepStatus = currentStatus.steps.find(s => s.number === firstStep.number); + const firstStepStatus = currentStatus.steps.find((s) => s.number === firstStep.number); if (firstStepStatus?.status !== "in-progress") { updateStepStatus(statusPath, firstStep.number, "in-progress"); logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name); @@ -421,13 +447,23 @@ export async function executeTaskV2( // ── Build worker prompt ───────────────────────────────────── const wrapUpFile = join(taskFolder, ".task-wrap-up"); - if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ } + if (existsSync(wrapUpFile)) + try { + unlinkSync(wrapUpFile); + } catch { + /* ignore */ + } // TP-174/TP-501: Compute segment scope mode BEFORE building prompt. - const isSegmentScoped = !!(stepSegmentMap && currentRepoId && repoStepNumbers - && remainingSteps.length > 0 - && stepSegmentMap.find(s => s.stepNumber === remainingSteps[0].number) - ?.segments.find(seg => seg.repoId === currentRepoId)); + const isSegmentScoped = !!( + stepSegmentMap && + currentRepoId && + repoStepNumbers && + remainingSteps.length > 0 && + stepSegmentMap + .find((s) => s.stepNumber === remainingSteps[0].number) + ?.segments.find((seg) => seg.repoId === currentRepoId) + ); const promptLines = [ `Read your task instructions at: ${promptPath}`, @@ -444,9 +480,7 @@ export async function executeTaskV2( `- Lane repo ID: ${config.repoId}`, // Only show segment ID when segment-scoped. For FULL_TASK, omit to avoid // workers incorrectly self-scoping based on segment metadata. - ...(isSegmentScoped - ? [`- Active segment ID: ${segmentId}`] - : []), + ...(isSegmentScoped ? [`- Active segment ID: ${segmentId}`] : []), ``, `Packet home context:`, `- Packet home repo ID: ${unit.packetHomeRepoId}`, @@ -464,9 +498,10 @@ export async function executeTaskV2( // Only show segment DAG in segment-scoped mode const segmentDag = isSegmentScoped ? unit.task.explicitSegmentDag : null; if (segmentDag && segmentDag.repoIds.length > 0) { - const edgeSummary = segmentDag.edges.length > 0 - ? segmentDag.edges.map(edge => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ") - : "(no explicit edges)"; + const edgeSummary = + segmentDag.edges.length > 0 + ? segmentDag.edges.map((edge) => `${edge.fromRepoId}->${edge.toRepoId}`).join(", ") + : "(no explicit edges)"; promptLines.push( ``, `Segment DAG context (from PROMPT metadata):`, @@ -481,18 +516,19 @@ export async function executeTaskV2( // TP-174: Segment-scoped prompt — show only this segment's checkboxes if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) { const currentStepNum = remainingSteps[0].number; - const currentStepMapping = stepSegmentMap.find(s => s.stepNumber === currentStepNum); - const mySegment = currentStepMapping?.segments.find(seg => seg.repoId === currentRepoId); + const currentStepMapping = stepSegmentMap.find((s) => s.stepNumber === currentStepNum); + const mySegment = currentStepMapping?.segments.find((seg) => seg.repoId === currentRepoId); // Only inject segment-scoped prompt when the current step has an explicit // segment for this repoId. If mySegment is missing (legacy task without // markers, or step has no work for this repo), skip and preserve legacy behavior. if (currentStepMapping && mySegment) { - const otherSegments = currentStepMapping.segments.filter(seg => seg.repoId !== currentRepoId); + const otherSegments = currentStepMapping.segments.filter((seg) => seg.repoId !== currentRepoId); // Count total segments for this repo across all steps const totalStepsForRepo = repoStepNumbers ? repoStepNumbers.size : 0; - const segmentIndexInStep = currentStepMapping.segments.findIndex(seg => seg.repoId === currentRepoId) + 1; + const segmentIndexInStep = + currentStepMapping.segments.findIndex((seg) => seg.repoId === currentRepoId) + 1; const totalSegmentsInStep = currentStepMapping.segments.length; promptLines.push( @@ -514,19 +550,23 @@ export async function executeTaskV2( promptLines.push(``); promptLines.push(`Other segments in this step (NOT yours — do not attempt):`); for (const seg of otherSegments) { - promptLines.push(` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`); + promptLines.push( + ` - ${seg.repoId}: ${seg.checkboxes.length} checkbox(es) (will run in a separate segment)`, + ); } } // List completed steps for this repo - const completedForRepo = parsed.steps.filter(step => { + const completedForRepo = parsed.steps.filter((step) => { if (!repoStepNumbers || !repoStepNumbers.has(step.number)) return false; - const ss = currentStatus.steps.find(s => s.number === step.number); + const ss = currentStatus.steps.find((s) => s.number === step.number); return isStepComplete(ss); }); if (completedForRepo.length > 0) { promptLines.push(``); - promptLines.push(`Prior steps completed: ${completedForRepo.map(s => `Step ${s.number} (${s.name})`).join(", ")}`); + promptLines.push( + `Prior steps completed: ${completedForRepo.map((s) => `Step ${s.number} (${s.name})`).join(", ")}`, + ); } promptLines.push( @@ -538,13 +578,13 @@ export async function executeTaskV2( } if (totalIterations > 1 && remainingSteps.length > 0) { - const remainingSet = new Set(remainingSteps.map(s => s.number)); - const completedSteps = parsed.steps.filter(s => !remainingSet.has(s.number)); + const remainingSet = new Set(remainingSteps.map((s) => s.number)); + const completedSteps = parsed.steps.filter((s) => !remainingSet.has(s.number)); promptLines.push( ``, `IMPORTANT: You exited previously without completing all steps.`, - `Completed (do not redo): ${completedSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`, - `Remaining (focus here): ${remainingSteps.map(s => `Step ${s.number}: ${s.name}`).join(", ")}`, + `Completed (do not redo): ${completedSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ") || "(none)"}`, + `Remaining (focus here): ${remainingSteps.map((s) => `Step ${s.number}: ${s.name}`).join(", ")}`, ); // If the worker exited without checking any boxes, add a corrective directive @@ -572,12 +612,22 @@ export async function executeTaskV2( const steeringPendingPath = join(taskFolder, ".steering-pending"); // TP-106: Bridge extension wiring for agent-side reply/escalate tools - const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox"); + const outboxDir = join( + config.stateRoot, + ".pi", + "mailbox", + config.batchId, + workerAgentId, + "outbox", + ); const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts"); // TP-180: Forward user-installed extensions to worker agent const allPackages = loadPiSettingsPackages(config.stateRoot); - const workerPackages = filterExcludedExtensions(allPackages, config.workerExcludeExtensions ?? []); + const workerPackages = filterExcludedExtensions( + allPackages, + config.workerExcludeExtensions ?? [], + ); const hostOpts: AgentHostOptions = { agentId: workerAgentId, @@ -588,9 +638,10 @@ export async function executeTaskV2( repoId: config.repoId, cwd: unit.worktreePath, prompt: promptLines.join("\n"), - systemPrompt: (isSegmentScoped && config.workerSegmentPrompt - ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt - : config.workerSystemPrompt) || undefined, + systemPrompt: + (isSegmentScoped && config.workerSegmentPrompt + ? config.workerSystemPrompt + "\n\n---\n\n" + config.workerSegmentPrompt + : config.workerSystemPrompt) || undefined, model: config.workerModel || undefined, // TP-184: buildWorkerToolsAllowlist always appends ENGINE_BRIDGE_TOOLS // (review_step, notify_supervisor, request_segment_expansion) so that @@ -635,185 +686,217 @@ export async function executeTaskV2( // exits without making visible progress (no checkboxes, no blocker logged). onPrematureExit: config.onSupervisorAlert ? async (assistantMessage: string): Promise => { - // Check if the worker made visible progress during this turn: - // 1. Checkbox progress (more items checked) - // 2. Blocker logged (non-empty Blockers section) - try { - const statusContent = readFileSync(statusPath, "utf-8"); - // TP-174: Use same scope as prevTotalChecked (segment or global) - let midTotalChecked: number; - if (repoStepNumbers && currentRepoId) { - const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); - midTotalChecked = segCbs ? segCbs.checked : 0; - } else { - const midStatus = parseStatusMd(statusContent); - midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0); - } - if (midTotalChecked > prevTotalChecked) { - // Worker checked off checkboxes — let it exit normally - return null; - } - // Check for blocker entries: extract Blockers section and see if non-empty - const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i); - if (blockerMatch) { - const blockerContent = blockerMatch[1].trim(); - // If blockers section has real content (not just "*None*" or empty) - if (blockerContent && blockerContent !== "*None*") { - // Worker logged a blocker — let it exit normally + // Check if the worker made visible progress during this turn: + // 1. Checkbox progress (more items checked) + // 2. Blocker logged (non-empty Blockers section) + try { + const statusContent = readFileSync(statusPath, "utf-8"); + // TP-174: Use same scope as prevTotalChecked (segment or global) + let midTotalChecked: number; + if (repoStepNumbers && currentRepoId) { + const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); + midTotalChecked = segCbs ? segCbs.checked : 0; + } else { + const midStatus = parseStatusMd(statusContent); + midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0); + } + if (midTotalChecked > prevTotalChecked) { + // Worker checked off checkboxes — let it exit normally return null; } + // Check for blocker entries: extract Blockers section and see if non-empty + const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i); + if (blockerMatch) { + const blockerContent = blockerMatch[1].trim(); + // If blockers section has real content (not just "*None*" or empty) + if (blockerContent && blockerContent !== "*None*") { + // Worker logged a blocker — let it exit normally + return null; + } + } + } catch { + /* If we can't read STATUS.md, proceed with escalation */ } - } catch { /* If we can't read STATUS.md, proceed with escalation */ } - - // No visible progress — compose escalation message. - // TP-187 (#540): when the worker exits silently, fall back to the most - // recent `assistant_message` event in events.jsonl so the supervisor - // has SOMETHING to act on instead of `Worker said: ""`. - let workerSaid = (assistantMessage ?? "").trim(); - let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" = "current-turn"; - if (!workerSaid) { - workerSaidSource = "empty-sentinel"; - try { - const raw = readFileSync(eventsPath, "utf-8"); - const lines = raw.split("\n"); - // Walk backward to find the most recent assistant_message with non-empty text. - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (!line) continue; - try { - const evt = JSON.parse(line) as Record; - if (evt.type === "assistant_message") { - const payload = evt.payload as Record | undefined; - const text = typeof payload?.text === "string" ? payload.text.trim() : ""; - if (text) { - workerSaid = text; - workerSaidSource = "events-jsonl-fallback"; - break; + + // No visible progress — compose escalation message. + // TP-187 (#540): when the worker exits silently, fall back to the most + // recent `assistant_message` event in events.jsonl so the supervisor + // has SOMETHING to act on instead of `Worker said: ""`. + let workerSaid = (assistantMessage ?? "").trim(); + let workerSaidSource: "current-turn" | "events-jsonl-fallback" | "empty-sentinel" = + "current-turn"; + if (!workerSaid) { + workerSaidSource = "empty-sentinel"; + try { + const raw = readFileSync(eventsPath, "utf-8"); + const lines = raw.split("\n"); + // Walk backward to find the most recent assistant_message with non-empty text. + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + try { + const evt = JSON.parse(line) as Record; + if (evt.type === "assistant_message") { + const payload = evt.payload as Record | undefined; + const text = typeof payload?.text === "string" ? payload.text.trim() : ""; + if (text) { + workerSaid = text; + workerSaidSource = "events-jsonl-fallback"; + break; + } } + } catch { + /* skip malformed line */ } - } catch { /* skip malformed line */ } - } - } catch { /* events.jsonl unreadable; sentinel will be used */ } - } - if (!workerSaid) { - workerSaid = "(no assistant message captured — worker exited without producing visible output)"; - workerSaidSource = "empty-sentinel"; - } - const truncatedMsg = workerSaid.slice(0, 500); - const uncheckedItems: string[] = []; - try { - const statusContent = readFileSync(statusPath, "utf-8"); - // TP-174: When segment-scoped, report only this segment's unchecked items - if (repoStepNumbers && currentRepoId) { - const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); - if (segCbs) { - for (const text of segCbs.uncheckedTexts.slice(0, 5)) { - uncheckedItems.push(text); } + } catch { + /* events.jsonl unreadable; sentinel will be used */ } - } else { - const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm); - if (uncheckedMatches) { - for (const item of uncheckedMatches.slice(0, 5)) { - uncheckedItems.push(item.replace(/^- \[ \] /, "").trim()); + } + if (!workerSaid) { + workerSaid = + "(no assistant message captured — worker exited without producing visible output)"; + workerSaidSource = "empty-sentinel"; + } + const truncatedMsg = workerSaid.slice(0, 500); + const uncheckedItems: string[] = []; + try { + const statusContent = readFileSync(statusPath, "utf-8"); + // TP-174: When segment-scoped, report only this segment's unchecked items + if (repoStepNumbers && currentRepoId) { + const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); + if (segCbs) { + for (const text of segCbs.uncheckedTexts.slice(0, 5)) { + uncheckedItems.push(text); + } + } + } else { + const uncheckedMatches = statusContent.match(/^- \[ \] .+$/gm); + if (uncheckedMatches) { + for (const item of uncheckedMatches.slice(0, 5)) { + uncheckedItems.push(item.replace(/^- \[ \] /, "").trim()); + } } } + } catch { + /* best effort */ } - } catch { /* best effort */ } - const currentStepInfo = remainingSteps.length > 0 - ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}` - : "Unknown"; + const currentStepInfo = + remainingSteps.length > 0 + ? `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}` + : "Unknown"; - // Fire supervisor alert - try { - config.onSupervisorAlert!({ - category: "worker-exit-intercept", - summary: - `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` + - ` Task: ${taskId}\n` + - ` Current step: ${currentStepInfo}\n` + - ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` + - ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` + - ` Worker said: "${truncatedMsg}"` + - (workerSaidSource === "events-jsonl-fallback" - ? ` (fallback: most-recent assistant_message from events.jsonl)\n` - : workerSaidSource === "empty-sentinel" - ? ` (no assistant message captured this iteration)\n` - : "\n") + - `\nSend a steering message to ${workerAgentId} with targeted instructions,` + - ` or reply "skip" / "let it fail" to close the session.`, - context: { - taskId, - laneId: `lane-${config.laneNumber}`, - laneNumber: config.laneNumber, - agentId: workerAgentId, - exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`, - }, - }); - } catch { /* best effort — don't block on alert failure */ } - - // Poll worker mailbox inbox for supervisor reply (60s timeout) - const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000; - const POLL_INTERVAL_MS = 2_000; - const escalationTimestamp = Date.now(); - const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId); - - const supervisorReply = await new Promise((resolve) => { - const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS; - const poll = () => { - if (Date.now() >= deadline) { - resolve(null); // Timeout — fall back to corrective re-spawn - return; - } - try { - const messages = readInbox(inboxDir, config.batchId); - // Only accept messages newer than escalation timestamp - for (const { filename, message } of messages) { - if (message.timestamp >= escalationTimestamp && message.from === "supervisor") { - // Consume the message - const ackDir = join(dirname(inboxDir), "ack"); - try { ackMessage(inboxDir, filename); } catch { /* best effort */ } - resolve(message.content); - return; + // Fire supervisor alert + try { + config.onSupervisorAlert!({ + category: "worker-exit-intercept", + summary: + `🔄 Worker on lane ${config.laneNumber} wants to exit with no progress.\n` + + ` Task: ${taskId}\n` + + ` Current step: ${currentStepInfo}\n` + + ` Iteration: ${totalIterations}, No-progress count: ${noProgressCount + 1}\n` + + ` Unchecked items: ${uncheckedItems.length > 0 ? uncheckedItems.join("; ") : "(none found)"}\n` + + ` Worker said: "${truncatedMsg}"` + + (workerSaidSource === "events-jsonl-fallback" + ? ` (fallback: most-recent assistant_message from events.jsonl)\n` + : workerSaidSource === "empty-sentinel" + ? ` (no assistant message captured this iteration)\n` + : "\n") + + `\nSend a steering message to ${workerAgentId} with targeted instructions,` + + ` or reply "skip" / "let it fail" to close the session.`, + context: { + taskId, + laneId: `lane-${config.laneNumber}`, + laneNumber: config.laneNumber, + agentId: workerAgentId, + exitReason: `worker_exit_no_progress: ${truncatedMsg.slice(0, 200)}`, + }, + }); + } catch { + /* best effort — don't block on alert failure */ + } + + // Poll worker mailbox inbox for supervisor reply (60s timeout) + const SUPERVISOR_REPLY_TIMEOUT_MS = 60_000; + const POLL_INTERVAL_MS = 2_000; + const escalationTimestamp = Date.now(); + const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId); + + const supervisorReply = await new Promise((resolve) => { + const deadline = Date.now() + SUPERVISOR_REPLY_TIMEOUT_MS; + const poll = () => { + if (Date.now() >= deadline) { + resolve(null); // Timeout — fall back to corrective re-spawn + return; + } + try { + const messages = readInbox(inboxDir, config.batchId); + // Only accept messages newer than escalation timestamp + for (const { filename, message } of messages) { + if (message.timestamp >= escalationTimestamp && message.from === "supervisor") { + // Consume the message + const ackDir = join(dirname(inboxDir), "ack"); + try { + ackMessage(inboxDir, filename); + } catch { + /* best effort */ + } + resolve(message.content); + return; + } } + } catch { + /* inbox not ready yet */ } - } catch { /* inbox not ready yet */ } - setTimeout(poll, POLL_INTERVAL_MS); - }; - poll(); - }); + setTimeout(poll, POLL_INTERVAL_MS); + }; + poll(); + }); - if (!supervisorReply) { - // Timeout — let the session close, corrective re-spawn will handle it - logExecution(statusPath, "Exit intercept timeout", - `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`); - return null; - } + if (!supervisorReply) { + // Timeout — let the session close, corrective re-spawn will handle it + logExecution( + statusPath, + "Exit intercept timeout", + `Supervisor did not respond within ${SUPERVISOR_REPLY_TIMEOUT_MS / 1000}s — closing session`, + ); + return null; + } - // Interpret supervisor reply: close directives vs instructional content - const normalizedReply = supervisorReply.trim().toLowerCase(); - const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"]; - // Only short messages (< 30 chars) can be close directives. - // Longer messages are always instructions even if they start with "stop". - const isShortEnoughForDirective = normalizedReply.length < 30; - if (isShortEnoughForDirective && CLOSE_DIRECTIVES.some(d => - normalizedReply === d || - normalizedReply.startsWith(d + ":") || - normalizedReply.startsWith(d + " ") || - normalizedReply.startsWith(d + ".") || - normalizedReply.startsWith(d + " -") - )) { - logExecution(statusPath, "Exit intercept close", - `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`); - return null; - } + // Interpret supervisor reply: close directives vs instructional content + const normalizedReply = supervisorReply.trim().toLowerCase(); + const CLOSE_DIRECTIVES = ["skip", "let it fail", "close", "abort", "stop"]; + // Only short messages (< 30 chars) can be close directives. + // Longer messages are always instructions even if they start with "stop". + const isShortEnoughForDirective = normalizedReply.length < 30; + if ( + isShortEnoughForDirective && + CLOSE_DIRECTIVES.some( + (d) => + normalizedReply === d || + normalizedReply.startsWith(d + ":") || + normalizedReply.startsWith(d + " ") || + normalizedReply.startsWith(d + ".") || + normalizedReply.startsWith(d + " -"), + ) + ) { + logExecution( + statusPath, + "Exit intercept close", + `Supervisor directed session close: "${supervisorReply.slice(0, 100)}"`, + ); + return null; + } - // Instructional reply — return as new prompt for the worker - logExecution(statusPath, "Exit intercept reprompt", - `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`); - return supervisorReply; - } + // Instructional reply — return as new prompt for the worker + logExecution( + statusPath, + "Exit intercept reprompt", + `Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`, + ); + return supervisorReply; + } : undefined, }; @@ -822,11 +905,17 @@ export async function executeTaskV2( // present in the allowlist. Warn (do NOT throw or block spawn) if any // is missing — this catches future helper bugs or accidental bypasses. // See issue #530 for what silently breaks when bridge tools are missing. - const toolsList = (hostOpts.tools ?? "").split(",").map((s) => s.trim()).filter(Boolean); + const toolsList = (hostOpts.tools ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); for (const bridgeTool of ENGINE_BRIDGE_TOOLS) { if (!toolsList.includes(bridgeTool)) { - logExecution(statusPath, "WARN", - `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`); + logExecution( + statusPath, + "WARN", + `workerTools allowlist missing engine bridge tool '${bridgeTool}'; review/coordination features will silently no-op`, + ); } } @@ -852,8 +941,19 @@ export async function executeTaskV2( iterationTelemetry = telemetry; lastTelemetry = telemetry; // Emit lane snapshot - emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx); - } catch { /* non-fatal: telemetry callback must never crash the engine */ } + emitSnapshot( + config, + taskId, + segmentId, + "running", + telemetry, + statusPath, + reviewerStatePath, + snapshotSegmentCtx, + ); + } catch { + /* non-fatal: telemetry callback must never crash the engine */ + } }); // Reviewer telemetry is written by the worker bridge during review_step. @@ -862,7 +962,16 @@ export async function executeTaskV2( let reviewerSnapshotFailures = 0; const reviewerRefreshFailureThreshold = 5; const reviewerRefresh = setInterval(() => { - const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx); + const ok = emitSnapshot( + config, + taskId, + segmentId, + "running", + iterationTelemetry, + statusPath, + reviewerStatePath, + snapshotSegmentCtx, + ); if (ok) { reviewerSnapshotFailures = 0; return; @@ -890,12 +999,20 @@ export async function executeTaskV2( lastTelemetry = workerResult; // Clean up wrap-up signal - if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch { /* ignore */ } + if (existsSync(wrapUpFile)) + try { + unlinkSync(wrapUpFile); + } catch { + /* ignore */ + } // Accumulate costs cumulativeCostUsd += workerResult.costUsd; - cumulativeTokens += workerResult.inputTokens + workerResult.outputTokens + - workerResult.cacheReadTokens + workerResult.cacheWriteTokens; + cumulativeTokens += + workerResult.inputTokens + + workerResult.outputTokens + + workerResult.cacheReadTokens + + workerResult.cacheWriteTokens; // ── TP-106: Poll worker outbox for replies/escalations ───── try { @@ -949,37 +1066,50 @@ export async function executeTaskV2( exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`, }, }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } } // Consume outbox message to prevent duplicate processing in later iterations. ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id); } - } catch { /* best effort */ } + } catch { + /* best effort */ + } // ── Steering annotation ───────────────────────────────────── try { if (existsSync(steeringPendingPath)) { const raw = readFileSync(steeringPendingPath, "utf-8"); - for (const line of raw.split("\n").filter(l => l.trim())) { + for (const line of raw.split("\n").filter((l) => l.trim())) { try { const entry = JSON.parse(line) as { ts: number; content: string; id: string }; const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200); const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " "); logExecution(statusPath, "⚠️ Steering", sanitized); - } catch { /* skip malformed */ } + } catch { + /* skip malformed */ + } } unlinkSync(steeringPendingPath); } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // Log iteration result const statusMsg = workerResult.killed ? `killed (${workerKillReason === "context" ? "context limit" : "wall-clock timeout"})` - : (workerResult.exitCode === 0 ? "done" : `error (code ${workerResult.exitCode})`); - logExecution(statusPath, `Worker iter ${totalIterations}`, - `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`); + : workerResult.exitCode === 0 + ? "done" + : `error (code ${workerResult.exitCode})`; + logExecution( + statusPath, + `Worker iter ${totalIterations}`, + `${statusMsg} in ${Math.round(workerResult.durationMs / 1000)}s, tools: ${workerResult.toolCalls}`, + ); // ── Check progress ────────────────────────────────────────── const afterStatusContent = readFileSync(statusPath, "utf-8"); @@ -1008,21 +1138,31 @@ export async function executeTaskV2( stdio: ["pipe", "pipe", "pipe"], }).trim(); // Only count source file changes as soft progress, not just STATUS.md - const changedFiles = diffOutput.split("\n").filter(l => l.includes("|")); - const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering")); + const changedFiles = diffOutput.split("\n").filter((l) => l.includes("|")); + const sourceChanges = changedFiles.filter( + (l) => !l.includes("STATUS.md") && !l.includes(".steering"), + ); hasSoftProgress = sourceChanges.length > 0; - } catch { /* git not available or timeout — treat as no soft progress */ } + } catch { + /* git not available or timeout — treat as no soft progress */ + } if (hasSoftProgress) { // Worker has uncommitted code changes — don't count toward stall. // Reset the counter since the worker is actively editing. - logExecution(statusPath, "Soft progress", - `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`); + logExecution( + statusPath, + "Soft progress", + `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`, + ); noProgressCount = 0; } else { noProgressCount++; - logExecution(statusPath, "No progress", - `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`); + logExecution( + statusPath, + "No progress", + `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`, + ); if (noProgressCount >= config.noProgressLimit) { logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`); // TP-187 (#538): synchronous outbox drain at lane-termination decision @@ -1032,10 +1172,15 @@ export async function executeTaskV2( try { const drained = drainAgentOutbox(config.stateRoot, config.batchId, workerAgentId); if (drained > 0) { - logExecution(statusPath, "Outbox drained", - `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`); + logExecution( + statusPath, + "Outbox drained", + `No-progress kill: drained ${drained} pending outbox entr${drained === 1 ? "y" : "ies"} for ${workerAgentId}`, + ); } - } catch { /* best effort — do not block termination */ } + } catch { + /* best effort — do not block termination */ + } // TP-187 (#538): notify the supervisor process so it can suppress any // further alerts queued for this lane (zombie-alert filter). if (config.onLaneTerminated) { @@ -1047,10 +1192,27 @@ export async function executeTaskV2( terminatedAt: Date.now(), reason: "no-progress-kill", }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } - return makeResult(taskId, segmentId, workerAgentId, "failed", startTime, - `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + return makeResult( + taskId, + segmentId, + workerAgentId, + "failed", + startTime, + `No progress after ${noProgressCount} iterations`, + false, + totalIterations, + cumulativeCostUsd, + cumulativeTokens, + config, + statusPath, + reviewerStatePath, + lastTelemetry, + snapshotSegmentCtx, + ); } } } else { @@ -1065,7 +1227,7 @@ export async function executeTaskV2( if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) { // Only mark step complete in STATUS.md if ALL segments in that step // are complete (not just ours). But for loop exit, we only care about ours. - const ss = afterStatus.steps.find(s => s.number === stepNum); + const ss = afterStatus.steps.find((s) => s.number === stepNum); if (isStepComplete(ss)) { updateStepStatus(statusPath, stepNum, "complete"); } @@ -1073,7 +1235,7 @@ export async function executeTaskV2( } } else { for (const step of parsed.steps) { - const ss = afterStatus.steps.find(s => s.number === step.number); + const ss = afterStatus.steps.find((s) => s.number === step.number); if (isStepComplete(ss)) { updateStepStatus(statusPath, step.number, "complete"); } @@ -1085,12 +1247,12 @@ export async function executeTaskV2( // have their segment checkboxes complete. let allComplete: boolean; if (repoStepNumbers && currentRepoId) { - allComplete = [...repoStepNumbers].every(stepNum => + allComplete = [...repoStepNumbers].every((stepNum) => isSegmentComplete(afterStatusContent, stepNum, currentRepoId), ); } else { - allComplete = parsed.steps.every(step => { - const ss = afterStatus.steps.find(s => s.number === step.number); + allComplete = parsed.steps.every((step) => { + const ss = afterStatus.steps.find((s) => s.number === step.number); return isStepComplete(ss); }); } @@ -1106,21 +1268,21 @@ export async function executeTaskV2( // the iteration loop variables are out of scope here. const postLoopRepoId = segmentId ? config.repoId : null; const postLoopStepSegMap = unit.task.stepSegmentMap; - const postLoopRepoSteps = (postLoopStepSegMap && postLoopRepoId) - ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId) - : null; - const effectivePostLoopRepoSteps = (postLoopRepoSteps && postLoopRepoSteps.size > 0) - ? postLoopRepoSteps - : null; + const postLoopRepoSteps = + postLoopStepSegMap && postLoopRepoId + ? getStepsForRepoId(postLoopStepSegMap, postLoopRepoId) + : null; + const effectivePostLoopRepoSteps = + postLoopRepoSteps && postLoopRepoSteps.size > 0 ? postLoopRepoSteps : null; let allStepsComplete: boolean; if (effectivePostLoopRepoSteps && postLoopRepoId) { - allStepsComplete = [...effectivePostLoopRepoSteps].every(stepNum => + allStepsComplete = [...effectivePostLoopRepoSteps].every((stepNum) => isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId), ); } else { - allStepsComplete = parsed.steps.every(step => { - const ss = finalStatus.steps.find(s => s.number === step.number); + allStepsComplete = parsed.steps.every((step) => { + const ss = finalStatus.steps.find((s) => s.number === step.number); return isStepComplete(ss); }); } @@ -1129,40 +1291,55 @@ export async function executeTaskV2( let incomplete: string; if (effectivePostLoopRepoSteps && postLoopRepoId) { incomplete = [...effectivePostLoopRepoSteps] - .filter(stepNum => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId)) - .map(n => `Step ${n}`) + .filter((stepNum) => !isSegmentComplete(finalStatusContent, stepNum, postLoopRepoId)) + .map((n) => `Step ${n}`) .join(", "); } else { incomplete = parsed.steps - .filter(step => { - const ss = finalStatus.steps.find(s => s.number === step.number); + .filter((step) => { + const ss = finalStatus.steps.find((s) => s.number === step.number); return !isStepComplete(ss); }) - .map(s => `Step ${s.number}`) + .map((s) => `Step ${s.number}`) .join(", "); } logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`); - return makeResult(taskId, segmentId, workerAgentId, "failed", startTime, + return makeResult( + taskId, + segmentId, + workerAgentId, + "failed", + startTime, `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`, - false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + false, + totalIterations, + cumulativeCostUsd, + cumulativeTokens, + config, + statusPath, + reviewerStatePath, + lastTelemetry, + snapshotSegmentCtx, + ); } // TP-145: Determine if this is a non-final segment of a multi-segment task. // If more segments remain after this one, suppress .DONE creation so that // the engine can advance the segment frontier and execute subsequent segments. // .DONE must only exist when ALL segments of a multi-segment task are complete. - const isNonFinalSegment = segmentId != null - && Array.isArray(unit.task.segmentIds) - && unit.task.segmentIds.length > 1 - && unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId; + const isNonFinalSegment = + segmentId != null && + Array.isArray(unit.task.segmentIds) && + unit.task.segmentIds.length > 1 && + unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId; // TP-165: Check for pending expansion requests in the worker's outbox. // If the worker filed expansion requests, more segments may be added by the // engine at the segment boundary — .DONE must not be created even if this // appears to be the final segment based on the static segmentIds list. - const hasPendingExpansionRequests = segmentId != null && hasPendingExpansionRequestFiles( - config.stateRoot, config.batchId, workerAgentId, - ); + const hasPendingExpansionRequests = + segmentId != null && + hasPendingExpansionRequestFiles(config.stateRoot, config.batchId, workerAgentId); if (isNonFinalSegment || hasPendingExpansionRequests) { // Segment succeeded but more segments remain — suppress .DONE and "✅ Complete" status. @@ -1171,23 +1348,50 @@ export async function executeTaskV2( // write access and sometimes create .DONE on their own, bypassing this gate). if (existsSync(donePath)) { let deleted = false; - try { unlinkSync(donePath); deleted = true; } catch { /* best effort */ } + try { + unlinkSync(donePath); + deleted = true; + } catch { + /* best effort */ + } if (deleted) { - logExecution(statusPath, "Segment complete", - `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`); + logExecution( + statusPath, + "Segment complete", + `Segment ${segmentId} succeeded (non-final — removed premature worker-created .DONE)`, + ); } else { - logExecution(statusPath, "Segment complete", - `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`); + logExecution( + statusPath, + "Segment complete", + `⚠️ Segment ${segmentId} succeeded but FAILED to remove premature .DONE — downstream segments may be skipped`, + ); } } else { - logExecution(statusPath, "Segment complete", - `Segment ${segmentId} succeeded (not final — .DONE suppressed)`); + logExecution( + statusPath, + "Segment complete", + `Segment ${segmentId} succeeded (not final — .DONE suppressed)`, + ); } - const suppressionReason = isNonFinalSegment - ? "non-final" - : "pending expansion requests"; - return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime, - `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + const suppressionReason = isNonFinalSegment ? "non-final" : "pending expansion requests"; + return makeResult( + taskId, + segmentId, + workerAgentId, + "succeeded", + startTime, + `Segment completed (${suppressionReason} — .DONE suppressed)`, + false, + totalIterations, + cumulativeCostUsd, + cumulativeTokens, + config, + statusPath, + reviewerStatePath, + lastTelemetry, + snapshotSegmentCtx, + ); } // Create .DONE if not already present (final segment or single-segment/whole-task execution) @@ -1197,8 +1401,23 @@ export async function executeTaskV2( updateStatusField(statusPath, "Status", "✅ Complete"); logExecution(statusPath, "Task complete", ".DONE created"); - return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime, - ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + return makeResult( + taskId, + segmentId, + workerAgentId, + "succeeded", + startTime, + ".DONE file created by lane-runner", + true, + totalIterations, + cumulativeCostUsd, + cumulativeTokens, + config, + statusPath, + reviewerStatePath, + lastTelemetry, + snapshotSegmentCtx, + ); } // ── Helpers ────────────────────────────────────────────────────────── @@ -1262,17 +1481,18 @@ function makeResult( /** TP-174: Segment context for segment-scoped snapshot progress */ segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null, ): LaneRunnerTaskResult { - const telemetry = status === "skipped" - ? undefined - : { - inputTokens: finalTelemetry?.inputTokens ?? 0, - outputTokens: finalTelemetry?.outputTokens ?? 0, - cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0, - cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0, - costUsd: finalTelemetry?.costUsd ?? 0, - toolCalls: finalTelemetry?.toolCalls ?? 0, - durationMs: finalTelemetry?.durationMs ?? 0, - }; + const telemetry = + status === "skipped" + ? undefined + : { + inputTokens: finalTelemetry?.inputTokens ?? 0, + outputTokens: finalTelemetry?.outputTokens ?? 0, + cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0, + cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0, + costUsd: finalTelemetry?.costUsd ?? 0, + toolCalls: finalTelemetry?.toolCalls ?? 0, + durationMs: finalTelemetry?.durationMs ?? 0, + }; const result: LaneRunnerTaskResult = { outcome: { @@ -1295,7 +1515,16 @@ function makeResult( // TP-115: Emit terminal snapshot with real telemetry from agent-host result if (config && statusPath && reviewerStatePath) { const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status); - emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx); + emitSnapshot( + config, + taskId, + segmentId, + terminalStatus, + finalTelemetry ?? {}, + statusPath, + reviewerStatePath, + segmentCtx, + ); } return result; @@ -1308,9 +1537,10 @@ export function readReviewerTelemetrySnapshot( config: LaneRunnerConfig, reviewerStatePathOrStatusPath: string, ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null { - const reviewerPath = basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md" - ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json") - : reviewerStatePathOrStatusPath; + const reviewerPath = + basename(reviewerStatePathOrStatusPath).toLowerCase() === "status.md" + ? join(dirname(reviewerStatePathOrStatusPath), ".reviewer-state.json") + : reviewerStatePathOrStatusPath; if (!existsSync(reviewerPath)) return null; try { @@ -1334,7 +1564,7 @@ export function readReviewerTelemetrySnapshot( if (parsed.status !== "running") return null; // Stale guard: if updatedAt is present and older than threshold, ignore - if (parsed.updatedAt && (Date.now() - parsed.updatedAt) > REVIEWER_STATE_STALE_MS) return null; + if (parsed.updatedAt && Date.now() - parsed.updatedAt > REVIEWER_STATE_STALE_MS) return null; return { agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"), @@ -1413,7 +1643,9 @@ function emitSnapshot( iteration: parsed.iteration, reviews: parsed.reviewCounter, }; - } catch { /* best effort */ } + } catch { + /* best effort */ + } const reviewerSnapshot = readReviewerTelemetrySnapshot(config, reviewerStatePath); @@ -1451,4 +1683,3 @@ function emitSnapshot( return false; } } - diff --git a/extensions/taskplane/mailbox.ts b/extensions/taskplane/mailbox.ts index d5f9b871..19bd3ec5 100644 --- a/extensions/taskplane/mailbox.ts +++ b/extensions/taskplane/mailbox.ts @@ -24,7 +24,16 @@ */ import { join, dirname } from "path"; -import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, appendFileSync } from "fs"; +import { + existsSync, + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, + renameSync, + unlinkSync, + appendFileSync, +} from "fs"; import { randomBytes } from "crypto"; import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts"; import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts"; @@ -85,7 +94,6 @@ export function broadcastInboxDir(stateRoot: string, batchId: string): string { return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, "_broadcast", "inbox"); } - // ── Write ──────────────────────────────────────────────────────────── /** @@ -116,8 +124,8 @@ export function writeMailboxMessage( if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) { throw new Error( `Mailbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit ` + - `(${contentBytes} bytes). Steering messages should be concise directives. ` + - `Write larger context to a file and reference it by path.`, + `(${contentBytes} bytes). Steering messages should be concise directives. ` + + `Write larger context to a file and reference it by path.`, ); } @@ -140,9 +148,10 @@ export function writeMailboxMessage( }; // Determine inbox directory - const inboxDir = to === "_broadcast" - ? broadcastInboxDir(stateRoot, batchId) - : sessionInboxDir(stateRoot, batchId, to); + const inboxDir = + to === "_broadcast" + ? broadcastInboxDir(stateRoot, batchId) + : sessionInboxDir(stateRoot, batchId, to); // Ensure inbox directory exists mkdirSync(inboxDir, { recursive: true }); @@ -171,7 +180,6 @@ export function writeMailboxMessage( return message; } - // ── Read ───────────────────────────────────────────────────────────── /** @@ -208,7 +216,7 @@ export function readInbox( } // Filter: only *.msg.json files (excludes .msg.json.tmp, .tmp, etc.) - const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); + const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); const results: Array<{ filename: string; message: MailboxMessage }> = []; @@ -228,17 +236,13 @@ export function readInbox( try { parsed = JSON.parse(raw); } catch { - process.stderr.write( - `[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`, - ); + process.stderr.write(`[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`); continue; } // Validate shape if (!isValidMailboxMessage(parsed)) { - process.stderr.write( - `[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`, - ); + process.stderr.write(`[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`); continue; } @@ -265,7 +269,6 @@ export function readInbox( return results; } - // ── Acknowledge ────────────────────────────────────────────────────── /** @@ -312,7 +315,6 @@ export function ackMessage(inboxDir: string, filename: string): boolean { } } - // ── Validation ─────────────────────────────────────────────────────── /** @@ -334,13 +336,14 @@ export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage { typeof m.batchId === "string" && typeof m.from === "string" && typeof m.to === "string" && - typeof m.timestamp === "number" && Number.isFinite(m.timestamp) && - typeof m.type === "string" && MAILBOX_MESSAGE_TYPES.has(m.type) && + typeof m.timestamp === "number" && + Number.isFinite(m.timestamp) && + typeof m.type === "string" && + MAILBOX_MESSAGE_TYPES.has(m.type) && typeof m.content === "string" ); } - // ── Outbox (Agent → Supervisor, TP-106) ───────────────────────── /** @@ -413,8 +416,14 @@ export function writeOutboxMessage( writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8"); renameSync(tempPath, finalPath); } catch (err) { - try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* cleanup */ } - throw new Error(`Failed to write outbox message: ${err instanceof Error ? err.message : String(err)}`); + try { + if (existsSync(tempPath)) unlinkSync(tempPath); + } catch { + /* cleanup */ + } + throw new Error( + `Failed to write outbox message: ${err instanceof Error ? err.message : String(err)}`, + ); } return message; @@ -430,11 +439,7 @@ export function writeOutboxMessage( * * @since TP-106 */ -export function readOutbox( - stateRoot: string, - batchId: string, - agentId: string, -): MailboxMessage[] { +export function readOutbox(stateRoot: string, batchId: string, agentId: string): MailboxMessage[] { const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId); if (!existsSync(outboxDir)) return []; @@ -445,7 +450,7 @@ export function readOutbox( return []; } - const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); + const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); const messages: MailboxMessage[] = []; for (const filename of msgFiles) { @@ -455,7 +460,9 @@ export function readOutbox( if (isValidMailboxMessage(parsed)) { messages.push(parsed); } - } catch { /* skip malformed */ } + } catch { + /* skip malformed */ + } } messages.sort((a, b) => a.timestamp - b.timestamp); @@ -484,12 +491,19 @@ export function readOutboxHistory( const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId); const results: Array<{ message: MailboxMessage; acked: boolean }> = []; - for (const [dir, acked] of [[outboxDir, false], [join(outboxDir, "processed"), true]] as const) { + for (const [dir, acked] of [ + [outboxDir, false], + [join(outboxDir, "processed"), true], + ] as const) { if (!existsSync(dir)) continue; let entries: string[]; - try { entries = readdirSync(dir); } catch { continue; } + try { + entries = readdirSync(dir); + } catch { + continue; + } - const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); + const msgFiles = entries.filter((f) => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")); for (const filename of msgFiles) { try { const raw = readFileSync(join(dir, filename), "utf-8"); @@ -497,7 +511,9 @@ export function readOutboxHistory( if (isValidMailboxMessage(parsed)) { results.push({ message: parsed, acked }); } - } catch { /* skip malformed */ } + } catch { + /* skip malformed */ + } } } @@ -557,11 +573,7 @@ export function ackOutboxMessage( * * @since TP-187 (#538) */ -export function drainAgentOutbox( - stateRoot: string, - batchId: string, - agentId: string, -): number { +export function drainAgentOutbox(stateRoot: string, batchId: string, agentId: string): number { const outboxDir = sessionOutboxDir(stateRoot, batchId, agentId); if (!existsSync(outboxDir)) return 0; @@ -587,7 +599,11 @@ export function drainAgentOutbox( if (entry.endsWith(".msg.json")) { if (!processedDirEnsured) { - try { mkdirSync(processedDir, { recursive: true }); } catch { /* fall through to rename error handling */ } + try { + mkdirSync(processedDir, { recursive: true }); + } catch { + /* fall through to rename error handling */ + } processedDirEnsured = true; } const dstPath = join(processedDir, entry); @@ -632,23 +648,17 @@ export function drainAgentOutbox( * * @since TP-091 */ -export function discoverMailboxAgentIds( - stateRoot: string, - batchId: string, -): string[] { +export function discoverMailboxAgentIds(stateRoot: string, batchId: string): string[] { const mbRoot = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId); if (!existsSync(mbRoot)) return []; try { const entries = readdirSync(mbRoot, { withFileTypes: true }); - return entries - .filter(e => e.isDirectory() && e.name !== "_broadcast") - .map(e => e.name); + return entries.filter((e) => e.isDirectory() && e.name !== "_broadcast").map((e) => e.name); } catch { return []; } } - export type MailboxAuditEventType = | "message_sent" | "message_delivered" @@ -694,7 +704,6 @@ export function appendMailboxAuditEvent( } } - // ── Broadcast (TP-106) ──────────────────────────────────────── /** @@ -721,7 +730,6 @@ export function writeBroadcastMessage( }); } - // ── Rate Limiting (TP-106) ───────────────────────────────────── /** Default rate limit: max 1 message per agent per 30 seconds. */ diff --git a/extensions/taskplane/merge.ts b/extensions/taskplane/merge.ts index 91e061c2..c9b60917 100644 --- a/extensions/taskplane/merge.ts +++ b/extensions/taskplane/merge.ts @@ -2,31 +2,89 @@ * Merge orchestration, merge agents, merge worktree * @module orch/merge */ -import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync, readdirSync, type Dirent } from "fs"; +import { + readFileSync, + writeFileSync, + existsSync, + unlinkSync, + copyFileSync, + mkdirSync, + rmSync, + readdirSync, + type Dirent, +} from "fs"; import { readFile as fsReadFile } from "fs/promises"; import { execSync, spawnSync } from "child_process"; import { join, dirname, resolve, relative } from "path"; import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts"; import { resolveOperatorId } from "./naming.ts"; -import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts"; -import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase, RuntimeMergeSnapshot, RuntimeAgentTelemetrySnapshot } from "./types.ts"; +import { + MERGE_POLL_INTERVAL_MS, + MERGE_RESULT_GRACE_MS, + MERGE_RESULT_READ_RETRIES, + MERGE_RESULT_READ_RETRY_DELAY_MS, + MERGE_SPAWN_RETRY_MAX, + MERGE_TIMEOUT_MAX_RETRIES, + MERGE_TIMEOUT_MS, + MERGE_HEALTH_POLL_INTERVAL_MS, + MERGE_HEALTH_WARNING_THRESHOLD_MS, + MERGE_HEALTH_STUCK_THRESHOLD_MS, + MergeError, + VALID_MERGE_STATUSES, + buildEngineEventBase, +} from "./types.ts"; +import type { + AllocatedLane, + LaneExecutionResult, + MergeLaneResult, + MergeResult, + MergeResultStatus, + MergeWaveResult, + OrchestratorConfig, + RepoMergeOutcome, + TaskRunnerConfig, + TransactionRecord, + TransactionStatus, + VerificationBaselineResult, + WaveExecutionResult, + WorkspaceConfig, + MergeHealthStatus, + MergeHealthEventType, + MergeSessionSnapshot, + MergeSessionHealthState, + EngineEvent, + OrchBatchPhase, + RuntimeMergeSnapshot, + RuntimeAgentTelemetrySnapshot, +} from "./types.ts"; import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts"; -import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot, writeMergeSnapshot } from "./process-registry.ts"; +import { + readManifest, + writeManifest, + buildRegistrySnapshot, + writeRegistrySnapshot, + readRegistrySnapshot, + writeMergeSnapshot, +} from "./process-registry.ts"; import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts"; import { getCurrentBranch, runGit } from "./git.ts"; import { ORCH_MESSAGES } from "./messages.ts"; import { emitEngineEvent } from "./persistence.ts"; import { loadOrchestratorConfig } from "./config.ts"; -import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts"; +import { + captureBaseline, + diffFingerprints, + runVerificationCommands, + parseTestOutput, + deduplicateFingerprints, +} from "./verification.ts"; import { spawnAgent } from "./agent-host.ts"; import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts"; import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts"; import type { RuntimeBackend } from "./execution.ts"; import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts"; - - // ── Merge Implementation ───────────────────────────────────────────── /** @@ -47,10 +105,7 @@ import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./v */ export function parseMergeResult(resultPath: string): MergeResult { if (!existsSync(resultPath)) { - throw new MergeError( - "MERGE_RESULT_INVALID", - `Merge result file not found: ${resultPath}`, - ); + throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`); } const pickString = (obj: Record, ...keys: string[]): string | null => { @@ -64,50 +119,55 @@ export function parseMergeResult(resultPath: string): MergeResult { }; const hasFlatVerification = (obj: Record): boolean => - typeof obj.verification_passed === "boolean" - || Array.isArray(obj.verification_commands) - || typeof obj.verification_output === "string" - || typeof obj.verification_exit_code === "number"; - - const normalizeVerification = (obj: Record): MergeResult["verification"] | null => { - const nested = (obj.verification && typeof obj.verification === "object") - ? obj.verification as Record - : null; + typeof obj.verification_passed === "boolean" || + Array.isArray(obj.verification_commands) || + typeof obj.verification_output === "string" || + typeof obj.verification_exit_code === "number"; + + const normalizeVerification = ( + obj: Record, + ): MergeResult["verification"] | null => { + const nested = + obj.verification && typeof obj.verification === "object" + ? (obj.verification as Record) + : null; if (!nested && !hasFlatVerification(obj)) { return null; } const passedFromBool = - (nested && typeof nested.passed === "boolean" ? nested.passed : undefined) - ?? (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) - ?? (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined); + (nested && typeof nested.passed === "boolean" ? nested.passed : undefined) ?? + (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) ?? + (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined); const exitCode = - (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) - ?? (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) - ?? (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined); - - const passed = typeof passedFromBool === "boolean" - ? passedFromBool - : (typeof exitCode === "number" ? exitCode === 0 : false); - - const ran = (nested && typeof nested.ran === "boolean") - ? nested.ran - : ( - typeof passedFromBool === "boolean" - || typeof exitCode === "number" - || (nested && typeof nested.command === "string") - || (nested && typeof nested.summary === "string") - || typeof obj.verification_output === "string" - || Array.isArray(obj.verification_commands) - ); + (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) ?? + (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) ?? + (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined); + + const passed = + typeof passedFromBool === "boolean" + ? passedFromBool + : typeof exitCode === "number" + ? exitCode === 0 + : false; + + const ran = + nested && typeof nested.ran === "boolean" + ? nested.ran + : typeof passedFromBool === "boolean" || + typeof exitCode === "number" || + (nested && typeof nested.command === "string") || + (nested && typeof nested.summary === "string") || + typeof obj.verification_output === "string" || + Array.isArray(obj.verification_commands); const output = ( - (nested && typeof nested.output === "string" ? nested.output : undefined) - ?? (nested && typeof nested.summary === "string" ? nested.summary : undefined) - ?? (nested && typeof nested.notes === "string" ? nested.notes : undefined) - ?? (typeof obj.verification_output === "string" ? obj.verification_output : "") + (nested && typeof nested.output === "string" ? nested.output : undefined) ?? + (nested && typeof nested.summary === "string" ? nested.summary : undefined) ?? + (nested && typeof nested.notes === "string" ? nested.notes : undefined) ?? + (typeof obj.verification_output === "string" ? obj.verification_output : "") ).slice(0, 2000); return { ran, passed, output }; @@ -163,9 +223,14 @@ export function parseMergeResult(resultPath: string): MergeResult { // Validate status value if (!VALID_MERGE_STATUSES.has(parsed.status)) { - execLog("merge", "parse", `unknown merge status "${parsed.status}" — treating as BUILD_FAILURE`, { - resultPath, - }); + execLog( + "merge", + "parse", + `unknown merge status "${parsed.status}" — treating as BUILD_FAILURE`, + { + resultPath, + }, + ); parsed.status = "BUILD_FAILURE"; } @@ -173,19 +238,20 @@ export function parseMergeResult(resultPath: string): MergeResult { const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? ""; const conflicts = Array.isArray(parsed.conflicts) ? parsed.conflicts - .filter((c): c is { file: string; type: string; resolved: boolean; resolution?: string } => ( - typeof c === "object" - && c !== null - && typeof (c as { file?: unknown }).file === "string" - && typeof (c as { type?: unknown }).type === "string" - && typeof (c as { resolved?: unknown }).resolved === "boolean" - )) - .map(c => ({ - file: c.file, - type: c.type, - resolved: c.resolved, - ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}), - })) + .filter( + (c): c is { file: string; type: string; resolved: boolean; resolution?: string } => + typeof c === "object" && + c !== null && + typeof (c as { file?: unknown }).file === "string" && + typeof (c as { type?: unknown }).type === "string" && + typeof (c as { resolved?: unknown }).resolved === "boolean", + ) + .map((c) => ({ + file: c.file, + type: c.type, + resolved: c.resolved, + ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}), + })) : []; // Normalize optional fields with defaults @@ -212,7 +278,7 @@ export function parseMergeResult(resultPath: string): MergeResult { throw new MergeError( "MERGE_RESULT_INVALID", `Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` + - `Last error: ${lastParseError}. File: ${resultPath}`, + `Last error: ${lastParseError}. File: ${resultPath}`, ); } @@ -232,10 +298,7 @@ export function parseMergeResult(resultPath: string): MergeResult { */ export async function parseMergeResultAsync(resultPath: string): Promise { if (!existsSync(resultPath)) { - throw new MergeError( - "MERGE_RESULT_INVALID", - `Merge result file not found: ${resultPath}`, - ); + throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`); } const pickString = (obj: Record, ...keys: string[]): string | null => { @@ -249,50 +312,55 @@ export async function parseMergeResultAsync(resultPath: string): Promise): boolean => - typeof obj.verification_passed === "boolean" - || Array.isArray(obj.verification_commands) - || typeof obj.verification_output === "string" - || typeof obj.verification_exit_code === "number"; - - const normalizeVerification = (obj: Record): MergeResult["verification"] | null => { - const nested = (obj.verification && typeof obj.verification === "object") - ? obj.verification as Record - : null; + typeof obj.verification_passed === "boolean" || + Array.isArray(obj.verification_commands) || + typeof obj.verification_output === "string" || + typeof obj.verification_exit_code === "number"; + + const normalizeVerification = ( + obj: Record, + ): MergeResult["verification"] | null => { + const nested = + obj.verification && typeof obj.verification === "object" + ? (obj.verification as Record) + : null; if (!nested && !hasFlatVerification(obj)) { return null; } const passedFromBool = - (nested && typeof nested.passed === "boolean" ? nested.passed : undefined) - ?? (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) - ?? (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined); + (nested && typeof nested.passed === "boolean" ? nested.passed : undefined) ?? + (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) ?? + (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined); const exitCode = - (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) - ?? (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) - ?? (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined); - - const passed = typeof passedFromBool === "boolean" - ? passedFromBool - : (typeof exitCode === "number" ? exitCode === 0 : false); - - const ran = (nested && typeof nested.ran === "boolean") - ? nested.ran - : ( - typeof passedFromBool === "boolean" - || typeof exitCode === "number" - || (nested && typeof nested.command === "string") - || (nested && typeof nested.summary === "string") - || typeof obj.verification_output === "string" - || Array.isArray(obj.verification_commands) - ); + (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) ?? + (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) ?? + (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined); + + const passed = + typeof passedFromBool === "boolean" + ? passedFromBool + : typeof exitCode === "number" + ? exitCode === 0 + : false; + + const ran = + nested && typeof nested.ran === "boolean" + ? nested.ran + : typeof passedFromBool === "boolean" || + typeof exitCode === "number" || + (nested && typeof nested.command === "string") || + (nested && typeof nested.summary === "string") || + typeof obj.verification_output === "string" || + Array.isArray(obj.verification_commands); const output = ( - (nested && typeof nested.output === "string" ? nested.output : undefined) - ?? (nested && typeof nested.summary === "string" ? nested.summary : undefined) - ?? (nested && typeof nested.notes === "string" ? nested.notes : undefined) - ?? (typeof obj.verification_output === "string" ? obj.verification_output : "") + (nested && typeof nested.output === "string" ? nested.output : undefined) ?? + (nested && typeof nested.summary === "string" ? nested.summary : undefined) ?? + (nested && typeof nested.notes === "string" ? nested.notes : undefined) ?? + (typeof obj.verification_output === "string" ? obj.verification_output : "") ).slice(0, 2000); return { ran, passed, output }; @@ -345,9 +413,14 @@ export async function parseMergeResultAsync(resultPath: string): Promise ( - typeof c === "object" - && c !== null - && typeof (c as { file?: unknown }).file === "string" - && typeof (c as { type?: unknown }).type === "string" - && typeof (c as { resolved?: unknown }).resolved === "boolean" - )) - .map(c => ({ - file: c.file, - type: c.type, - resolved: c.resolved, - ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}), - })) + .filter( + (c): c is { file: string; type: string; resolved: boolean; resolution?: string } => + typeof c === "object" && + c !== null && + typeof (c as { file?: unknown }).file === "string" && + typeof (c as { type?: unknown }).type === "string" && + typeof (c as { resolved?: unknown }).resolved === "boolean", + ) + .map((c) => ({ + file: c.file, + type: c.type, + resolved: c.resolved, + ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}), + })) : []; return { @@ -392,7 +466,7 @@ export async function parseMergeResultAsync(resultPath: string): Promise l.laneNumber).join(","), + lanes: lanes.map((l) => l.laneNumber).join(","), }); } else { execLog("merge", `W${waveIndex}`, `failed to commit skipped-task artifacts`, { @@ -511,12 +586,16 @@ function stageSkippedArtifactsToTargetBranch( // Clean up the temporary worktree try { spawnSync("git", ["worktree", "remove", "--force", resolvedTmpPath], { cwd: repoRoot }); - } catch { /* best effort cleanup */ } + } catch { + /* best effort cleanup */ + } try { if (existsSync(resolvedTmpPath)) { rmSync(resolvedTmpPath, { recursive: true, force: true }); } - } catch { /* best effort cleanup */ } + } catch { + /* best effort cleanup */ + } } } @@ -584,10 +663,10 @@ export function buildMergeRequest( verifyCommands: string[], resultFilePath: string, ): string { - const taskIds = lane.tasks.map(t => t.taskId).join(", "); + const taskIds = lane.tasks.map((t) => t.taskId).join(", "); // TP-169: Guard against null task stubs from reconstructAllocatedLanes const fileScopes = lane.tasks - .flatMap(t => t.task?.fileScope || []) + .flatMap((t) => t.task?.fileScope || []) .filter((f, i, arr) => arr.indexOf(f) === i); // deduplicate const mergeMessage = `merge: wave ${waveIndex} lane ${lane.laneNumber} — ${taskIds}`; @@ -605,15 +684,13 @@ export function buildMergeRequest( `${mergeMessage}`, "", `## Tasks Completed`, - ...lane.tasks.map(t => `- ${t.taskId}: ${t.task?.taskName ?? "(unknown)"}`), + ...lane.tasks.map((t) => `- ${t.taskId}: ${t.task?.taskName ?? "(unknown)"}`), "", `## File Scope`, - ...(fileScopes.length > 0 - ? fileScopes.map(f => `- ${f}`) - : ["- (no file scope declared)"]), + ...(fileScopes.length > 0 ? fileScopes.map((f) => `- ${f}`) : ["- (no file scope declared)"]), "", `## Verification Commands`, - ...verifyCommands.map(cmd => `\`\`\`bash\n${cmd}\n\`\`\``), + ...verifyCommands.map((cmd) => `\`\`\`bash\n${cmd}\n\`\`\``), "", `## Result File`, `result_file: ${resultFilePath.split("\\").join("/")}`, @@ -624,12 +701,12 @@ export function buildMergeRequest( "", "```json", "{", - " \"status\": \"SUCCESS\" | \"CONFLICT_RESOLVED\" | \"CONFLICT_UNRESOLVED\" | \"BUILD_FAILURE\",", - " \"source_branch\": \"\",", - " \"target_branch\": \"\",", - " \"merge_commit\": \"\",", - " \"conflicts\": [{ \"file\": \"...\", \"type\": \"...\", \"resolved\": true|false }],", - " \"verification\": { \"ran\": true|false, \"passed\": true|false, \"output\": \"...\" }", + ' "status": "SUCCESS" | "CONFLICT_RESOLVED" | "CONFLICT_UNRESOLVED" | "BUILD_FAILURE",', + ' "source_branch": "",', + ' "target_branch": "",', + ' "merge_commit": "",', + ' "conflicts": [{ "file": "...", "type": "...", "resolved": true|false }],', + ' "verification": { "ran": true|false, "passed": true|false, "output": "..." }', "}", "```", "", @@ -648,8 +725,6 @@ export function buildMergeRequest( return lines.join("\n"); } - - /** * Spawn a merge agent via Runtime V2 direct agent-host (no terminal multiplexer). * @@ -698,17 +773,28 @@ export async function spawnMergeAgentV2( agentRoot ? join(agentRoot, "task-merger.md") : "", join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"), ].filter(Boolean); - const systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || ""; + const systemPromptPath = systemPromptCandidates.find((p) => existsSync(p)) || ""; let systemPrompt: string | undefined; if (systemPromptPath) { - try { systemPrompt = readFileSync(systemPromptPath, "utf-8"); } catch { /* use default */ } + try { + systemPrompt = readFileSync(systemPromptPath, "utf-8"); + } catch { + /* use default */ + } } // Resolve event/exit paths const sidecarRoot = join(stateRoot ?? repoRoot, ".pi"); const bid = batchId || "unknown"; const eventsPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "events.jsonl"); - const exitSummaryPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "exit-summary.json"); + const exitSummaryPath = join( + sidecarRoot, + "runtime", + bid, + "agents", + sessionName, + "exit-summary.json", + ); // Mailbox directory let mailboxDir: string | null = null; @@ -752,16 +838,24 @@ export async function spawnMergeAgentV2( // (e.g. "orch-henry-merge-1" → 1, "orch-henry-merge-2" → 2). const mergeNumberMatch = sessionName.match(/-merge-(\d+)$/); if (!mergeNumberMatch) { - execLog("merge", sessionName, "warning: could not parse merge number from session name — defaulting to 1", { sessionName }); + execLog( + "merge", + sessionName, + "warning: could not parse merge number from session name — defaulting to 1", + { sessionName }, + ); } const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1; const mergeStartedAt = Date.now(); // Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult. - const buildAgentSnap = (tel: Partial, status: RuntimeAgentTelemetrySnapshot["status"]): RuntimeAgentTelemetrySnapshot => ({ + const buildAgentSnap = ( + tel: Partial, + status: RuntimeAgentTelemetrySnapshot["status"], + ): RuntimeAgentTelemetrySnapshot => ({ agentId: sessionName, status, - elapsedMs: tel.durationMs ?? (Date.now() - mergeStartedAt), + elapsedMs: tel.durationMs ?? Date.now() - mergeStartedAt, toolCalls: tel.toolCalls ?? 0, contextPct: tel.contextUsage?.percent ?? 0, costUsd: tel.costUsd ?? 0, @@ -786,7 +880,9 @@ export async function spawnMergeAgentV2( updatedAt: Date.now(), }; writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } }; const { promise, kill } = spawnAgent(opts, undefined, onMergeTelemetry); @@ -804,7 +900,9 @@ export async function spawnMergeAgentV2( updatedAt: Date.now(), }; writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // Store the kill handle for external cleanup (pause/abort). // The promise runs in background — caller uses waitForMergeResult() @@ -813,65 +911,78 @@ export async function spawnMergeAgentV2( // Fire-and-forget: the background promise handles exit logging and // writes a terminal snapshot ("complete" or "failed") when the agent exits. - promise.then(result => { - activeMergeAgents.delete(sessionName); - execLog("merge", sessionName, "merge agent exited (V2)", { - exitCode: result.exitCode, - durationMs: result.durationMs, - costUsd: result.costUsd, - killed: result.killed, - }); - // Write terminal snapshot. Promise resolves for both successful and - // failed exits, so derive status from result fields rather than - // relying on .catch to handle failures. - // Determine terminal status. A clean post-success kill sets registry - // manifest to "exited" via killMergeAgentV2(name, true=cleanExit). - // Check the registry first so a successful-then-killed agent is shown - // as "complete" rather than "failed". - let terminalStatus: RuntimeMergeSnapshot["status"] = "complete"; - try { - const manifest = readManifest(mergeStateRoot, bid, sessionName as any); - if (manifest?.status === "exited") { - terminalStatus = "complete"; - } else if (result.exitCode !== 0 || !result.agentEnded) { - terminalStatus = "failed"; + promise + .then((result) => { + activeMergeAgents.delete(sessionName); + execLog("merge", sessionName, "merge agent exited (V2)", { + exitCode: result.exitCode, + durationMs: result.durationMs, + costUsd: result.costUsd, + killed: result.killed, + }); + // Write terminal snapshot. Promise resolves for both successful and + // failed exits, so derive status from result fields rather than + // relying on .catch to handle failures. + // Determine terminal status. A clean post-success kill sets registry + // manifest to "exited" via killMergeAgentV2(name, true=cleanExit). + // Check the registry first so a successful-then-killed agent is shown + // as "complete" rather than "failed". + let terminalStatus: RuntimeMergeSnapshot["status"] = "complete"; + try { + const manifest = readManifest(mergeStateRoot, bid, sessionName as any); + if (manifest?.status === "exited") { + terminalStatus = "complete"; + } else if (result.exitCode !== 0 || !result.agentEnded) { + terminalStatus = "failed"; + } + } catch { + if (result.exitCode !== 0 || !result.agentEnded) terminalStatus = "failed"; } - } catch { - if (result.exitCode !== 0 || !result.agentEnded) terminalStatus = "failed"; - } - try { - const snap: RuntimeMergeSnapshot = { - batchId: bid, - mergeNumber, - sessionName, - waveIndex: waveIndex ?? 0, - status: terminalStatus, - agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"), - updatedAt: Date.now(), - }; - writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap); - } catch { /* non-fatal */ } - }).catch(err => { - activeMergeAgents.delete(sessionName); - execLog("merge", sessionName, `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`); - // Write a failed terminal snapshot on unexpected rejection. - try { - const snap: RuntimeMergeSnapshot = { - batchId: bid, - mergeNumber, + try { + const snap: RuntimeMergeSnapshot = { + batchId: bid, + mergeNumber, + sessionName, + waveIndex: waveIndex ?? 0, + status: terminalStatus, + agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"), + updatedAt: Date.now(), + }; + writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap); + } catch { + /* non-fatal */ + } + }) + .catch((err) => { + activeMergeAgents.delete(sessionName); + execLog( + "merge", sessionName, - waveIndex: waveIndex ?? 0, - status: "failed", - agent: buildAgentSnap({}, "crashed"), - updatedAt: Date.now(), - }; - writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap); - } catch { /* non-fatal */ } - }); + `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`, + ); + // Write a failed terminal snapshot on unexpected rejection. + try { + const snap: RuntimeMergeSnapshot = { + batchId: bid, + mergeNumber, + sessionName, + waveIndex: waveIndex ?? 0, + status: "failed", + agent: buildAgentSnap({}, "crashed"), + updatedAt: Date.now(), + }; + writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap); + } catch { + /* non-fatal */ + } + }); } /** Active V2 merge agent handles for cleanup/abort. @since TP-108 */ -const activeMergeAgents = new Map; kill: () => void; stateRoot?: string; batchId?: string }>(); +const activeMergeAgents = new Map< + string, + { promise: Promise; kill: () => void; stateRoot?: string; batchId?: string } +>(); /** * Kill a V2 merge agent if it's still running. @@ -893,7 +1004,9 @@ export function killMergeAgentV2(sessionName: string, cleanExit?: boolean): bool const snapshot = buildRegistrySnapshot(handle.stateRoot, handle.batchId); writeRegistrySnapshot(handle.stateRoot, snapshot); } - } catch { /* best effort */ } + } catch { + /* best effort */ + } } activeMergeAgents.delete(sessionName); return true; @@ -937,7 +1050,11 @@ export function reloadMergeTimeoutMs(configRoot: string, pointerConfigRoot?: str } catch (err: unknown) { // Config re-read is best-effort — fall back to default on failure const errMsg = err instanceof Error ? err.message : String(err); - execLog("merge", "config-reload", `failed to re-read merge timeout from config: ${errMsg} — using default`); + execLog( + "merge", + "config-reload", + `failed to re-read merge timeout from config: ${errMsg} — using default`, + ); return MERGE_TIMEOUT_MS; } } @@ -989,11 +1106,16 @@ export async function waitForMergeResult( try { const lateResult = await parseMergeResultAsync(resultPath); if (SUCCESSFUL_MERGE_STATUSES.has(lateResult.status)) { - execLog("merge", sessionName, "merge agent slow but succeeded — accepting result at timeout", { - status: lateResult.status, - elapsed, - timeoutMs, - }); + execLog( + "merge", + sessionName, + "merge agent slow but succeeded — accepting result at timeout", + { + status: lateResult.status, + elapsed, + timeoutMs, + }, + ); // Clean up agent (may still be running post-write) killMergeAgentV2(sessionName, true); return lateResult; @@ -1012,8 +1134,8 @@ export async function waitForMergeResult( throw new MergeError( "MERGE_TIMEOUT", `Merge agent '${sessionName}' did not produce a result within ` + - `${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` + - `Check the merge request and agent logs.`, + `${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` + + `Check the merge request and agent logs.`, ); } @@ -1032,7 +1154,11 @@ export async function waitForMergeResult( if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") { await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS); if (existsSync(resultPath)) { - try { return await parseMergeResultAsync(resultPath); } catch { /* give up */ } + try { + return await parseMergeResultAsync(resultPath); + } catch { + /* give up */ + } } } } @@ -1051,14 +1177,18 @@ export async function waitForMergeResult( } else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) { // Grace period expired — one final check if (existsSync(resultPath)) { - try { return await parseMergeResultAsync(resultPath); } catch { /* fall through */ } + try { + return await parseMergeResultAsync(resultPath); + } catch { + /* fall through */ + } } throw new MergeError( "MERGE_SESSION_DIED", `Merge agent '${sessionName}' exited without writing ` + - `a result file to '${resultPath}'. The merge may have crashed. ` + - `Check agent logs for diagnostics.`, + `a result file to '${resultPath}'. The merge may have crashed. ` + + `Check agent logs for diagnostics.`, ); } } @@ -1081,25 +1211,28 @@ export async function waitForMergeResult( * @param repoRoot - Main repository root for git operations * @param context - Logging context (e.g., "W1" for wave 1) */ -function forceRemoveMergeWorktree( - mergeWorkDir: string, - repoRoot: string, - context: string, -): void { +function forceRemoveMergeWorktree(mergeWorkDir: string, repoRoot: string, context: string): void { if (!existsSync(mergeWorkDir)) return; // Try git worktree remove --force first - const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot }); + const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { + cwd: repoRoot, + }); if (removeResult.status === 0) { return; } // Fallback: force-remove the directory and prune git worktree state const stderr = removeResult.stderr?.toString().trim() || ""; - execLog("merge", context, `git worktree remove failed for merge worktree, applying force cleanup`, { - error: stderr.slice(0, 200), - path: mergeWorkDir, - }); + execLog( + "merge", + context, + `git worktree remove failed for merge worktree, applying force cleanup`, + { + error: stderr.slice(0, 200), + path: mergeWorkDir, + }, + ); try { rmSync(mergeWorkDir, { recursive: true, force: true }); @@ -1107,14 +1240,18 @@ function forceRemoveMergeWorktree( } catch (rmErr: unknown) { // Node's rmSync may fail on Windows reserved-name files — try OS-level removal const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr); - execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, { error: rmMsg }); + execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, { + error: rmMsg, + }); try { if (process.platform === "win32") { execSync(`rd /s /q "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 }); } else { execSync(`rm -rf "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 }); } - execLog("merge", context, `OS-level removal of merge worktree succeeded`, { path: mergeWorkDir }); + execLog("merge", context, `OS-level removal of merge worktree succeeded`, { + path: mergeWorkDir, + }); } catch (osErr: unknown) { const osMsg = osErr instanceof Error ? osErr.message : String(osErr); execLog("merge", context, `OS-level removal also failed — manual cleanup needed`, { @@ -1149,17 +1286,11 @@ function forceRemoveMergeWorktree( */ function persistTransactionRecord(record: TransactionRecord, stateRoot: string): string | null { try { - const repoSlug = record.repoId - ? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_") - : "default"; + const repoSlug = record.repoId ? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_") : "default"; const verifyDir = join(stateRoot, ".pi", "verification", record.opId); mkdirSync(verifyDir, { recursive: true }); const fileName = `txn-b${record.batchId}-repo-${repoSlug}-wave-${record.waveIndex}-lane-${record.laneNumber}.json`; - writeFileSync( - join(verifyDir, fileName), - JSON.stringify(record, null, 2), - "utf-8", - ); + writeFileSync(join(verifyDir, fileName), JSON.stringify(record, null, 2), "utf-8"); execLog("merge", `W${record.waveIndex}`, `transaction record persisted`, { file: fileName, status: record.status, @@ -1229,11 +1360,7 @@ function runPostMergeVerification( // when mergeWaveByRepo() calls mergeWave() once per repo group. const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : ""; const postFileName = `post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`; - writeFileSync( - join(verifyDir, postFileName), - JSON.stringify(postMerge, null, 2), - "utf-8", - ); + writeFileSync(join(verifyDir, postFileName), JSON.stringify(postMerge, null, 2), "utf-8"); } catch { // Best effort — persistence failure doesn't block verification } @@ -1264,7 +1391,7 @@ function runPostMergeVerification( // Only when flakyReruns > 0 (0 = disabled — any new failure immediately blocks) if (flakyReruns > 0) { // Identify which commandIds produced new failures - const failedCommandIds = new Set(diff.newFailures.map(fp => fp.commandId)); + const failedCommandIds = new Set(diff.newFailures.map((fp) => fp.commandId)); const rerunCommands: Record = {}; for (const cmdId of failedCommandIds) { if (testingCommands[cmdId]) { @@ -1275,10 +1402,15 @@ function runPostMergeVerification( // Re-run up to flakyReruns times; break early if failures clear let clearedOnRerun = false; for (let attempt = 0; attempt < flakyReruns; attempt++) { - execLog("merge", sessionName, `new failures detected — running flaky re-run ${attempt + 1}/${flakyReruns}`, { - failedCommands: [...failedCommandIds].join(", "), - rerunCount: Object.keys(rerunCommands).length, - }); + execLog( + "merge", + sessionName, + `new failures detected — running flaky re-run ${attempt + 1}/${flakyReruns}`, + { + failedCommands: [...failedCommandIds].join(", "), + rerunCount: Object.keys(rerunCommands).length, + }, + ); const rerunResults = runVerificationCommands(rerunCommands, mergeWorkDir); @@ -1292,12 +1424,18 @@ function runPostMergeVerification( // Re-diff: compare baseline against re-run results for the failed commands only // Filter baseline fingerprints to only the commands we re-ran - const baselineForRerun = baseline.fingerprints.filter(fp => failedCommandIds.has(fp.commandId)); + const baselineForRerun = baseline.fingerprints.filter((fp) => + failedCommandIds.has(fp.commandId), + ); const rerunDiff = diffFingerprints(baselineForRerun, dedupedRerun); if (rerunDiff.newFailures.length === 0) { // Failures disappeared on re-run — flaky suspected - execLog("merge", sessionName, `flaky re-run ${attempt + 1} cleared all new failures — classifying as flaky_suspected`); + execLog( + "merge", + sessionName, + `flaky re-run ${attempt + 1} cleared all new failures — classifying as flaky_suspected`, + ); clearedOnRerun = true; break; } @@ -1306,11 +1444,10 @@ function runPostMergeVerification( if (attempt === flakyReruns - 1) { const summary = rerunDiff.newFailures .slice(0, 5) - .map(fp => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`) + .map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`) .join("; "); - const truncated = rerunDiff.newFailures.length > 5 - ? ` ... and ${rerunDiff.newFailures.length - 5} more` - : ""; + const truncated = + rerunDiff.newFailures.length > 5 ? ` ... and ${rerunDiff.newFailures.length - 5} more` : ""; return { performed: true, @@ -1340,11 +1477,10 @@ function runPostMergeVerification( // flakyReruns === 0 or fallthrough: new failures block immediately const summary = diff.newFailures .slice(0, 5) - .map(fp => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`) + .map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`) .join("; "); - const truncated = diff.newFailures.length > 5 - ? ` ... and ${diff.newFailures.length - 5} more` - : ""; + const truncated = + diff.newFailures.length > 5 ? ` ... and ${diff.newFailures.length - 5} more` : ""; return { performed: true, @@ -1427,14 +1563,12 @@ export async function mergeWave( // TP-078: When forceMixedOutcome is true, lanes with both succeeded and // failed/stalled tasks are also considered mergeable. This allows the // orch_force_merge tool to merge succeeded commits from mixed-outcome lanes. - const mergeableLanes = completedLanes.filter(lane => { + const mergeableLanes = completedLanes.filter((lane) => { const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded"); - const hasHardFailure = outcome.tasks.some( - t => t.status === "failed" || t.status === "stalled", - ); + const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded"); + const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled"); if (forceMixedOutcome) { // In force mode, merge any lane with at least one succeeded task @@ -1449,11 +1583,11 @@ export async function mergeWave( // partial progress (STATUS.md updates) that should be staged on the target // branch so it survives integration. Stage artifacts directly without // creating a full merge worktree. - const skippedOnlyLanes = completedLanes.filter(lane => { + const skippedOnlyLanes = completedLanes.filter((lane) => { if (!lane.worktreePath) return false; const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - return outcome.tasks.some(t => t.status === "skipped"); + return outcome.tasks.some((t) => t.status === "skipped"); }); if (skippedOnlyLanes.length > 0) { stageSkippedArtifactsToTargetBranch(skippedOnlyLanes, waveIndex, repoRoot, targetBranch); @@ -1477,21 +1611,22 @@ export async function mergeWave( // These lanes won't have their branches merged, but their task artifacts // (STATUS.md, .reviews) should still be staged so partial progress is preserved // through integration. Only lanes with worktree paths can contribute artifacts. - const mergeableLaneNumbers = new Set(mergeableLanes.map(l => l.laneNumber)); - const skippedArtifactLanes = completedLanes.filter(lane => { + const mergeableLaneNumbers = new Set(mergeableLanes.map((l) => l.laneNumber)); + const skippedArtifactLanes = completedLanes.filter((lane) => { if (mergeableLaneNumbers.has(lane.laneNumber)) return false; if (!lane.worktreePath) return false; const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - return outcome.tasks.some(t => t.status === "skipped"); + return outcome.tasks.some((t) => t.status === "skipped"); }); execLog("merge", `W${waveIndex}`, `merging ${orderedLanes.length} lane(s)`, { order: config.merge.order, - lanes: orderedLanes.map(l => l.laneNumber).join(","), - skippedArtifactLanes: skippedArtifactLanes.length > 0 - ? skippedArtifactLanes.map(l => l.laneNumber).join(",") - : undefined, + lanes: orderedLanes.map((l) => l.laneNumber).join(","), + skippedArtifactLanes: + skippedArtifactLanes.length > 0 + ? skippedArtifactLanes.map((l) => l.laneNumber).join(",") + : undefined, }); // ── Create isolated merge worktree ────────────────────────────── @@ -1513,7 +1648,9 @@ export async function mergeWave( } try { spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); - } catch { /* branch may not exist */ } + } catch { + /* branch may not exist */ + } // Create temp branch at target branch HEAD, then worktree const branchResult = spawnSync("git", ["branch", tempBranch, targetBranch], { cwd: repoRoot }); @@ -1521,20 +1658,28 @@ export async function mergeWave( const err = branchResult.stderr?.toString().trim() || "unknown error"; execLog("merge", `W${waveIndex}`, `failed to create temp branch: ${err}`); return { - waveIndex, status: "failed", laneResults: [], - failedLane: null, failureReason: `Failed to create merge temp branch: ${err}`, + waveIndex, + status: "failed", + laneResults: [], + failedLane: null, + failureReason: `Failed to create merge temp branch: ${err}`, totalDurationMs: Date.now() - startTime, }; } - const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { cwd: repoRoot }); + const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { + cwd: repoRoot, + }); if (wtResult.status !== 0) { const err = wtResult.stderr?.toString().trim() || "unknown error"; execLog("merge", `W${waveIndex}`, `failed to create merge worktree: ${err}`); spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); return { - waveIndex, status: "failed", laneResults: [], - failedLane: null, failureReason: `Failed to create merge worktree: ${err}`, + waveIndex, + status: "failed", + laneResults: [], + failedLane: null, + failureReason: `Failed to create merge worktree: ${err}`, totalDurationMs: Date.now() - startTime, }; } @@ -1559,18 +1704,33 @@ export async function mergeWave( // Verification is enabled but no testing commands configured — treat as // baseline-unavailable. Strict/permissive handling below. if (verificationMode === "strict") { - execLog("merge", `W${waveIndex}`, "verification enabled but no testing commands configured — strict mode: failing merge"); + execLog( + "merge", + `W${waveIndex}`, + "verification enabled but no testing commands configured — strict mode: failing merge", + ); // Clean up worktree and temp branch before returning failure forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`); - try { spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); } catch { /* best effort */ } + try { + spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); + } catch { + /* best effort */ + } return { - waveIndex, status: "failed", laneResults: [], + waveIndex, + status: "failed", + laneResults: [], failedLane: null, - failureReason: "Verification enabled (strict mode) but no testing commands configured in taskRunner.testing.commands", + failureReason: + "Verification enabled (strict mode) but no testing commands configured in taskRunner.testing.commands", totalDurationMs: Date.now() - startTime, }; } else { - execLog("merge", `W${waveIndex}`, "verification enabled but no testing commands configured — permissive mode: continuing without verification"); + execLog( + "merge", + `W${waveIndex}`, + "verification enabled but no testing commands configured — permissive mode: continuing without verification", + ); } } @@ -1591,11 +1751,7 @@ export async function mergeWave( // when mergeWaveByRepo() calls mergeWave() once per repo group. const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : ""; const baselineFileName = `baseline-b${batchId}-w${waveIndex}${repoSuffix}.json`; - writeFileSync( - join(verifyDir, baselineFileName), - JSON.stringify(baseline, null, 2), - "utf-8", - ); + writeFileSync(join(verifyDir, baselineFileName), JSON.stringify(baseline, null, 2), "utf-8"); execLog("merge", `W${waveIndex}`, "verification baseline captured", { fingerprints: baseline.fingerprints.length, @@ -1610,17 +1766,28 @@ export async function mergeWave( }); // Clean up worktree and temp branch before returning failure forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`); - try { spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); } catch { /* best effort */ } + try { + spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); + } catch { + /* best effort */ + } return { - waveIndex, status: "failed", laneResults: [], + waveIndex, + status: "failed", + laneResults: [], failedLane: null, failureReason: `Verification baseline capture failed (strict mode): ${errMsg}`, totalDurationMs: Date.now() - startTime, }; } - execLog("merge", `W${waveIndex}`, `baseline capture failed — permissive mode: continuing without baseline verification`, { - error: errMsg, - }); + execLog( + "merge", + `W${waveIndex}`, + `baseline capture failed — permissive mode: continuing without baseline verification`, + { + error: errMsg, + }, + ); // Permissive: baseline capture failure is non-fatal — merge proceeds without // orchestrator-side verification. Merge-agent verification (merge.verify) // still applies independently. @@ -1659,7 +1826,10 @@ export async function mergeWave( // This is the rollback target if verification detects new failures. let baseHEAD = ""; { - const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd: mergeWorkDir, encoding: "utf-8" }); + const headResult = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: mergeWorkDir, + encoding: "utf-8", + }); if (headResult.status === 0) { baseHEAD = headResult.stdout.trim(); } @@ -1668,7 +1838,10 @@ export async function mergeWave( // ── TP-033: Capture laneHEAD (source branch tip being merged in) ── let laneHEAD = ""; { - const laneRef = spawnSync("git", ["rev-parse", lane.branch], { cwd: repoRoot, encoding: "utf-8" }); + const laneRef = spawnSync("git", ["rev-parse", lane.branch], { + cwd: repoRoot, + encoding: "utf-8", + }); if (laneRef.status === 0) { laneHEAD = laneRef.stdout.trim(); } @@ -1730,28 +1903,62 @@ export async function mergeWave( // Apply 2× backoff: double the timeout for each retry attempt currentTimeoutMs = freshTimeoutMs * Math.pow(2, attempt); - execLog("merge", sessionName, `retry ${attempt}/${MERGE_TIMEOUT_MAX_RETRIES} after timeout — respawning merge agent`, { - newTimeoutMs: currentTimeoutMs, - newTimeoutMin: Math.round(currentTimeoutMs / 60_000), - attempt, - }); + execLog( + "merge", + sessionName, + `retry ${attempt}/${MERGE_TIMEOUT_MAX_RETRIES} after timeout — respawning merge agent`, + { + newTimeoutMs: currentTimeoutMs, + newTimeoutMin: Math.round(currentTimeoutMs / 60_000), + attempt, + }, + ); // Clean up stale result file from prior attempt if (existsSync(resultFilePath)) { - try { unlinkSync(resultFilePath); } catch { /* best effort */ } + try { + unlinkSync(resultFilePath); + } catch { + /* best effort */ + } } // Re-spawn merge agent for the retry. // Kill previous V2 agent handle to prevent orphan/duplicate. killMergeAgentV2(sessionName); - await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId, waveIndex); + await spawnMergeAgentV2( + sessionName, + repoRoot, + mergeWorkDir, + requestFilePath, + config, + stateRoot, + agentRoot, + batchId, + waveIndex, + ); } else { // First attempt: spawn merge agent (Runtime V2) - await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId, waveIndex); + await spawnMergeAgentV2( + sessionName, + repoRoot, + mergeWorkDir, + requestFilePath, + config, + stateRoot, + agentRoot, + batchId, + waveIndex, + ); } try { - mergeResult = await waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs, runtimeBackend); + mergeResult = await waitForMergeResult( + resultFilePath, + sessionName, + currentTimeoutMs, + runtimeBackend, + ); // TP-056: Deregister session from health monitor on completion if (healthMonitor) healthMonitor.removeSession(sessionName); lastTimeoutError = null; @@ -1820,11 +2027,12 @@ export async function mergeWave( case "CONFLICT_UNRESOLVED": execLog("merge", sessionName, "merge failed — unresolved conflicts", { conflictCount: mergeResult.conflicts.length, - files: mergeResult.conflicts.map(c => c.file).join(", "), + files: mergeResult.conflicts.map((c) => c.file).join(", "), }); failedLane = lane.laneNumber; - failureReason = `Unresolved merge conflicts in lane ${lane.laneNumber}: ` + - mergeResult.conflicts.map(c => c.file).join(", "); + failureReason = + `Unresolved merge conflicts in lane ${lane.laneNumber}: ` + + mergeResult.conflicts.map((c) => c.file).join(", "); break; case "BUILD_FAILURE": @@ -1838,7 +2046,8 @@ export async function mergeWave( baselineActive: !!baseline, }); failedLane = lane.laneNumber; - failureReason = `Post-merge verification failed in lane ${lane.laneNumber}: ` + + failureReason = + `Post-merge verification failed in lane ${lane.laneNumber}: ` + mergeResult.verification.output.slice(0, 500); break; } @@ -1846,7 +2055,10 @@ export async function mergeWave( // ── TP-033: Capture mergedHEAD after successful merge commit ── let mergedHEAD: string | null = null; if (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED") { - const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], { cwd: mergeWorkDir, encoding: "utf-8" }); + const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: mergeWorkDir, + encoding: "utf-8", + }); if (postMergeRef.status === 0) { mergedHEAD = postMergeRef.stdout.trim(); } @@ -1916,7 +2128,8 @@ export async function mergeWave( // ref advancement MUST NOT proceed for ANY lane, because the temp // branch HEAD includes the unverified commit. const resetErr = resetResult.stderr?.toString().trim() || "unknown error"; - laneResult.error = `verification_new_failure: rollback reset failed (${resetErr}) — ` + + laneResult.error = + `verification_new_failure: rollback reset failed (${resetErr}) — ` + `temp branch may contain failing merge commit, advancement blocked`; blockAdvancement = true; txnStatus = "rollback_failed"; @@ -1931,15 +2144,21 @@ export async function mergeWave( ]; rollbackFailed = true; - execLog("merge", sessionName, `CRITICAL: rollback reset failed: ${resetErr} — safe-stop triggered`, { - preLaneHead: preLaneHead.slice(0, 8), - recoveryCommands: txnRecoveryCommands, - }); + execLog( + "merge", + sessionName, + `CRITICAL: rollback reset failed: ${resetErr} — safe-stop triggered`, + { + preLaneHead: preLaneHead.slice(0, 8), + recoveryCommands: txnRecoveryCommands, + }, + ); } } else { // TP-032 R006-2: No pre-lane HEAD captured — cannot roll back. // Block advancement since the bad commit cannot be removed. - laneResult.error = `verification_new_failure: no pre-lane HEAD available for rollback — ` + + laneResult.error = + `verification_new_failure: no pre-lane HEAD available for rollback — ` + `advancement blocked`; blockAdvancement = true; txnStatus = "rollback_failed"; @@ -1957,18 +2176,28 @@ export async function mergeWave( ]; rollbackFailed = true; - execLog("merge", sessionName, "CRITICAL: no baseHEAD — cannot roll back, safe-stop triggered"); + execLog( + "merge", + sessionName, + "CRITICAL: no baseHEAD — cannot roll back, safe-stop triggered", + ); } failedLane = lane.laneNumber; - failureReason = `Verification baseline comparison detected ${verificationResult.newFailureCount} new failure(s) ` + + failureReason = + `Verification baseline comparison detected ${verificationResult.newFailureCount} new failure(s) ` + `in lane ${lane.laneNumber} (${verificationResult.preExistingCount} pre-existing). ` + verificationResult.newFailureSummary.slice(0, 300); } else if (verificationResult.classification === "flaky_suspected") { - execLog("merge", sessionName, "flaky test suspected — failures disappeared on re-run (warning only)", { - newFailures: verificationResult.newFailureCount, - flakyRerun: true, - }); + execLog( + "merge", + sessionName, + "flaky test suspected — failures disappeared on re-run (warning only)", + { + newFailures: verificationResult.newFailureCount, + flakyRerun: true, + }, + ); // Warning only — does not block merge advancement } else { execLog("merge", sessionName, "orchestrator-side verification passed", { @@ -2001,7 +2230,6 @@ export async function mergeWave( // Stop merging if this lane failed if (failedLane !== null) break; - } catch (err: unknown) { // Clean up request file on error try { @@ -2106,7 +2334,7 @@ export async function mergeWave( // because their code was not merged; staging .DONE would create false // completion markers on the orch branch. const SKIPPED_ARTIFACT_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"]; - const skippedArtifactLaneNumbers = new Set(skippedArtifactLanes.map(l => l.laneNumber)); + const skippedArtifactLaneNumbers = new Set(skippedArtifactLanes.map((l) => l.laneNumber)); // Include both merged lanes and skipped-artifact lanes in staging. const artifactStagingLanes = [...orderedLanes, ...skippedArtifactLanes]; @@ -2117,9 +2345,14 @@ export async function mergeWave( for (const allocTask of lane.tasks) { if (!allocTask.task?.taskFolder?.trim()) { - execLog("merge", `W${waveIndex}`, `skipping task with missing taskFolder (possibly dynamically expanded)`, { - taskId: allocTask.taskId, - }); + execLog( + "merge", + `W${waveIndex}`, + `skipping task with missing taskFolder (possibly dynamically expanded)`, + { + taskId: allocTask.taskId, + }, + ); continue; } const absFolder = resolve(allocTask.task.taskFolder); @@ -2190,7 +2423,10 @@ export async function mergeWave( const resolvedSrc = resolve(repoRootSrc); const srcRelToRepo = relative(resolvedRepoRoot, resolvedSrc).replace(/\\/g, "/"); if (srcRelToRepo.startsWith("..") || srcRelToRepo.startsWith("/")) { - execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, { path: relPath, src: repoRootSrc }); + execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, { + path: relPath, + src: repoRootSrc, + }); continue; } srcPath = repoRootSrc; @@ -2211,14 +2447,26 @@ export async function mergeWave( } if (staged > 0) { - spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json, .reviews/*)`], { cwd: mergeWorkDir }); + spawnSync( + "git", + [ + "commit", + "-m", + `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json, .reviews/*)`, + ], + { cwd: mergeWorkDir }, + ); execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, { skipped, preserved, allowedCandidates: allowedRelPaths.size, }); } else { - execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed, ${preserved} preserved from lane merge)`); + execLog( + "merge", + `W${waveIndex}`, + `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed, ${preserved} preserved from lane merge)`, + ); } // Keep both .DONE and STATUS.md in develop's working tree: @@ -2235,13 +2483,19 @@ export async function mergeWave( // that would be included in branch advancement — so we block entirely. // Also exclude verification_new_failure lanes (with successful rollback) from // success accounting: they have laneResult.error set, so !r.error filters them. - const anySuccess = !blockAdvancement && laneResults.some( - r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), - ); + const anySuccess = + !blockAdvancement && + laneResults.some( + (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), + ); if (blockAdvancement) { - execLog("merge", `W${waveIndex}`, "branch advancement BLOCKED due to verification rollback failure — " + - "temp branch may contain unverified merge commit"); + execLog( + "merge", + `W${waveIndex}`, + "branch advancement BLOCKED due to verification rollback failure — " + + "temp branch may contain unverified merge commit", + ); } if (anySuccess) { @@ -2296,7 +2550,9 @@ export async function mergeWave( } else { // Not checked out — safe to use update-ref without touching the worktree. // Use compare-and-swap (3-arg form) to guard against concurrent branch movement. - const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], { cwd: repoRoot }); + const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], { + cwd: repoRoot, + }); const oldRef = oldRefResult.status === 0 ? oldRefResult.stdout.toString().trim() : ""; const updateRefArgs = oldRef @@ -2328,10 +2584,15 @@ export async function mergeWave( // branch for manual recovery. The operator can use the recovery commands in // the transaction record to restore consistency. if (rollbackFailed) { - execLog("merge", `W${waveIndex}`, "SAFE-STOP: preserving merge worktree and temp branch for recovery", { - mergeWorkDir, - tempBranch, - }); + execLog( + "merge", + `W${waveIndex}`, + "SAFE-STOP: preserving merge worktree and temp branch for recovery", + { + mergeWorkDir, + tempBranch, + }, + ); } else { // TP-029: Apply forceRemoveMergeWorktree fallback so locked/corrupted // merge worktrees don't persist between attempts. @@ -2340,7 +2601,9 @@ export async function mergeWave( // Small delay to ensure worktree lock is released await sleepAsync(500); spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } // Determine overall status @@ -2356,7 +2619,9 @@ export async function mergeWave( const totalDurationMs = Date.now() - startTime; execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, { - mergedLanes: laneResults.filter(r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")).length, + mergedLanes: laneResults.filter( + (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), + ).length, failedLane: failedLane ?? 0, duration: `${Math.round(totalDurationMs / 1000)}s`, }); @@ -2386,7 +2651,6 @@ export async function mergeWave( return result; } - // ── Repo-Scoped Merge ──────────────────────────────────────────────── /** @@ -2412,7 +2676,7 @@ export function groupLanesByRepo( } const sortedKeys = [...groupMap.keys()].sort(); - return sortedKeys.map(key => ({ + return sortedKeys.map((key) => ({ repoId: key || undefined, lanes: groupMap.get(key)!, })); @@ -2477,13 +2741,11 @@ export async function mergeWaveByRepo( // Filter to mergeable lanes (same criteria as mergeWave). // TP-078: When forceMixedOutcome is true, lanes with mixed outcomes are also included. - const mergeableLanes = completedLanes.filter(lane => { + const mergeableLanes = completedLanes.filter((lane) => { const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded"); - const hasHardFailure = outcome.tasks.some( - t => t.status === "failed" || t.status === "stalled", - ); + const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded"); + const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled"); if (forceMixedOutcome) return hasSucceeded; return hasSucceeded && !hasHardFailure; }); @@ -2491,11 +2753,11 @@ export async function mergeWaveByRepo( if (mergeableLanes.length === 0) { // TP-171: Even when no lanes are mergeable, skipped-task lanes may have // partial progress that should be staged on the target branch. - const skippedOnlyLanes = completedLanes.filter(lane => { + const skippedOnlyLanes = completedLanes.filter((lane) => { if (!lane.worktreePath) return false; const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - return outcome.tasks.some(t => t.status === "skipped"); + return outcome.tasks.some((t) => t.status === "skipped"); }); if (skippedOnlyLanes.length > 0) { // In workspace mode, group skipped lanes by repo and stage per-repo. @@ -2522,7 +2784,7 @@ export async function mergeWaveByRepo( const repoGroups = groupLanesByRepo(mergeableLanes); execLog("merge", `W${waveIndex}`, `merging across ${repoGroups.length} repo group(s)`, { - repos: repoGroups.map(g => g.repoId ?? "(default)").join(", "), + repos: repoGroups.map((g) => g.repoId ?? "(default)").join(", "), totalLanes: mergeableLanes.length, }); @@ -2577,21 +2839,21 @@ export async function mergeWaveByRepo( repoRoot: groupRepoRoot, baseBranch: groupBaseBranch, laneCount: group.lanes.length, - lanes: group.lanes.map(l => l.laneNumber).join(","), + lanes: group.lanes.map((l) => l.laneNumber).join(","), }); // TP-171: Build allGroupLanes from all completed lanes for this repo // (not just mergeable) so mergeWave() can compute skippedArtifactLanes. const groupRepoId = group.repoId; - const allGroupLanes = completedLanes.filter(l => (l.repoId ?? undefined) === groupRepoId); - const allGroupLaneNumbers = new Set(allGroupLanes.map(l => l.laneNumber)); + const allGroupLanes = completedLanes.filter((l) => (l.repoId ?? undefined) === groupRepoId); + const allGroupLaneNumbers = new Set(allGroupLanes.map((l) => l.laneNumber)); // Build a filtered WaveExecutionResult containing all lanes for this repo // (including skipped-only lanes that aren't in the mergeable group). const filteredWaveResult: WaveExecutionResult = { ...waveResult, - laneResults: waveResult.laneResults.filter(lr => allGroupLaneNumbers.has(lr.laneNumber)), - allocatedLanes: waveResult.allocatedLanes.filter(l => allGroupLaneNumbers.has(l.laneNumber)), + laneResults: waveResult.laneResults.filter((lr) => allGroupLaneNumbers.has(lr.laneNumber)), + allocatedLanes: waveResult.allocatedLanes.filter((l) => allGroupLaneNumbers.has(l.laneNumber)), }; const groupResult = await mergeWave( @@ -2658,9 +2920,14 @@ export async function mergeWaveByRepo( const processedIndex = repoGroups.indexOf(group); const remainingGroups = repoGroups.slice(processedIndex + 1); if (remainingGroups.length > 0) { - execLog("merge", `W${waveIndex}`, `safe-stop: skipping ${remainingGroups.length} remaining repo group(s) after rollback failure`, { - skippedRepos: remainingGroups.map(g => g.repoId ?? "(default)").join(", "), - }); + execLog( + "merge", + `W${waveIndex}`, + `safe-stop: skipping ${remainingGroups.length} remaining repo group(s) after rollback failure`, + { + skippedRepos: remainingGroups.map((g) => g.repoId ?? "(default)").join(", "), + }, + ); } break; } @@ -2668,14 +2935,14 @@ export async function mergeWaveByRepo( // TP-171: Stage artifacts for repos that have only skipped lanes but were // not included in the mergeable repoGroups. - const processedRepoIds = new Set(repoGroups.map(g => g.repoId)); - const skippedOnlyRepoLanes = completedLanes.filter(lane => { + const processedRepoIds = new Set(repoGroups.map((g) => g.repoId)); + const skippedOnlyRepoLanes = completedLanes.filter((lane) => { if (!lane.worktreePath) return false; const laneRepoId = lane.repoId ?? undefined; if (processedRepoIds.has(laneRepoId)) return false; // already handled by mergeWave const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - return outcome.tasks.some(t => t.status === "skipped"); + return outcome.tasks.some((t) => t.status === "skipped"); }); // TP-171 R004: Gate artifact staging behind safe-stop — do not advance // any branch refs when a rollback failure has been detected. @@ -2694,7 +2961,7 @@ export async function mergeWaveByRepo( // both lane-level failures AND repo setup failures with failedLane=null) // TP-032 R006-3: Exclude verification_new_failure lanes from success determination const anyLaneSucceeded = allLaneResults.some( - r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), + (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), ); let status: MergeWaveResult["status"]; @@ -2710,8 +2977,10 @@ export async function mergeWaveByRepo( execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, { repoCount: repoOutcomes.length, - repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "), - mergedLanes: allLaneResults.filter(r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")).length, + repoStatuses: repoOutcomes.map((r) => `${r.repoId ?? "default"}:${r.status}`).join(", "), + mergedLanes: allLaneResults.filter( + (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), + ).length, duration: `${Math.round(totalDurationMs / 1000)}s`, }); @@ -2740,8 +3009,6 @@ export async function mergeWaveByRepo( return aggregateResult; } - - // ── Auto-Integration ───────────────────────────────────────────────── /** @@ -2839,7 +3106,9 @@ export function attemptAutoIntegration( } } - execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, { orchHead }); + execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, { + orchHead, + }); onNotify(ORCH_MESSAGES.orchIntegrationAutoSuccess(orchBranch, baseBranch), "info"); return true; } @@ -3042,12 +3311,7 @@ export class MergeHealthMonitor { const resultPath = this._resultPaths.get(sessionName) ?? ""; const hasResultFile = resultPath ? existsSync(resultPath) : false; - const newStatus = classifyMergeHealth( - sessionAlive, - hasResultFile, - state, - now, - ); + const newStatus = classifyMergeHealth(sessionAlive, hasResultFile, state, now); state.status = newStatus; @@ -3132,4 +3396,3 @@ export class MergeHealthMonitor { return new Map(this.sessions); } } - diff --git a/extensions/taskplane/messages.ts b/extensions/taskplane/messages.ts index 1cbf9b6a..ba50052c 100644 --- a/extensions/taskplane/messages.ts +++ b/extensions/taskplane/messages.ts @@ -2,7 +2,17 @@ * User-facing message templates (ORCH_MESSAGES) * @module orch/messages */ -import type { AbortMode, MergeFailureClassification, MergeRetryCallbacks, MergeRetryDecision, MergeRetryLoopOutcome, MergeRetryPolicy, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts"; +import type { + AbortMode, + MergeFailureClassification, + MergeRetryCallbacks, + MergeRetryDecision, + MergeRetryLoopOutcome, + MergeRetryPolicy, + MergeWaveResult, + OrchestratorConfig, + RepoMergeOutcome, +} from "./types.ts"; import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts"; // ── Message Templates ──────────────────────────────────────────────── @@ -17,7 +27,13 @@ export const ORCH_MESSAGES = { `🚀 Starting batch ${batchId}: ${waves} wave(s), ${tasks} task(s)`, orchWaveStart: (waveNum: number, totalWaves: number, tasks: number, lanes: number) => `\n🌊 Wave ${waveNum}/${totalWaves}: ${tasks} task(s) across ${lanes} lane(s)`, - orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) => + orchWaveComplete: ( + waveNum: number, + succeeded: number, + failed: number, + skipped: number, + elapsedSec: number, + ) => `✅ Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`, orchMergeStart: (waveNum: number, laneCount: number) => `🔀 [Wave ${waveNum}] Merging ${laneCount} lane(s) into target branch...`, @@ -31,14 +47,24 @@ export const ORCH_MESSAGES = { `🔀 [Wave ${waveNum}] Merge complete: ${mergedCount} lane(s) merged (${totalSec}s)`, orchMergeFailed: (waveNum: number, laneNum: number, reason: string) => `❌ [Wave ${waveNum}] Merge failed at lane ${laneNum}: ${reason}`, - orchMergeSkipped: (waveNum: number) => - `📝 [Wave ${waveNum}] No successful lanes to merge`, + orchMergeSkipped: (waveNum: number) => `📝 [Wave ${waveNum}] No successful lanes to merge`, orchMergePlaceholder: (waveNum: number) => `🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`, orchWorktreeReset: (waveNum: number, lanes: number) => `🔄 Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`, - orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number, orchBranch?: string, baseBranch?: string) => { - const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`]; + orchBatchComplete: ( + batchId: string, + succeeded: number, + failed: number, + skipped: number, + blocked: number, + elapsedSec: number, + orchBranch?: string, + baseBranch?: string, + ) => { + const lines = [ + `\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`, + ]; if (failed > 0 || blocked > 0) { lines.push(""); if (blocked > 0) { @@ -66,8 +92,7 @@ export const ORCH_MESSAGES = { } return lines.join("\n"); }, - orchBatchFailed: (batchId: string, reason: string) => - `\n❌ Batch ${batchId} failed: ${reason}`, + orchBatchFailed: (batchId: string, reason: string) => `\n❌ Batch ${batchId} failed: ${reason}`, orchBatchStopped: (batchId: string, policy: string) => `\n⛔ Batch ${batchId} stopped by ${policy} policy`, @@ -88,17 +113,22 @@ export const ORCH_MESSAGES = { orphanDetectionAbort: (sessionCount: number) => `⚠️ Found ${sessionCount} orphan orchestrator session(s) without usable state.\n` + ` Use /orch-abort to clean up before starting a new batch.`, - orphanDetectionCleanup: () => - `🧹 Cleaned up stale batch state file. Starting fresh.`, + orphanDetectionCleanup: () => `🧹 Cleaned up stale batch state file. Starting fresh.`, // /orch-resume resumeStarting: (batchId: string, phase: string) => `🔄 Resuming batch ${batchId} (was: ${phase})...`, - resumeReconciled: (batchId: string, completed: number, pending: number, failed: number, reconnecting: number, reExecuting: number = 0) => + resumeReconciled: ( + batchId: string, + completed: number, + pending: number, + failed: number, + reconnecting: number, + reExecuting: number = 0, + ) => `📊 Batch ${batchId} reconciliation: ${completed} completed, ${pending} pending, ${failed} failed, ${reconnecting} reconnecting` + (reExecuting > 0 ? `, ${reExecuting} re-executing` : ""), - resumeSkippedWaves: (skippedCount: number) => - `⏭️ Skipping ${skippedCount} completed wave(s)`, + resumeSkippedWaves: (skippedCount: number) => `⏭️ Skipping ${skippedCount} completed wave(s)`, resumeReconnecting: (sessionCount: number) => `🔗 Reconnecting to ${sessionCount} alive session(s)...`, resumeNoState: () => @@ -128,9 +158,15 @@ export const ORCH_MESSAGES = { ` Error: ${error}\n` + ` Delete .pi/batch-state.json and start a new batch.`, resumePhaseNotResumable: (batchId: string, phase: string, reason: string) => - `❌ Cannot resume batch ${batchId} (phase: ${phase}).\n` + - ` ${reason}`, - resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) => + `❌ Cannot resume batch ${batchId} (phase: ${phase}).\n` + ` ${reason}`, + resumeComplete: ( + batchId: string, + succeeded: number, + failed: number, + skipped: number, + blocked: number, + elapsedSec: number, + ) => `\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`, // /orch-resume --force @@ -147,7 +183,12 @@ export const ORCH_MESSAGES = { `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`, abortGracefulForceKill: (count: number) => `⚠️ Force-killing ${count} session(s) that did not exit within timeout`, - abortGracefulComplete: (batchId: string, graceful: number, forceKilled: number, durationSec: number) => + abortGracefulComplete: ( + batchId: string, + graceful: number, + forceKilled: number, + durationSec: number, + ) => `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`, abortHardStarting: (batchId: string, sessionCount: number) => `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`, @@ -155,8 +196,7 @@ export const ORCH_MESSAGES = { `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`, abortPartialFailure: (failureCount: number) => `⚠️ ${failureCount} error(s) during abort (see details above)`, - abortNoBatch: () => - `No active batch to abort. Use /orch to start a batch.`, + abortNoBatch: () => `No active batch to abort. Use /orch to start a batch.`, abortComplete: (mode: AbortMode, sessionsKilled: number) => `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`, // /orch merge — repo-scoped partial summary (TP-005 Step 1) @@ -182,7 +222,6 @@ export const ORCH_MESSAGES = { }, } as const; - // ── Repo-Scoped Merge Summary (TP-005) ────────────────────────────── /** @@ -190,10 +229,14 @@ export const ORCH_MESSAGES = { */ function repoStatusIcon(status: RepoMergeOutcome["status"]): string { switch (status) { - case "succeeded": return "✅"; - case "partial": return "⚠️"; - case "failed": return "❌"; - default: return "❓"; + case "succeeded": + return "✅"; + case "partial": + return "⚠️"; + case "failed": + return "❌"; + default: + return "❓"; } } @@ -228,7 +271,7 @@ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | n } // Check for actual divergence: are there different statuses across repos? - const statuses = new Set(repoResults.map(r => r.status)); + const statuses = new Set(repoResults.map((r) => r.status)); if (statuses.size < 2) { // All repos have the same status (e.g., all "partial") — // the partial is from within-repo lane failures, not cross-repo divergence @@ -236,12 +279,13 @@ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | n } // Build per-repo summary lines (sorted by repoId, which repoResults already is) - const repoLines = repoResults.map(r => { + const repoLines = repoResults.map((r) => { const repoLabel = r.repoId ?? "(default)"; const icon = repoStatusIcon(r.status); // TP-032 R006-3: Exclude verification_new_failure lanes from success count const mergedCount = r.laneResults.filter( - lr => !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"), + (lr) => + !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"), ).length; const totalCount = r.laneResults.length; let detail = `${mergedCount}/${totalCount} lane(s) merged`; @@ -254,7 +298,6 @@ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | n return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines); } - // ── Merge Failure Policy Application (TP-005 Step 2) ───────────────── /** @@ -328,8 +371,11 @@ export function computeMergeFailurePolicy( // 3. Repo-level: repos with non-succeeded status from repoResults // (catches setup failures where failedLane=null and no lane results) let failedLaneIds = mergeResult.laneResults - .filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error) - .map(r => `lane-${r.laneNumber}`) + .filter( + (r) => + r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error, + ) + .map((r) => `lane-${r.laneNumber}`) .join(", "); if (!failedLaneIds && mergeResult.failedLane !== null) { failedLaneIds = `lane-${mergeResult.failedLane}`; @@ -338,8 +384,8 @@ export function computeMergeFailurePolicy( // Repo-level fallback for setup failures (no lane results, failedLane=null). // Uses sorted repoResults order for determinism. failedLaneIds = mergeResult.repoResults - .filter(r => r.status !== "succeeded") - .map(r => `repo:${r.repoId ?? "default"}`) + .filter((r) => r.status !== "succeeded") + .map((r) => `repo:${r.repoId ?? "default"}`) .join(", "); } @@ -384,7 +430,6 @@ export function computeMergeFailurePolicy( }; } - // ── Cleanup Gate Policy (TP-029 Step 2) ────────────────────────────── /** @@ -456,12 +501,12 @@ export function computeCleanupGatePolicy( const failedRepoCount = failures.length; const totalStaleWorktrees = failures.reduce((sum, f) => sum + f.staleWorktrees.length, 0); - const repos = failures.map(f => ({ + const repos = failures.map((f) => ({ repoId: f.repoId ?? "(default)", staleCount: f.staleWorktrees.length, })); - const repoDetail = repos.map(r => `${r.repoId} (${r.staleCount} stale)`).join(", "); + const repoDetail = repos.map((r) => `${r.repoId} (${r.staleCount} stale)`).join(", "); const errorMessage = `Post-merge cleanup failed at wave ${waveNum}: ${totalStaleWorktrees} stale worktree(s) ` + @@ -481,7 +526,8 @@ export function computeCleanupGatePolicy( `⏸️ Batch paused: post-merge cleanup failed at wave ${waveNum}.\n` + ` ${totalStaleWorktrees} stale worktree(s) in ${failedRepoCount} repo(s): ${repoDetail}\n` + ` Manual recovery:\n` + - recoveryLines.join("\n") + "\n" + + recoveryLines.join("\n") + + "\n" + ` Then: /orch-resume`; return { @@ -522,7 +568,9 @@ export function computeCleanupGatePolicy( * @returns Classification or null if no merge-retry class matches * @since TP-033 */ -export function classifyMergeFailure(mergeResult: MergeWaveResult): MergeFailureClassification | null { +export function classifyMergeFailure( + mergeResult: MergeWaveResult, +): MergeFailureClassification | null { // Check lane-level errors first (most specific) for (const lr of mergeResult.laneResults) { if (lr.error && lr.error.startsWith("verification_new_failure")) { @@ -619,7 +667,8 @@ export function computeMergeRetryDecision( return { shouldRetry: true, cooldownMs: policy.cooldownMs, - reason: `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` + + reason: + `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` + (policy.cooldownMs > 0 ? ` (cooldown: ${policy.cooldownMs}ms)` : ""), currentAttempt: currentRetryCount + 1, maxAttempts: policy.maxAttempts, @@ -678,8 +727,11 @@ export function extractFailedRepoId(mergeResult: MergeWaveResult): string | unde // 1. Try lane-level extraction if (failedLaneNum !== null && failedLaneNum !== undefined) { const failedLaneResult = mergeResult.laneResults.find( - lr => lr.laneNumber === failedLaneNum && - (lr.error || lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE"), + (lr) => + lr.laneNumber === failedLaneNum && + (lr.error || + lr.result?.status === "CONFLICT_UNRESOLVED" || + lr.result?.status === "BUILD_FAILURE"), ); if (failedLaneResult?.repoId) return failedLaneResult.repoId; } @@ -687,7 +739,7 @@ export function extractFailedRepoId(mergeResult: MergeWaveResult): string | unde // 2. Repo-level fallback for setup failures (failedLane === null) if (mergeResult.repoResults && mergeResult.repoResults.length > 0) { const failedRepo = mergeResult.repoResults.find( - rr => rr.status === "failed" || rr.status === "partial", + (rr) => rr.status === "failed" || rr.status === "partial", ); if (failedRepo?.repoId) return failedRepo.repoId; } @@ -783,7 +835,9 @@ export async function applyMergeRetryLoop( callbacks.notify( `🔄 Merge retry (${lastDecision.reason}) at wave ${waveIdx + 1}. ` + - (lastDecision.cooldownMs > 0 ? `Waiting ${lastDecision.cooldownMs}ms before retry...` : "Retrying immediately..."), + (lastDecision.cooldownMs > 0 + ? `Waiting ${lastDecision.cooldownMs}ms before retry...` + : "Retrying immediately..."), "warning", ); @@ -811,7 +865,8 @@ export async function applyMergeRetryLoop( if (currentResult.rollbackFailed) { // Safe-stop takes priority - const hasPersistErrors = currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0; + const hasPersistErrors = + currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0; const persistWarning = hasPersistErrors ? ` WARNING: ${currentResult.persistenceErrors!.length} transaction record(s) failed to persist.` : ""; @@ -824,10 +879,12 @@ export async function applyMergeRetryLoop( lastDecision, errorMessage: `Safe-stop at wave ${waveIdx + 1}: verification rollback failed after retry. ` + - `Merge worktree and temp branch preserved for recovery.` + persistWarning, + `Merge worktree and temp branch preserved for recovery.` + + persistWarning, notifyMessage: `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1} after retry. ` + - `Batch force-paused.` + persistWarning, + `Batch force-paused.` + + persistWarning, }; } @@ -907,12 +964,13 @@ export function computeIntegrateCleanupResult( repoFindings: IntegrateCleanupRepoFindings[], ): IntegrateCleanupResult { // Filter to repos that have at least one issue - const dirtyRepos = repoFindings.filter(r => - r.staleWorktrees.length > 0 || - r.staleLaneBranches.length > 0 || - r.staleOrchBranches.length > 0 || - r.staleAutostashEntries.length > 0 || - r.nonEmptyWorktreeContainers.length > 0, + const dirtyRepos = repoFindings.filter( + (r) => + r.staleWorktrees.length > 0 || + r.staleLaneBranches.length > 0 || + r.staleOrchBranches.length > 0 || + r.staleAutostashEntries.length > 0 || + r.nonEmptyWorktreeContainers.length > 0, ); if (dirtyRepos.length === 0) { diff --git a/extensions/taskplane/migrations.ts b/extensions/taskplane/migrations.ts index 048bdb04..7eb8eac0 100644 --- a/extensions/taskplane/migrations.ts +++ b/extensions/taskplane/migrations.ts @@ -180,7 +180,7 @@ export const MIGRATION_REGISTRY: Migration[] = [ if (!existsSync(templatePath)) { throw new Error( `Migration template not found: ${templatePath}. ` + - `This may indicate a packaging issue with the taskplane package.`, + `This may indicate a packaging issue with the taskplane package.`, ); } diff --git a/extensions/taskplane/path-resolver.ts b/extensions/taskplane/path-resolver.ts index f4b6fcb4..3a651553 100644 --- a/extensions/taskplane/path-resolver.ts +++ b/extensions/taskplane/path-resolver.ts @@ -169,10 +169,10 @@ export function resolvePiCliPath(): string { throw new Error( "Cannot find Pi CLI entrypoint (pi-coding-agent/dist/cli.js) under any known npm scope " + - `(${PI_PACKAGE_SCOPES.join(" or ")}). ` + - "Install via 'npm install -g @earendil-works/pi-coding-agent' " + - "(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " + - `npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`, + `(${PI_PACKAGE_SCOPES.join(" or ")}). ` + + "Install via 'npm install -g @earendil-works/pi-coding-agent' " + + "(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " + + `npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`, ); } @@ -236,7 +236,9 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string): const piPkgDir = resolve(piPath, "..", ".."); // //pi-coding-agent const npmRootFromPi = resolve(piPkgDir, "..", ".."); // candidates.push(join(npmRootFromPi, "taskplane", relPath)); - } catch { /* ignore — process.argv[1] may be undefined in test contexts */ } + } catch { + /* ignore — process.argv[1] may be undefined in test contexts */ + } for (const candidate of candidates) { if (existsSync(candidate)) return candidate; @@ -269,8 +271,5 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string): * ``` */ export function resolveTaskplaneAgentTemplate(agentName: string): string { - return resolveTaskplanePackageFile( - process.cwd(), - join("templates", "agents", `${agentName}.md`), - ); + return resolveTaskplanePackageFile(process.cwd(), join("templates", "agents", `${agentName}.md`)); } diff --git a/extensions/taskplane/persistence.ts b/extensions/taskplane/persistence.ts index cccd2db5..69fd5eb0 100644 --- a/extensions/taskplane/persistence.ts +++ b/extensions/taskplane/persistence.ts @@ -2,13 +2,50 @@ * State persistence, serialization, orphan detection * @module orch/persistence */ -import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync, readdirSync, statSync } from "fs"; +import { + readFileSync, + writeFileSync, + existsSync, + unlinkSync, + renameSync, + mkdirSync, + appendFileSync, + readdirSync, + statSync, +} from "fs"; import { join, dirname, basename } from "path"; import { execLog } from "./execution.ts"; -import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics, runtimeRoot, runtimeManifestPath } from "./types.ts"; +import { + BATCH_STATE_SCHEMA_VERSION, + StateFileError, + batchStatePath, + BATCH_HISTORY_MAX_ENTRIES, + defaultResilienceState, + defaultBatchDiagnostics, + runtimeRoot, + runtimeManifestPath, +} from "./types.ts"; import type { BatchHistorySummary, RuntimeAgentManifest } from "./types.ts"; -import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts"; +import type { + AllocatedLane, + DiscoveryResult, + EngineEvent, + EscalationContext, + LaneTaskOutcome, + LaneTaskStatus, + MonitorState, + OrchBatchPhase, + OrchBatchRuntimeState, + PersistedBatchState, + PersistedLaneRecord, + PersistedMergeResult, + PersistedSegmentRecord, + PersistedTaskRecord, + TaskMonitorSnapshot, + Tier0RecoveryPattern, + WorkspaceMode, +} from "./types.ts"; import { sleepSync } from "./worktree.ts"; import type { PreserveFailedLaneProgressResult } from "./worktree.ts"; import { normalizeLaneSessionAlias, readLaneSessionAliases } from "./tmux-compat.ts"; @@ -53,23 +90,28 @@ export function hasTaskDoneMarker(taskFolder: string): boolean { /** * Compare optional embedded outcome telemetry. */ -function sameOutcomeTelemetry(a: LaneTaskOutcome["telemetry"], b: LaneTaskOutcome["telemetry"]): boolean { +function sameOutcomeTelemetry( + a: LaneTaskOutcome["telemetry"], + b: LaneTaskOutcome["telemetry"], +): boolean { if (!a && !b) return true; if (!a || !b) return false; - return a.inputTokens === b.inputTokens - && a.outputTokens === b.outputTokens - && a.cacheReadTokens === b.cacheReadTokens - && a.cacheWriteTokens === b.cacheWriteTokens - && a.costUsd === b.costUsd - && a.toolCalls === b.toolCalls - && a.durationMs === b.durationMs; + return ( + a.inputTokens === b.inputTokens && + a.outputTokens === b.outputTokens && + a.cacheReadTokens === b.cacheReadTokens && + a.cacheWriteTokens === b.cacheWriteTokens && + a.costUsd === b.costUsd && + a.toolCalls === b.toolCalls && + a.durationMs === b.durationMs + ); } /** * Upsert a task outcome in-place. Returns true if changed. */ export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOutcome): boolean { - const idx = outcomes.findIndex(o => o.taskId === next.taskId); + const idx = outcomes.findIndex((o) => o.taskId === next.taskId); if (idx < 0) { outcomes.push(next); return true; @@ -120,7 +162,7 @@ export function applyPartialProgressToOutcomes( let updated = 0; for (const r of ppResult.results) { if (!r.saved || !r.savedBranch) continue; - const outcome = outcomes.find(o => o.taskId === r.taskId); + const outcome = outcomes.find((o) => o.taskId === r.taskId); if (outcome) { outcome.partialProgressCommits = r.commitCount; outcome.partialProgressBranch = r.savedBranch; @@ -143,18 +185,19 @@ export function seedPendingOutcomesForAllocatedLanes( let changed = false; for (const lane of lanes) { for (const laneTask of lane.tasks) { - const existing = outcomes.find(o => o.taskId === laneTask.taskId); + const existing = outcomes.find((o) => o.taskId === laneTask.taskId); if (existing) continue; - changed = upsertTaskOutcome(outcomes, { - taskId: laneTask.taskId, - status: "pending", - startTime: null, - endTime: null, - exitReason: "Pending execution", - sessionName: lane.laneSessionId, - doneFileFound: false, - laneNumber: lane.laneNumber, - }) || changed; + changed = + upsertTaskOutcome(outcomes, { + taskId: laneTask.taskId, + status: "pending", + startTime: null, + endTime: null, + exitReason: "Pending execution", + sessionName: lane.laneSessionId, + doneFileFound: false, + laneNumber: lane.laneNumber, + }) || changed; } } return changed; @@ -175,70 +218,78 @@ export function syncTaskOutcomesFromMonitor( for (const lane of monitorState.lanes) { // Remaining tasks => pending for (const taskId of lane.remainingTasks) { - const existing = outcomes.find(o => o.taskId === taskId); - if (existing && (existing.status === "succeeded" || existing.status === "failed" || existing.status === "stalled")) { + const existing = outcomes.find((o) => o.taskId === taskId); + if ( + existing && + (existing.status === "succeeded" || + existing.status === "failed" || + existing.status === "stalled") + ) { continue; } - changed = upsertTaskOutcome(outcomes, { - taskId, - status: "pending", - startTime: existing?.startTime ?? null, - endTime: null, - exitReason: existing?.exitReason || "Pending execution", - sessionName: existing?.sessionName || lane.sessionName, - doneFileFound: false, - laneNumber: existing?.laneNumber ?? lane.laneNumber, - telemetry: existing?.telemetry, - partialProgressCommits: existing?.partialProgressCommits, - partialProgressBranch: existing?.partialProgressBranch, - exitDiagnostic: existing?.exitDiagnostic, - }) || changed; + changed = + upsertTaskOutcome(outcomes, { + taskId, + status: "pending", + startTime: existing?.startTime ?? null, + endTime: null, + exitReason: existing?.exitReason || "Pending execution", + sessionName: existing?.sessionName || lane.sessionName, + doneFileFound: false, + laneNumber: existing?.laneNumber ?? lane.laneNumber, + telemetry: existing?.telemetry, + partialProgressCommits: existing?.partialProgressCommits, + partialProgressBranch: existing?.partialProgressBranch, + exitDiagnostic: existing?.exitDiagnostic, + }) || changed; } // Completed tasks => succeeded // Use existing endTime if already set — prevents changed=true on every // poll tick (lastPollTime differs each tick, causing persist log spam). for (const taskId of lane.completedTasks) { - const existing = outcomes.find(o => o.taskId === taskId); - changed = upsertTaskOutcome(outcomes, { - taskId, - status: "succeeded", - startTime: existing?.startTime ?? null, - endTime: existing?.endTime ?? monitorState.lastPollTime, - exitReason: existing?.exitReason || ".DONE file created by task-runner", - sessionName: existing?.sessionName || lane.sessionName, - doneFileFound: true, - laneNumber: existing?.laneNumber ?? lane.laneNumber, - telemetry: existing?.telemetry, - partialProgressCommits: existing?.partialProgressCommits, - partialProgressBranch: existing?.partialProgressBranch, - exitDiagnostic: existing?.exitDiagnostic, - }) || changed; + const existing = outcomes.find((o) => o.taskId === taskId); + changed = + upsertTaskOutcome(outcomes, { + taskId, + status: "succeeded", + startTime: existing?.startTime ?? null, + endTime: existing?.endTime ?? monitorState.lastPollTime, + exitReason: existing?.exitReason || ".DONE file created by task-runner", + sessionName: existing?.sessionName || lane.sessionName, + doneFileFound: true, + laneNumber: existing?.laneNumber ?? lane.laneNumber, + telemetry: existing?.telemetry, + partialProgressCommits: existing?.partialProgressCommits, + partialProgressBranch: existing?.partialProgressBranch, + exitDiagnostic: existing?.exitDiagnostic, + }) || changed; } // Failed tasks => failed for (const taskId of lane.failedTasks) { - const existing = outcomes.find(o => o.taskId === taskId); - changed = upsertTaskOutcome(outcomes, { - taskId, - status: "failed", - startTime: existing?.startTime ?? null, - endTime: existing?.endTime ?? monitorState.lastPollTime, - exitReason: existing?.exitReason || "Task failed or stalled", - sessionName: existing?.sessionName || lane.sessionName, - doneFileFound: false, - laneNumber: existing?.laneNumber ?? lane.laneNumber, - telemetry: existing?.telemetry, - partialProgressCommits: existing?.partialProgressCommits, - partialProgressBranch: existing?.partialProgressBranch, - exitDiagnostic: existing?.exitDiagnostic, - }) || changed; + const existing = outcomes.find((o) => o.taskId === taskId); + changed = + upsertTaskOutcome(outcomes, { + taskId, + status: "failed", + startTime: existing?.startTime ?? null, + endTime: existing?.endTime ?? monitorState.lastPollTime, + exitReason: existing?.exitReason || "Task failed or stalled", + sessionName: existing?.sessionName || lane.sessionName, + doneFileFound: false, + laneNumber: existing?.laneNumber ?? lane.laneNumber, + telemetry: existing?.telemetry, + partialProgressCommits: existing?.partialProgressCommits, + partialProgressBranch: existing?.partialProgressBranch, + exitDiagnostic: existing?.exitDiagnostic, + }) || changed; } // Current task snapshot => running/stalled/succeeded/failed/skipped if (lane.currentTaskId && lane.currentTaskSnapshot) { const snap = lane.currentTaskSnapshot; - const existing = outcomes.find(o => o.taskId === lane.currentTaskId); + const existing = outcomes.find((o) => o.taskId === lane.currentTaskId); const monitorToLane: Record = { pending: "pending", running: "running", @@ -249,26 +300,35 @@ export function syncTaskOutcomesFromMonitor( unknown: existing?.status || "running", }; const mappedStatus = monitorToLane[snap.status]; - const terminal = mappedStatus === "succeeded" || mappedStatus === "failed" || mappedStatus === "stalled" || mappedStatus === "skipped"; + const terminal = + mappedStatus === "succeeded" || + mappedStatus === "failed" || + mappedStatus === "stalled" || + mappedStatus === "skipped"; // TP-051: Use snap.observedAt (Date.now() from monitor poll) instead of // snap.lastHeartbeat (STATUS.md mtime) for task start time. The mtime // reflects when STATUS.md was last edited, which may be long before // actual execution started (e.g., during task staging). - changed = upsertTaskOutcome(outcomes, { - taskId: lane.currentTaskId, - status: mappedStatus, - startTime: existing?.startTime ?? snap.observedAt, - endTime: terminal ? (existing?.endTime ?? snap.observedAt) : null, - exitReason: existing?.exitReason || (mappedStatus === "running" ? "Task in progress" : (snap.stallReason || "Task reached terminal state")), - sessionName: existing?.sessionName || lane.sessionName, - doneFileFound: snap.doneFileFound, - laneNumber: existing?.laneNumber ?? lane.laneNumber, - telemetry: existing?.telemetry, - partialProgressCommits: existing?.partialProgressCommits, - partialProgressBranch: existing?.partialProgressBranch, - exitDiagnostic: existing?.exitDiagnostic, - }) || changed; + changed = + upsertTaskOutcome(outcomes, { + taskId: lane.currentTaskId, + status: mappedStatus, + startTime: existing?.startTime ?? snap.observedAt, + endTime: terminal ? (existing?.endTime ?? snap.observedAt) : null, + exitReason: + existing?.exitReason || + (mappedStatus === "running" + ? "Task in progress" + : snap.stallReason || "Task reached terminal state"), + sessionName: existing?.sessionName || lane.sessionName, + doneFileFound: snap.doneFileFound, + laneNumber: existing?.laneNumber ?? lane.laneNumber, + telemetry: existing?.telemetry, + partialProgressCommits: existing?.partialProgressCommits, + partialProgressBranch: existing?.partialProgressBranch, + exitDiagnostic: existing?.exitDiagnostic, + }) || changed; } } @@ -322,13 +382,19 @@ export function persistRuntimeState( if ((taskRecord as any).packetRepoId === undefined && parsedTask.packetRepoId !== undefined) { (taskRecord as any).packetRepoId = parsedTask.packetRepoId; } - if ((taskRecord as any).packetTaskPath === undefined && parsedTask.packetTaskPath !== undefined) { + if ( + (taskRecord as any).packetTaskPath === undefined && + parsedTask.packetTaskPath !== undefined + ) { (taskRecord as any).packetTaskPath = parsedTask.packetTaskPath; } if ((taskRecord as any).segmentIds === undefined && parsedTask.segmentIds !== undefined) { (taskRecord as any).segmentIds = parsedTask.segmentIds; } - if ((taskRecord as any).activeSegmentId === undefined && parsedTask.activeSegmentId !== undefined) { + if ( + (taskRecord as any).activeSegmentId === undefined && + parsedTask.activeSegmentId !== undefined + ) { (taskRecord as any).activeSegmentId = parsedTask.activeSegmentId; } } @@ -344,9 +410,12 @@ export function persistRuntimeState( waveIndex: batchState.currentWaveIndex, }); } catch (err: unknown) { - const msg = err instanceof StateFileError - ? `[${err.code}] ${err.message}` - : (err instanceof Error ? err.message : String(err)); + const msg = + err instanceof StateFileError + ? `[${err.code}] ${err.message}` + : err instanceof Error + ? err.message + : String(err); execLog("state", batchState.batchId, `write failed: ${msg}`, { reason, phase: batchState.phase, @@ -355,22 +424,36 @@ export function persistRuntimeState( } } - // ── State Validation ───────────────────────────────────────────────── /** All valid OrchBatchPhase values for validation. */ export const VALID_BATCH_PHASES: ReadonlySet = new Set([ - "idle", "launching", "planning", "executing", "merging", "paused", "stopped", "completed", "failed", + "idle", + "launching", + "planning", + "executing", + "merging", + "paused", + "stopped", + "completed", + "failed", ]); /** All valid LaneTaskStatus values for validation. */ export const VALID_TASK_STATUSES: ReadonlySet = new Set([ - "pending", "running", "succeeded", "failed", "stalled", "skipped", + "pending", + "running", + "succeeded", + "failed", + "stalled", + "skipped", ]); /** All valid merge result statuses for persisted state. */ export const VALID_PERSISTED_MERGE_STATUSES: ReadonlySet = new Set([ - "succeeded", "failed", "partial", + "succeeded", + "failed", + "partial", ]); /** @@ -462,10 +545,7 @@ export function upconvertV3toV4(obj: Record): void { */ export function validatePersistedState(data: unknown): PersistedBatchState { if (!data || typeof data !== "object") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - "Batch state must be a non-null object", - ); + throw new StateFileError("STATE_SCHEMA_INVALID", "Batch state must be a non-null object"); } const obj = data as Record; @@ -484,8 +564,8 @@ export function validatePersistedState(data: unknown): PersistedBatchState { throw new StateFileError( "STATE_SCHEMA_INVALID", `Unsupported schema version ${obj.schemaVersion} (expected ${BATCH_STATE_SCHEMA_VERSION}). ` + - `Upgrade taskplane to a version that supports schema v${obj.schemaVersion}, ` + - `or delete .pi/batch-state.json and re-run the batch.`, + `Upgrade taskplane to a version that supports schema v${obj.schemaVersion}, ` + + `or delete .pi/batch-state.json and re-run the batch.`, ); } const isV1 = obj.schemaVersion === 1; @@ -552,8 +632,15 @@ export function validatePersistedState(data: unknown): PersistedBatchState { // ── Required number fields ─────────────────────────────────── for (const field of [ - "startedAt", "updatedAt", "currentWaveIndex", "totalWaves", - "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks", + "startedAt", + "updatedAt", + "currentWaveIndex", + "totalWaves", + "totalTasks", + "succeededTasks", + "failedTasks", + "skippedTasks", + "blockedTasks", ] as const) { if (typeof obj[field] !== "number") { throw new StateFileError( @@ -572,7 +659,14 @@ export function validatePersistedState(data: unknown): PersistedBatchState { } // ── Required arrays ────────────────────────────────────────── - for (const field of ["wavePlan", "lanes", "tasks", "mergeResults", "blockedTaskIds", "errors"] as const) { + for (const field of [ + "wavePlan", + "lanes", + "tasks", + "mergeResults", + "blockedTaskIds", + "errors", + ] as const) { if (!Array.isArray(obj[field])) { throw new StateFileError( "STATE_SCHEMA_INVALID", @@ -585,10 +679,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { const wavePlan = obj.wavePlan as unknown[]; for (let i = 0; i < wavePlan.length; i++) { if (!Array.isArray(wavePlan[i])) { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `wavePlan[${i}] is not an array`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `wavePlan[${i}] is not an array`); } for (const taskId of wavePlan[i] as unknown[]) { if (typeof taskId !== "string") { @@ -605,10 +696,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { for (let i = 0; i < tasks.length; i++) { const t = tasks[i] as Record; if (!t || typeof t !== "object") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `tasks[${i}] is not an object`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `tasks[${i}] is not an object`); } for (const field of ["taskId", "sessionName", "taskFolder", "exitReason"] as const) { if (typeof t[field] !== "string") { @@ -637,10 +725,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { ); } if (t.endedAt !== null && typeof t.endedAt !== "number") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `tasks[${i}].endedAt is not a number or null`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `tasks[${i}].endedAt is not a number or null`); } if (typeof t.doneFileFound !== "boolean") { throw new StateFileError( @@ -676,7 +761,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState { } // TP-026 optional field: exitDiagnostic (object with classification string | undefined) if (t.exitDiagnostic !== undefined) { - if (typeof t.exitDiagnostic !== "object" || t.exitDiagnostic === null || Array.isArray(t.exitDiagnostic)) { + if ( + typeof t.exitDiagnostic !== "object" || + t.exitDiagnostic === null || + Array.isArray(t.exitDiagnostic) + ) { throw new StateFileError( "STATE_SCHEMA_INVALID", `tasks[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(t.exitDiagnostic) ? "array" : typeof t.exitDiagnostic})`, @@ -697,10 +786,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { for (let i = 0; i < lanes.length; i++) { const l = lanes[i] as Record; if (!l || typeof l !== "object") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `lanes[${i}] is not an object`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `lanes[${i}] is not an object`); } for (const field of ["laneId", "worktreePath", "branch"] as const) { if (typeof l[field] !== "string") { @@ -763,7 +849,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { if (legacyTmuxSessionLaneIndexes.length > 0) { console.error( "[taskplane] migration: detected legacy lanes[].tmuxSessionName in .pi/batch-state.json; " + - "normalized to lanes[].laneSessionId for this release. Re-save state (or re-run /orch-resume) to persist canonical fields.", + "normalized to lanes[].laneSessionId for this release. Re-save state (or re-run /orch-resume) to persist canonical fields.", ); } @@ -772,10 +858,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { for (let i = 0; i < mergeResults.length; i++) { const m = mergeResults[i] as Record; if (!m || typeof m !== "object") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `mergeResults[${i}] is not an object`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `mergeResults[${i}] is not an object`); } if (typeof m.waveIndex !== "number") { throw new StateFileError( @@ -824,10 +907,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState { // ── Validate lastError ─────────────────────────────────────── if (obj.lastError !== null) { if (typeof obj.lastError !== "object") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `lastError is not an object or null`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `lastError is not an object or null`); } const le = obj.lastError as Record; if (typeof le.code !== "string" || typeof le.message !== "string") { @@ -881,7 +961,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState { `resilience.resumeForced must be a boolean (got ${typeof res.resumeForced})`, ); } - if (!res.retryCountByScope || typeof res.retryCountByScope !== "object" || Array.isArray(res.retryCountByScope)) { + if ( + !res.retryCountByScope || + typeof res.retryCountByScope !== "object" || + Array.isArray(res.retryCountByScope) + ) { throw new StateFileError( "STATE_SCHEMA_INVALID", `resilience.retryCountByScope must be an object (got ${typeof res.retryCountByScope})`, @@ -1064,7 +1148,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState { } } // v4 optional field: activeSegmentId (string | null | undefined) - if (t.activeSegmentId !== undefined && t.activeSegmentId !== null && typeof t.activeSegmentId !== "string") { + if ( + t.activeSegmentId !== undefined && + t.activeSegmentId !== null && + typeof t.activeSegmentId !== "string" + ) { throw new StateFileError( "STATE_SCHEMA_INVALID", `tasks[${i}].activeSegmentId is not a string or null (got ${typeof t.activeSegmentId})`, @@ -1083,13 +1171,19 @@ export function validatePersistedState(data: unknown): PersistedBatchState { for (let i = 0; i < segments.length; i++) { const s = segments[i] as Record; if (!s || typeof s !== "object") { - throw new StateFileError( - "STATE_SCHEMA_INVALID", - `segments[${i}] is not an object`, - ); + throw new StateFileError("STATE_SCHEMA_INVALID", `segments[${i}] is not an object`); } // Required string fields - for (const field of ["segmentId", "taskId", "repoId", "laneId", "sessionName", "worktreePath", "branch", "exitReason"] as const) { + for (const field of [ + "segmentId", + "taskId", + "repoId", + "laneId", + "sessionName", + "worktreePath", + "branch", + "exitReason", + ] as const) { if (typeof s[field] !== "string") { throw new StateFileError( "STATE_SCHEMA_INVALID", @@ -1153,7 +1247,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState { } // Optional exitDiagnostic if (s.exitDiagnostic !== undefined) { - if (!s.exitDiagnostic || typeof s.exitDiagnostic !== "object" || Array.isArray(s.exitDiagnostic)) { + if ( + !s.exitDiagnostic || + typeof s.exitDiagnostic !== "object" || + Array.isArray(s.exitDiagnostic) + ) { throw new StateFileError( "STATE_SCHEMA_INVALID", `segments[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(s.exitDiagnostic) ? "array" : typeof s.exitDiagnostic})`, @@ -1173,12 +1271,31 @@ export function validatePersistedState(data: unknown): PersistedBatchState { // serialization. This protects against data loss from future schema // extensions or external tools writing additional fields. const KNOWN_TOP_LEVEL_FIELDS = new Set([ - "schemaVersion", "phase", "batchId", "baseBranch", "orchBranch", "mode", - "startedAt", "updatedAt", "endedAt", "currentWaveIndex", "totalWaves", - "wavePlan", "lanes", "tasks", "mergeResults", - "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks", - "blockedTaskIds", "lastError", "errors", - "resilience", "diagnostics", + "schemaVersion", + "phase", + "batchId", + "baseBranch", + "orchBranch", + "mode", + "startedAt", + "updatedAt", + "endedAt", + "currentWaveIndex", + "totalWaves", + "wavePlan", + "lanes", + "tasks", + "mergeResults", + "totalTasks", + "succeededTasks", + "failedTasks", + "skippedTasks", + "blockedTasks", + "blockedTaskIds", + "lastError", + "errors", + "resilience", + "diagnostics", "segments", "_extraFields", ]); @@ -1241,69 +1358,70 @@ export function serializeBatchState( } // Build a lookup from taskId → AllocatedTask (which holds the ParsedTask with repo fields). - const allocatedTaskByTaskId = new Map(); + const allocatedTaskByTaskId = new Map< + string, + { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane } + >(); for (const lane of lanes) { for (const allocTask of lane.tasks) { allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane }); } } - const taskRecords: PersistedTaskRecord[] = [...taskIdSet] - .sort() - .map((taskId) => { - const lane = laneByTaskId.get(taskId); - const outcome = outcomeByTaskId.get(taskId); - const allocated = allocatedTaskByTaskId.get(taskId); + const taskRecords: PersistedTaskRecord[] = [...taskIdSet].sort().map((taskId) => { + const lane = laneByTaskId.get(taskId); + const outcome = outcomeByTaskId.get(taskId); + const allocated = allocatedTaskByTaskId.get(taskId); - const record: PersistedTaskRecord = { - taskId, - laneNumber: lane?.laneNumber ?? outcome?.laneNumber ?? 0, - sessionName: outcome?.sessionName || lane?.laneSessionId || "", - status: outcome?.status ?? "pending", - taskFolder: "", // Enriched by caller from discovery - startedAt: outcome?.startTime ?? null, - endedAt: outcome?.endTime ?? null, - doneFileFound: outcome?.doneFileFound ?? false, - exitReason: outcome?.exitReason ?? "", - }; + const record: PersistedTaskRecord = { + taskId, + laneNumber: lane?.laneNumber ?? outcome?.laneNumber ?? 0, + sessionName: outcome?.sessionName || lane?.laneSessionId || "", + status: outcome?.status ?? "pending", + taskFolder: "", // Enriched by caller from discovery + startedAt: outcome?.startTime ?? null, + endedAt: outcome?.endTime ?? null, + doneFileFound: outcome?.doneFileFound ?? false, + exitReason: outcome?.exitReason ?? "", + }; - // v2: Serialize repo-aware fields from the ParsedTask - if (allocated?.allocatedTask.task?.promptRepoId !== undefined) { - record.repoId = allocated.allocatedTask.task.promptRepoId; - } - if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { - record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; - } + // v2: Serialize repo-aware fields from the ParsedTask + if (allocated?.allocatedTask.task?.promptRepoId !== undefined) { + record.repoId = allocated.allocatedTask.task.promptRepoId; + } + if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { + record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; + } - // TP-028: Serialize partial progress fields from task outcome - if (outcome?.partialProgressCommits !== undefined) { - record.partialProgressCommits = outcome.partialProgressCommits; - } - if (outcome?.partialProgressBranch !== undefined) { - record.partialProgressBranch = outcome.partialProgressBranch; - } + // TP-028: Serialize partial progress fields from task outcome + if (outcome?.partialProgressCommits !== undefined) { + record.partialProgressCommits = outcome.partialProgressCommits; + } + if (outcome?.partialProgressBranch !== undefined) { + record.partialProgressBranch = outcome.partialProgressBranch; + } - // TP-030 v3: Serialize exit diagnostic from task outcome - if (outcome?.exitDiagnostic !== undefined) { - record.exitDiagnostic = outcome.exitDiagnostic; - } + // TP-030 v3: Serialize exit diagnostic from task outcome + if (outcome?.exitDiagnostic !== undefined) { + record.exitDiagnostic = outcome.exitDiagnostic; + } - // TP-081 v4: Serialize segment-level fields from ParsedTask or existing state - if (allocated?.allocatedTask.task?.packetRepoId !== undefined) { - (record as any).packetRepoId = allocated.allocatedTask.task.packetRepoId; - } - if (allocated?.allocatedTask.task?.packetTaskPath !== undefined) { - (record as any).packetTaskPath = allocated.allocatedTask.task.packetTaskPath; - } - if (allocated?.allocatedTask.task?.segmentIds !== undefined) { - (record as any).segmentIds = allocated.allocatedTask.task.segmentIds; - } - if (allocated?.allocatedTask.task?.activeSegmentId !== undefined) { - (record as any).activeSegmentId = allocated.allocatedTask.task.activeSegmentId; - } + // TP-081 v4: Serialize segment-level fields from ParsedTask or existing state + if (allocated?.allocatedTask.task?.packetRepoId !== undefined) { + (record as any).packetRepoId = allocated.allocatedTask.task.packetRepoId; + } + if (allocated?.allocatedTask.task?.packetTaskPath !== undefined) { + (record as any).packetTaskPath = allocated.allocatedTask.task.packetTaskPath; + } + if (allocated?.allocatedTask.task?.segmentIds !== undefined) { + (record as any).segmentIds = allocated.allocatedTask.task.segmentIds; + } + if (allocated?.allocatedTask.task?.activeSegmentId !== undefined) { + (record as any).activeSegmentId = allocated.allocatedTask.task.activeSegmentId; + } - return record; - }); + return record; + }); // Build lane records const laneRecords: PersistedLaneRecord[] = lanes.map((lane) => { @@ -1326,26 +1444,25 @@ export function serializeBatchState( // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1"). // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1, // which would produce -2 without clamping. - const mergeResults: PersistedMergeResult[] = (state.mergeResults || []) - .map((mr) => { - const record: PersistedMergeResult = { - waveIndex: Math.max(0, mr.waveIndex - 1), - status: mr.status, - failedLane: mr.failedLane, - failureReason: mr.failureReason, - }; - // v2 (TP-009): Serialize per-repo merge outcomes when available (workspace mode). - if (mr.repoResults && mr.repoResults.length > 0) { - record.repoResults = mr.repoResults.map((rr) => ({ - repoId: rr.repoId, - status: rr.status, - laneNumbers: rr.laneResults.map((lr) => lr.laneNumber), - failedLane: rr.failedLane, - failureReason: rr.failureReason, - })); - } - return record; - }); + const mergeResults: PersistedMergeResult[] = (state.mergeResults || []).map((mr) => { + const record: PersistedMergeResult = { + waveIndex: Math.max(0, mr.waveIndex - 1), + status: mr.status, + failedLane: mr.failedLane, + failureReason: mr.failureReason, + }; + // v2 (TP-009): Serialize per-repo merge outcomes when available (workspace mode). + if (mr.repoResults && mr.repoResults.length > 0) { + record.repoResults = mr.repoResults.map((rr) => ({ + repoId: rr.repoId, + status: rr.status, + laneNumbers: rr.laneResults.map((lr) => lr.laneNumber), + failedLane: rr.failedLane, + failureReason: rr.failureReason, + })); + } + return record; + }); const persisted: PersistedBatchState = { schemaVersion: BATCH_STATE_SCHEMA_VERSION, @@ -1372,9 +1489,10 @@ export function serializeBatchState( skippedTasks: state.skippedTasks, blockedTasks: state.blockedTasks, blockedTaskIds: [...state.blockedTaskIds], - lastError: state.errors.length > 0 - ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] } - : null, + lastError: + state.errors.length > 0 + ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] } + : null, errors: [...state.errors], resilience: state.resilience ?? defaultResilienceState(), diagnostics: state.diagnostics ?? defaultBatchDiagnostics(), @@ -1461,12 +1579,16 @@ export function saveBatchState(json: string, repoRoot: string): void { } // All retries exhausted — clean up temp file if possible - try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ } + try { + unlinkSync(tmpPath); + } catch { + /* ignore cleanup errors */ + } throw new StateFileError( "STATE_FILE_IO_ERROR", `Failed to atomically save state file "${finalPath}" after ` + - `${STATE_WRITE_MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`, + `${STATE_WRITE_MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`, ); } @@ -1533,7 +1655,6 @@ export function deleteBatchState(repoRoot: string): void { } } - // ── Orphan Detection (TS-009 Step 3) ───────────────────────────────── /** @@ -1555,7 +1676,12 @@ export type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error"; * - "paused-corrupt" — No orphans + corrupt/unreadable state file: do NOT auto-delete; notify user to inspect or manually remove * - "start-fresh" — No orphans, no state file: proceed normally */ -export type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "paused-corrupt" | "start-fresh"; +export type OrphanRecommendedAction = + | "resume" + | "abort-orphans" + | "cleanup-stale" + | "paused-corrupt" + | "start-fresh"; /** * Result of orphan detection analysis. @@ -1597,8 +1723,8 @@ export function parseOrchSessionNames(stdout: string, prefix: string): string[] return stdout .split("\n") - .map(line => line.trim()) - .filter(name => name.length > 0 && name.startsWith(filterPrefix)) + .map((line) => line.trim()) + .filter((name) => name.length > 0 && name.startsWith(filterPrefix)) .sort(); } @@ -1687,8 +1813,8 @@ export function analyzeOrchestratorStartupState( if (stateStatus === "valid" && loadedState) { // Check if all tasks completed (all have .DONE files) - const allTaskIds = loadedState.tasks.map(t => t.taskId); - const allDone = allTaskIds.length > 0 && allTaskIds.every(id => doneTaskIds.has(id)); + const allTaskIds = loadedState.tasks.map((t) => t.taskId); + const allDone = allTaskIds.length > 0 && allTaskIds.every((id) => doneTaskIds.has(id)); if (allDone) { return { @@ -1704,7 +1830,7 @@ export function analyzeOrchestratorStartupState( } // Not all tasks done — batch was interrupted (crashed orchestrator) - const completedCount = allTaskIds.filter(id => doneTaskIds.has(id)).length; + const completedCount = allTaskIds.filter((id) => doneTaskIds.has(id)).length; // Only phases that resumeOrchBatch can actually handle should get "resume". // "failed" / "stopped" / "idle" / "planning" are non-resumable — if nothing @@ -1734,10 +1860,10 @@ export function analyzeOrchestratorStartupState( recommendedAction: isResumable ? "resume" : "cleanup-stale", userMessage: isResumable ? `🔄 Found interrupted batch ${loadedState.batchId} (${loadedState.phase}).\n` + - ` ${completedCount}/${allTaskIds.length} task(s) completed.\n` + - ` Use /orch-resume to continue, or /orch-abort to clean up.` + ` ${completedCount}/${allTaskIds.length} task(s) completed.\n` + + ` Use /orch-resume to continue, or /orch-abort to clean up.` : `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}).\n` + - ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`, + ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`, }; } @@ -1823,7 +1949,6 @@ export function detectOrphanSessions(prefix: string, repoRoot: string): OrphanDe ); } - // ── Batch History ──────────────────────────────────────────────────── /** Path to the batch history file. */ @@ -1858,7 +1983,7 @@ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary) const history = loadBatchHistory(repoRoot); // Upsert by batchId so resumed batches replace their earlier partial entry // instead of creating duplicates. - const nextHistory = history.filter(entry => entry.batchId !== summary.batchId); + const nextHistory = history.filter((entry) => entry.batchId !== summary.batchId); // Prepend newest first nextHistory.unshift(summary); // Trim to max @@ -1884,13 +2009,21 @@ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary) * * @since TP-179 */ -export function updateBatchHistoryIntegration(repoRoot: string, batchId: string, integratedAt: number): void { +export function updateBatchHistoryIntegration( + repoRoot: string, + batchId: string, + integratedAt: number, +): void { const filePath = batchHistoryPath(repoRoot); try { const history = loadBatchHistory(repoRoot); - const entry = history.find(e => e.batchId === batchId); + const entry = history.find((e) => e.batchId === batchId); if (!entry) { - execLog("batch", "history", `no history entry found for batchId=${batchId}, skipping integratedAt update`); + execLog( + "batch", + "history", + `no history entry found for batchId=${batchId}, skipping integratedAt update`, + ); return; } entry.integratedAt = integratedAt; @@ -1905,7 +2038,6 @@ export function updateBatchHistoryIntegration(repoRoot: string, batchId: string, } } - // ── Tier 0 Supervisor Event Logging (TP-039 Step 2) ───────────────── /** @@ -1986,7 +2118,10 @@ export function buildTier0EventBase( pattern: Tier0RecoveryPattern | "merge_timeout", attempt: number, maxAttempts: number, -): Pick { +): Pick< + Tier0Event, + "timestamp" | "type" | "batchId" | "waveIndex" | "pattern" | "attempt" | "maxAttempts" +> { return { timestamp: new Date().toISOString(), type, @@ -2028,7 +2163,6 @@ export function emitTier0Event(stateRoot: string, event: Tier0Event): void { } } - // ── Engine Event Logging (TP-040) ─────────────────────────────────── /** @@ -2085,7 +2219,6 @@ export function emitEngineEvent( } } - // ── TP-187 (#539): Batch-Meta Runtime Artifact ───────────────────── // // Small JSON file written at batch-start to `.pi/runtime//batch-meta.json`. @@ -2129,10 +2262,7 @@ function batchMetaPath(stateRoot: string, batchId: string): string { * * @since TP-187 (#539) */ -export function saveBatchMetaRuntimeArtifact( - stateRoot: string, - artifact: BatchMetaArtifact, -): void { +export function saveBatchMetaRuntimeArtifact(stateRoot: string, artifact: BatchMetaArtifact): void { try { const path = batchMetaPath(stateRoot, artifact.batchId); mkdirSync(dirname(path), { recursive: true }); @@ -2144,7 +2274,11 @@ export function saveBatchMetaRuntimeArtifact( tasks: artifact.wavePlan.reduce((sum, w) => sum + w.length, 0), }); } catch (err) { - execLog("state", artifact.batchId, `batch-meta write failed: ${err instanceof Error ? err.message : String(err)}`); + execLog( + "state", + artifact.batchId, + `batch-meta write failed: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -2184,7 +2318,6 @@ export function loadBatchMetaRuntimeArtifact( } } - // ── TP-187 (#539): Reconstruct PersistedBatchState from runtime artifacts ── /** @@ -2296,7 +2429,9 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct failures.push(`${cand.batchId}: no worker manifests`); continue; } - const workerManifestsWithWorktree = manifests.filter(m => typeof m.cwd === "string" && m.cwd.length > 0 && existsSync(m.cwd)); + const workerManifestsWithWorktree = manifests.filter( + (m) => typeof m.cwd === "string" && m.cwd.length > 0 && existsSync(m.cwd), + ); if (workerManifestsWithWorktree.length === 0) { failures.push(`${cand.batchId}: worktree paths from manifests no longer exist on disk`); continue; @@ -2322,16 +2457,19 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct if (distinctRepoIds.size > 1) { failures.push( `${cand.batchId}: multi-repo batch detected (${distinctRepoIds.size} distinct repoIds: ` + - `${[...distinctRepoIds].slice(0, 4).join(", ")}` + - `${distinctRepoIds.size > 4 ? ", ..." : ""}); reconstruction would lose segment ` + - `expansion state and is refused. Restore .pi/batch-state.json from backup or start a new batch.` + `${[...distinctRepoIds].slice(0, 4).join(", ")}` + + `${distinctRepoIds.size > 4 ? ", ..." : ""}); reconstruction would lose segment ` + + `expansion state and is refused. Restore .pi/batch-state.json from backup or start a new batch.`, ); continue; } } // Build per-lane aggregation from worker manifests. - const laneMap = new Map(); + const laneMap = new Map< + number, + { laneNumber: number; agentId: string; worktreePath: string; repoId: string; taskIds: string[] } + >(); for (const m of workerManifestsWithWorktree) { if (typeof m.laneNumber !== "number") continue; const lane = laneMap.get(m.laneNumber) ?? { @@ -2386,15 +2524,17 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct doneFileFound: false, }; if (m?.repoId) taskRecord.repoId = m.repoId; - if (m?.packet?.packetRepoId) (taskRecord as Record).packetRepoId = m.packet.packetRepoId; - if (m?.packet?.packetTaskPath) (taskRecord as Record).packetTaskPath = m.packet.packetTaskPath; + if (m?.packet?.packetRepoId) + (taskRecord as Record).packetRepoId = m.packet.packetRepoId; + if (m?.packet?.packetTaskPath) + (taskRecord as Record).packetTaskPath = m.packet.packetTaskPath; tasks.push(taskRecord); } // Build lane records. const lanes: PersistedLaneRecord[] = Array.from(laneMap.values()) .sort((a, b) => a.laneNumber - b.laneNumber) - .map(l => { + .map((l) => { const sessionId = l.agentId.replace(/-(worker|reviewer)$/, ""); const rec: PersistedLaneRecord = { laneId: `lane-${l.laneNumber}`, @@ -2426,7 +2566,7 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct failedTasks: 0, skippedTasks: 0, blockedTasks: 0, - wavePlan: meta.wavePlan.map(wave => [...wave]), + wavePlan: meta.wavePlan.map((wave) => [...wave]), lanes, tasks, mergeResults: [], @@ -2443,14 +2583,17 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct const json = JSON.stringify(reconstructed); validatePersistedState(JSON.parse(json)); } catch (err) { - failures.push(`${cand.batchId}: reconstructed state failed validation: ${err instanceof Error ? err.message : String(err)}`); + failures.push( + `${cand.batchId}: reconstructed state failed validation: ${err instanceof Error ? err.message : String(err)}`, + ); continue; } const totalCandidates = candidates.length; - const selectionNote = totalCandidates === 1 - ? `single batch in .pi/runtime/` - : `selected from ${totalCandidates} candidate(s) by mtime newest-first (skipped ${idx} earlier candidate(s))`; + const selectionNote = + totalCandidates === 1 + ? `single batch in .pi/runtime/` + : `selected from ${totalCandidates} candidate(s) by mtime newest-first (skipped ${idx} earlier candidate(s))`; return { ok: true, state: reconstructed, batchId: meta.batchId, selectionNote }; } @@ -2459,4 +2602,3 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct error: `no reconstructable batch found in .pi/runtime/ (${failures.length} candidate(s) inspected: ${failures.slice(0, 3).join("; ")}${failures.length > 3 ? "; ..." : ""})`, }; } - diff --git a/extensions/taskplane/process-registry.ts b/extensions/taskplane/process-registry.ts index 8adc90e7..078a723a 100644 --- a/extensions/taskplane/process-registry.ts +++ b/extensions/taskplane/process-registry.ts @@ -19,7 +19,16 @@ * @since TP-104 */ -import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, appendFileSync, renameSync } from "fs"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, + rmSync, + appendFileSync, + renameSync, +} from "fs"; import { join, dirname } from "path"; import { @@ -66,7 +75,11 @@ export function writeManifest(stateRoot: string, manifest: RuntimeAgentManifest) * * @since TP-104 */ -export function readManifest(stateRoot: string, batchId: string, agentId: RuntimeAgentId): RuntimeAgentManifest | null { +export function readManifest( + stateRoot: string, + batchId: string, + agentId: RuntimeAgentId, +): RuntimeAgentManifest | null { const path = runtimeManifestPath(stateRoot, batchId, agentId); if (!existsSync(path)) return null; try { @@ -237,7 +250,7 @@ export function isTerminalStatus(status: RuntimeAgentStatus): boolean { * @since TP-104 */ export function getLiveAgents(registry: RuntimeRegistry): RuntimeAgentManifest[] { - return Object.values(registry.agents).filter(m => !isTerminalStatus(m.status)); + return Object.values(registry.agents).filter((m) => !isTerminalStatus(m.status)); } /** @@ -245,8 +258,11 @@ export function getLiveAgents(registry: RuntimeRegistry): RuntimeAgentManifest[] * * @since TP-104 */ -export function getAgentsByRole(registry: RuntimeRegistry, role: RuntimeAgentRole): RuntimeAgentManifest[] { - return Object.values(registry.agents).filter(m => m.role === role); +export function getAgentsByRole( + registry: RuntimeRegistry, + role: RuntimeAgentRole, +): RuntimeAgentManifest[] { + return Object.values(registry.agents).filter((m) => m.role === role); } // ── Orphan Detection ───────────────────────────────────────────────── @@ -276,7 +292,11 @@ export function detectOrphans(registry: RuntimeRegistry): RuntimeAgentId[] { * * @since TP-104 */ -export function markOrphansCrashed(stateRoot: string, batchId: string, orphanIds: RuntimeAgentId[]): void { +export function markOrphansCrashed( + stateRoot: string, + batchId: string, + orphanIds: RuntimeAgentId[], +): void { for (const agentId of orphanIds) { updateManifestStatus(stateRoot, batchId, agentId, "crashed"); } @@ -291,7 +311,10 @@ export function markOrphansCrashed(stateRoot: string, batchId: string, orphanIds * * @since TP-104 */ -export function cleanupBatchRuntime(stateRoot: string, batchId: string): { removed: boolean; error?: string } { +export function cleanupBatchRuntime( + stateRoot: string, + batchId: string, +): { removed: boolean; error?: string } { const root = runtimeRoot(stateRoot, batchId); if (!existsSync(root)) return { removed: false }; try { diff --git a/extensions/taskplane/quality-gate.ts b/extensions/taskplane/quality-gate.ts index eb94a657..b925dbda 100644 --- a/extensions/taskplane/quality-gate.ts +++ b/extensions/taskplane/quality-gate.ts @@ -115,9 +115,7 @@ export function applyVerdictRules( const failReasons: VerdictFailReason[] = []; // Rule 1: Any status_mismatch category → NEEDS_FIXES - const statusMismatches = verdict.findings.filter( - (f) => f.category === "status_mismatch", - ); + const statusMismatches = verdict.findings.filter((f) => f.category === "status_mismatch"); if (statusMismatches.length > 0) { failReasons.push({ rule: "status_mismatch", @@ -135,9 +133,7 @@ export function applyVerdictRules( } // Rule 3: Threshold-dependent important check - const importants = verdict.findings.filter( - (f) => f.severity === "important", - ); + const importants = verdict.findings.filter((f) => f.severity === "important"); if (threshold === "no_important" && importants.length >= 3) { failReasons.push({ @@ -167,14 +163,8 @@ export function applyVerdictRules( } // For all_clear threshold: even suggestions-only should fail - if ( - threshold === "all_clear" && - failReasons.length === 0 && - verdict.findings.length > 0 - ) { - const suggestions = verdict.findings.filter( - (f) => f.severity === "suggestion", - ); + if (threshold === "all_clear" && failReasons.length === 0 && verdict.findings.length > 0) { + const suggestions = verdict.findings.filter((f) => f.severity === "suggestion"); if (suggestions.length > 0) { failReasons.push({ rule: "important_threshold", @@ -228,7 +218,10 @@ export function parseVerdict(jsonString: string | undefined | null): ReviewVerdi } if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return { ...FAIL_OPEN_VERDICT, summary: "Verdict is not a JSON object — fail-open policy applied" }; + return { + ...FAIL_OPEN_VERDICT, + summary: "Verdict is not a JSON object — fail-open policy applied", + }; } const obj = raw as Record; @@ -236,7 +229,10 @@ export function parseVerdict(jsonString: string | undefined | null): ReviewVerdi // Validate verdict field const verdict = obj.verdict; if (verdict !== "PASS" && verdict !== "NEEDS_FIXES") { - return { ...FAIL_OPEN_VERDICT, summary: `Invalid verdict value "${String(verdict)}" — fail-open policy applied` }; + return { + ...FAIL_OPEN_VERDICT, + summary: `Invalid verdict value "${String(verdict)}" — fail-open policy applied`, + }; } // Parse confidence with fallback @@ -396,7 +392,10 @@ function buildGitDiff(cwd: string): { diff: string; fileList: string } { try { const base = computeDiffBase(cwd); if (!base) { - return { diff: "(git diff unavailable — could not determine base)", fileList: "(file list unavailable)" }; + return { + diff: "(git diff unavailable — could not determine base)", + fileList: "(file list unavailable)", + }; } const range = `${base}..HEAD`; @@ -407,9 +406,7 @@ function buildGitDiff(cwd: string): { diff: string; fileList: string } { cwd, timeout: 30000, }); - const fileList = fileListResult.status === 0 - ? fileListResult.stdout.trim() - : ""; + const fileList = fileListResult.status === 0 ? fileListResult.stdout.trim() : ""; // Get full diff (truncated to avoid blowing up context) const diffResult = spawnSync("git", ["diff", range], { @@ -418,9 +415,7 @@ function buildGitDiff(cwd: string): { diff: string; fileList: string } { timeout: 30000, maxBuffer: 200 * 1024, // 200KB max }); - const diff = diffResult.status === 0 - ? diffResult.stdout.trim() - : "(git diff unavailable)"; + const diff = diffResult.status === 0 ? diffResult.stdout.trim() : "(git diff unavailable)"; return { diff, fileList }; } catch { @@ -455,13 +450,17 @@ function buildThresholdRules(threshold: PassThreshold): string[] { const rules: string[] = []; // Common rules — always apply - rules.push(`- **NEEDS_FIXES** if any finding has category \`status_mismatch\` (checkbox claims work is done but it isn't)`); + rules.push( + `- **NEEDS_FIXES** if any finding has category \`status_mismatch\` (checkbox claims work is done but it isn't)`, + ); rules.push(`- **NEEDS_FIXES** if any finding has severity \`critical\``); // Threshold-specific rules switch (threshold) { case "no_critical": - rules.push(`- **PASS** even if there are \`important\` or \`suggestion\` findings (threshold: \`no_critical\`)`); + rules.push( + `- **PASS** even if there are \`important\` or \`suggestion\` findings (threshold: \`no_critical\`)`, + ); break; case "no_important": rules.push(`- **NEEDS_FIXES** if 3 or more findings have severity \`important\``); @@ -488,22 +487,27 @@ export function generateQualityGatePrompt(context: QualityGateContext, cwd: stri if (existsSync(context.promptPath)) { promptContent = readFileSync(context.promptPath, "utf-8"); } - } catch { /* fail-open: proceed without */ } + } catch { + /* fail-open: proceed without */ + } let statusContent = "(STATUS.md not found)"; try { if (existsSync(statusPath)) { statusContent = readFileSync(statusPath, "utf-8"); } - } catch { /* fail-open: proceed without */ } + } catch { + /* fail-open: proceed without */ + } const { diff, fileList } = buildGitDiff(cwd); // Truncate diff if too long (keep first 100KB) const maxDiffLen = 100 * 1024; - const truncatedDiff = diff.length > maxDiffLen - ? diff.slice(0, maxDiffLen) + "\n\n... (diff truncated at 100KB) ..." - : diff; + const truncatedDiff = + diff.length > maxDiffLen + ? diff.slice(0, maxDiffLen) + "\n\n... (diff truncated at 100KB) ..." + : diff; return [ `# Quality Gate Review`, @@ -670,9 +674,9 @@ export interface ReconciliationAction { */ function normalizeCheckboxText(text: string): string { return text - .replace(/\*\*|__|``|`/g, "") // strip bold/code formatting - .replace(/\s+/g, " ") // collapse whitespace - .replace(/^\s*[-*•]\s*/, "") // strip leading bullets + .replace(/\*\*|__|``|`/g, "") // strip bold/code formatting + .replace(/\s+/g, " ") // collapse whitespace + .replace(/^\s*[-*•]\s*/, "") // strip leading bullets .trim() .toLowerCase(); } @@ -718,7 +722,11 @@ export function applyStatusReconciliation( // No STATUS.md — mark all as unmatched for (const r of reconciliations) { result.unmatched++; - result.actions.push({ checkbox: r.checkbox, outcome: "unmatched", reason: "STATUS.md not found" }); + result.actions.push({ + checkbox: r.checkbox, + outcome: "unmatched", + reason: "STATUS.md not found", + }); } return result; } @@ -726,7 +734,11 @@ export function applyStatusReconciliation( } catch { for (const r of reconciliations) { result.unmatched++; - result.actions.push({ checkbox: r.checkbox, outcome: "unmatched", reason: "STATUS.md unreadable" }); + result.actions.push({ + checkbox: r.checkbox, + outcome: "unmatched", + reason: "STATUS.md unreadable", + }); } return result; } @@ -742,7 +754,11 @@ export function applyStatusReconciliation( const normalizedRecon = normalizeCheckboxText(recon.checkbox); if (!normalizedRecon) { result.unmatched++; - result.actions.push({ checkbox: recon.checkbox, outcome: "unmatched", reason: "Empty checkbox text after normalization" }); + result.actions.push({ + checkbox: recon.checkbox, + outcome: "unmatched", + reason: "Empty checkbox text after normalization", + }); continue; } @@ -755,7 +771,11 @@ export function applyStatusReconciliation( const lineText = normalizeCheckboxText(cbMatch[4]); // Match if either contains the other (handles paraphrasing) - if (lineText === normalizedRecon || lineText.includes(normalizedRecon) || normalizedRecon.includes(lineText)) { + if ( + lineText === normalizedRecon || + lineText.includes(normalizedRecon) || + normalizedRecon.includes(lineText) + ) { matchedIdx = i; break; } @@ -763,7 +783,11 @@ export function applyStatusReconciliation( if (matchedIdx === -1) { result.unmatched++; - result.actions.push({ checkbox: recon.checkbox, outcome: "unmatched", reason: "No matching checkbox found in STATUS.md" }); + result.actions.push({ + checkbox: recon.checkbox, + outcome: "unmatched", + reason: "No matching checkbox found in STATUS.md", + }); continue; } @@ -779,7 +803,11 @@ export function applyStatusReconciliation( if (shouldBeChecked && currentlyChecked) { // Already correct result.alreadyCorrect++; - result.actions.push({ checkbox: recon.checkbox, outcome: "no_change", reason: "Already checked (done)" }); + result.actions.push({ + checkbox: recon.checkbox, + outcome: "no_change", + reason: "Already checked (done)", + }); } else if (!shouldBeChecked && !currentlyChecked) { // Already correct (unchecked for not_done or partial) // But if partial, might need annotation @@ -787,25 +815,38 @@ export function applyStatusReconciliation( // Add partial annotation lines[matchedIdx] = `${cbMatch[1]} ${cbMatch[3]}${currentText} (partial)`; result.changed++; - result.actions.push({ checkbox: recon.checkbox, outcome: "unchecked", reason: "Added (partial) annotation" }); + result.actions.push({ + checkbox: recon.checkbox, + outcome: "unchecked", + reason: "Added (partial) annotation", + }); } else { result.alreadyCorrect++; - result.actions.push({ checkbox: recon.checkbox, outcome: "no_change", reason: `Already unchecked (${recon.actualState})` }); + result.actions.push({ + checkbox: recon.checkbox, + outcome: "no_change", + reason: `Already unchecked (${recon.actualState})`, + }); } } else if (shouldBeChecked && !currentlyChecked) { // Need to check lines[matchedIdx] = `${cbMatch[1]}x${cbMatch[3]}${currentText}`; result.changed++; - result.actions.push({ checkbox: recon.checkbox, outcome: "checked", reason: "Work done but box was unchecked" }); + result.actions.push({ + checkbox: recon.checkbox, + outcome: "checked", + reason: "Work done but box was unchecked", + }); } else { // currentlyChecked but should not be (not_done or partial) const annotation = recon.actualState === "partial" ? " (partial)" : ""; const cleanText = currentText.replace(/\s*\(partial\)\s*$/, ""); lines[matchedIdx] = `${cbMatch[1]} ${cbMatch[3]}${cleanText}${annotation}`; result.changed++; - const outcomeReason = recon.actualState === "partial" - ? "Unchecked — work partially done" - : "Unchecked — work not done"; + const outcomeReason = + recon.actualState === "partial" + ? "Unchecked — work partially done" + : "Unchecked — work not done"; result.actions.push({ checkbox: recon.checkbox, outcome: "unchecked", reason: outcomeReason }); } } @@ -860,10 +901,10 @@ export function generateFeedbackMd( maxCycles: number, passThreshold: PassThreshold = "no_critical", ): string { - const criticals = verdict.findings.filter(f => f.severity === "critical"); - const importants = verdict.findings.filter(f => f.severity === "important"); - const suggestions = verdict.findings.filter(f => f.severity === "suggestion"); - const mismatches = verdict.statusReconciliation.filter(r => r.actualState !== "done"); + const criticals = verdict.findings.filter((f) => f.severity === "critical"); + const importants = verdict.findings.filter((f) => f.severity === "important"); + const suggestions = verdict.findings.filter((f) => f.severity === "suggestion"); + const mismatches = verdict.statusReconciliation.filter((r) => r.actualState !== "done"); // Under all_clear, suggestions are also blocking const includeSuggestions = passThreshold === "all_clear"; @@ -940,14 +981,21 @@ export function generateFeedbackMd( lines.push(``); } - const totalBlocking = criticals.length + importants.length - + (includeSuggestions ? suggestions.length : 0) + mismatches.length; + const totalBlocking = + criticals.length + + importants.length + + (includeSuggestions ? suggestions.length : 0) + + mismatches.length; if (totalBlocking === 0) { lines.push(`## No blocking findings`); lines.push(``); - lines.push(`The review returned NEEDS_FIXES but no blocking findings were extracted for threshold \`${passThreshold}\`.`); - lines.push(`This may indicate a threshold or verdict-rule mismatch. Review the REVIEW_VERDICT.json for details.`); + lines.push( + `The review returned NEEDS_FIXES but no blocking findings were extracted for threshold \`${passThreshold}\`.`, + ); + lines.push( + `This may indicate a threshold or verdict-rule mismatch. Review the REVIEW_VERDICT.json for details.`, + ); lines.push(``); } @@ -978,14 +1026,18 @@ export function buildFixAgentPrompt( if (existsSync(statusPath)) { statusContent = readFileSync(statusPath, "utf-8"); } - } catch { /* proceed without */ } + } catch { + /* proceed without */ + } let promptContent = "(PROMPT.md not found)"; try { if (existsSync(context.promptPath)) { promptContent = readFileSync(context.promptPath, "utf-8"); } - } catch { /* proceed without */ } + } catch { + /* proceed without */ + } return [ `# Quality Gate Remediation — Fix Cycle ${cycleNum}`, diff --git a/extensions/taskplane/resume.ts b/extensions/taskplane/resume.ts index ca3fe22f..5b45e408 100644 --- a/extensions/taskplane/resume.ts +++ b/extensions/taskplane/resume.ts @@ -7,8 +7,21 @@ import { join } from "path"; import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts"; import { runDiscovery } from "./discovery.ts"; -import { executeOrchBatch, resolveDisplayWaveNumber, buildSpawnFailureAlertExtras } from "./engine.ts"; -import { buildReviewerEnv, buildWorkerEnv, buildWorkerExcludeEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts"; +import { + executeOrchBatch, + resolveDisplayWaveNumber, + buildSpawnFailureAlertExtras, +} from "./engine.ts"; +import { + buildReviewerEnv, + buildWorkerEnv, + buildWorkerExcludeEnv, + computeTransitiveDependents, + execLog, + executeLaneV2, + executeWave, + resolveCanonicalTaskPaths, +} from "./execution.ts"; import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts"; import { selectRuntimeBackend } from "./engine.ts"; import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts"; @@ -28,20 +41,74 @@ function terminateAliveV2Agents(stateRoot: string, batchId: string, sessionName: try { process.kill(manifest.pid, "SIGTERM"); execLog("resume", key, `terminated alive V2 agent (PID ${manifest.pid}) before re-execute`); - } catch { /* already dead */ } + } catch { + /* already dead */ + } } } } import { getCurrentBranch, runGit } from "./git.ts"; import { mergeWaveByRepo } from "./merge.ts"; -import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts"; +import { + applyMergeRetryLoop, + computeCleanupGatePolicy, + computeMergeFailurePolicy, + extractFailedRepoId, + formatRepoMergeSummary, + ORCH_MESSAGES, +} from "./messages.ts"; import type { CleanupGateRepoFailure } from "./messages.ts"; import { resolveOperatorId } from "./naming.ts"; -import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, reconstructBatchStateFromRuntime, saveBatchState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts"; -import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, StateFileError } from "./types.ts"; -import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, PersistedSegmentRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts"; +import { + applyPartialProgressToOutcomes, + deleteBatchState, + hasTaskDoneMarker, + loadBatchState, + persistRuntimeState, + reconstructBatchStateFromRuntime, + saveBatchState, + seedPendingOutcomesForAllocatedLanes, + syncTaskOutcomesFromMonitor, + upsertTaskOutcome, +} from "./persistence.ts"; +import { + buildBatchProgressSnapshot, + buildSupervisorSegmentFrontierSnapshot, + defaultResilienceState, + StateFileError, +} from "./types.ts"; +import type { + AllocatedLane, + AllocatedTask, + LaneExecutionResult, + LaneTaskOutcome, + LaneTaskStatus, + MergeWaveResult, + OrchBatchPhase, + OrchBatchRuntimeState, + OrchestratorConfig, + ParsedTask, + PersistedBatchState, + PersistedLaneRecord, + PersistedSegmentRecord, + ReconciledTaskState, + ResumeEligibility, + ResumePoint, + TaskRunnerConfig, + WaveExecutionResult, + WorkspaceConfig, +} from "./types.ts"; import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts"; -import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, safeResetWorktree, sleepSync } from "./worktree.ts"; +import { + deleteBranchBestEffort, + forceCleanupWorktree, + listWorktrees, + preserveFailedLaneProgress, + removeAllWorktrees, + removeWorktree, + safeResetWorktree, + sleepSync, +} from "./worktree.ts"; // ── Resume Repo Helpers ────────────────────────────────────────────── @@ -245,7 +312,7 @@ export function collectDoneTaskIdsForResume( doneTaskIds.add(task.taskId); continue; } - const laneRec = persistedState.lanes.find(l => l.taskIds.includes(task.taskId)); + const laneRec = persistedState.lanes.find((l) => l.taskIds.includes(task.taskId)); if (laneRec?.worktreePath && task.taskFolder) { const resolved = resolveCanonicalTaskPaths( task.taskFolder, @@ -281,7 +348,10 @@ export function collectDoneTaskIdsForResume( * @param state - Persisted batch state to check * @param force - When true, `stopped` and `failed` phases become eligible */ -export function checkResumeEligibility(state: PersistedBatchState, force: boolean = false): ResumeEligibility { +export function checkResumeEligibility( + state: PersistedBatchState, + force: boolean = false, +): ResumeEligibility { const { phase, batchId } = state; switch (phase) { @@ -394,7 +464,9 @@ interface SegmentFrontierResumeTaskState { dependencyBySegmentId: Map; } -function classifySegmentStatus(status: PersistedSegmentRecord["status"] | undefined): "completed" | "failed" | "in-flight" | "pending" { +function classifySegmentStatus( + status: PersistedSegmentRecord["status"] | undefined, +): "completed" | "failed" | "in-flight" | "pending" { if (status === "succeeded" || status === "skipped") return "completed"; if (status === "failed" || status === "stalled") return "failed"; if (status === "running") return "in-flight"; @@ -434,9 +506,13 @@ export function reconstructSegmentFrontier( if (record) hasConcreteSegmentRecord = true; const recordDeps = record?.dependsOnSegmentIds ?? []; const fallbackDeps = idx > 0 ? [segmentIds[idx - 1]] : []; - const deps = (recordDeps.length > 0 ? recordDeps : fallbackDeps) - .filter(dep => segmentIds.includes(dep)); - dependencyBySegmentId.set(segmentId, [...new Set(deps)].sort((a, b) => a.localeCompare(b))); + const deps = (recordDeps.length > 0 ? recordDeps : fallbackDeps).filter((dep) => + segmentIds.includes(dep), + ); + dependencyBySegmentId.set( + segmentId, + [...new Set(deps)].sort((a, b) => a.localeCompare(b)), + ); switch (classifySegmentStatus(record?.status)) { case "completed": @@ -457,13 +533,10 @@ export function reconstructSegmentFrontier( const completedSet = new Set(completedSegmentIds); const readyPending = pendingSegmentIds.filter((segmentId) => { const deps = dependencyBySegmentId.get(segmentId) ?? []; - return deps.every(dep => completedSet.has(dep)); + return deps.every((dep) => completedSet.has(dep)); }); - const nextSegmentId = inFlightSegmentIds[0] - ?? readyPending[0] - ?? pendingSegmentIds[0] - ?? null; + const nextSegmentId = inFlightSegmentIds[0] ?? readyPending[0] ?? pendingSegmentIds[0] ?? null; const allSucceeded = segmentIds.every((segmentId) => { const status = segmentRecordById.get(segmentId)?.status; return status === "succeeded"; @@ -795,7 +868,12 @@ export function computeResumePoint( case "skip": if (task.liveStatus === "succeeded" || task.persistedStatus === "succeeded") { completedTaskIds.push(task.taskId); - } else if (task.liveStatus === "failed" || task.liveStatus === "stalled" || task.persistedStatus === "failed" || task.persistedStatus === "stalled") { + } else if ( + task.liveStatus === "failed" || + task.liveStatus === "stalled" || + task.persistedStatus === "failed" || + task.persistedStatus === "stalled" + ) { failedTaskIds.push(task.taskId); } // persistedStatus === "skipped" → terminal but neither completed nor failed. @@ -830,10 +908,12 @@ export function computeResumePoint( const waveSegmentId = waveSegmentIdByTaskOccurrence.get(`${i}:${taskId}`); if (waveSegmentId && segmentStatusBySegmentId.has(waveSegmentId)) { const segmentStatus = segmentStatusBySegmentId.get(waveSegmentId)!; - return segmentStatus === "succeeded" - || segmentStatus === "failed" - || segmentStatus === "stalled" - || segmentStatus === "skipped"; + return ( + segmentStatus === "succeeded" || + segmentStatus === "failed" || + segmentStatus === "stalled" || + segmentStatus === "skipped" + ); } const reconciled = reconciledMap.get(taskId); if (!reconciled) return false; @@ -870,7 +950,11 @@ export function computeResumePoint( const reconciled = reconciledMap.get(taskId); if (!reconciled) return false; if (reconciled.action === "mark-complete") return true; - if (reconciled.action === "skip" && (reconciled.liveStatus === "succeeded" || reconciled.persistedStatus === "succeeded")) return true; + if ( + reconciled.action === "skip" && + (reconciled.liveStatus === "succeeded" || reconciled.persistedStatus === "succeeded") + ) + return true; return false; }); @@ -935,7 +1019,6 @@ export function computeResumePoint( }; } - // ── Pre-Resume Diagnostics ─────────────────────────────────────────── /** @@ -1001,7 +1084,10 @@ export function runPreResumeDiagnostics( const label = repoId ? `repo:${repoId}` : "default-repo"; if (persistedState.orchBranch) { - const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${persistedState.orchBranch}`], root); + const branchCheck = runGit( + ["rev-parse", "--verify", `refs/heads/${persistedState.orchBranch}`], + root, + ); if (branchCheck.ok) { checks.push({ check: `branch-consistency:${label}`, @@ -1012,7 +1098,8 @@ export function runPreResumeDiagnostics( checks.push({ check: `branch-consistency:${label}`, passed: false, - detail: `Orch branch "${persistedState.orchBranch}" not found in ${label}. ` + + detail: + `Orch branch "${persistedState.orchBranch}" not found in ${label}. ` + `The branch may have been deleted or the repo is in an inconsistent state.`, }); } @@ -1045,18 +1132,17 @@ export function runPreResumeDiagnostics( } } - const failed = checks.filter(c => !c.passed); + const failed = checks.filter((c) => !c.passed); const passed = failed.length === 0; const summary = passed ? `✅ Pre-resume diagnostics passed (${checks.length} checks)` : `❌ Pre-resume diagnostics failed (${failed.length}/${checks.length} checks failed):\n` + - failed.map(c => ` • ${c.check}: ${c.detail}`).join("\n"); + failed.map((c) => ` • ${c.check}: ${c.detail}`).join("\n"); return { passed, checks, summary }; } - export async function resumeOrchBatch( orchConfig: OrchestratorConfig, runnerConfig: TaskRunnerConfig, @@ -1108,10 +1194,7 @@ export async function resumeOrchBatch( persistedState = loadBatchState(stateRoot); } catch (err: unknown) { if (err instanceof StateFileError) { - onNotify( - `❌ Cannot resume: ${err.message}`, - "error", - ); + onNotify(`❌ Cannot resume: ${err.message}`, "error"); // ── TP-040 R006: Reset phase on pre-execution early return ── // The caller may have set batchState.phase = "launching" before // calling this function. Since we're returning without starting @@ -1124,10 +1207,7 @@ export async function resumeOrchBatch( if (!persistedState) { if (!force) { - onNotify( - ORCH_MESSAGES.resumeNoState(), - "error", - ); + onNotify(ORCH_MESSAGES.resumeNoState(), "error"); // TP-040 R006: Reset phase on pre-execution early return batchState.phase = "idle"; return; @@ -1137,10 +1217,7 @@ export async function resumeOrchBatch( // by `orch_abort()` even though `.pi/batch-state.json` is deleted). const reconstruction = reconstructBatchStateFromRuntime(stateRoot); if (!reconstruction.ok) { - onNotify( - ORCH_MESSAGES.resumeNoStateAfterAbort(reconstruction.error, null), - "error", - ); + onNotify(ORCH_MESSAGES.resumeNoStateAfterAbort(reconstruction.error, null), "error"); // TP-040 R006: Reset phase on pre-execution early return batchState.phase = "idle"; return; @@ -1172,7 +1249,11 @@ export async function resumeOrchBatch( const eligibility = checkResumeEligibility(persistedState, force); if (!eligibility.eligible) { onNotify( - ORCH_MESSAGES.resumePhaseNotResumable(persistedState.batchId, persistedState.phase, eligibility.reason), + ORCH_MESSAGES.resumePhaseNotResumable( + persistedState.batchId, + persistedState.phase, + eligibility.reason, + ), "error", ); // TP-040 R006: Reset phase on pre-execution early return @@ -1181,7 +1262,8 @@ export async function resumeOrchBatch( } // ── 2b. Force-resume: pre-resume diagnostics & state mutation ── - const isForceResume = force && (persistedState.phase === "stopped" || persistedState.phase === "failed"); + const isForceResume = + force && (persistedState.phase === "stopped" || persistedState.phase === "failed"); if (isForceResume) { onNotify( ORCH_MESSAGES.forceResumeStarting(persistedState.batchId, persistedState.phase), @@ -1193,10 +1275,7 @@ export async function resumeOrchBatch( onNotify(diagnostics.summary, diagnostics.passed ? "info" : "error"); if (!diagnostics.passed) { - onNotify( - ORCH_MESSAGES.forceResumeDiagnosticsFailed(persistedState.batchId), - "error", - ); + onNotify(ORCH_MESSAGES.forceResumeDiagnosticsFailed(persistedState.batchId), "error"); // TP-040 R006: Reset phase on pre-execution early return batchState.phase = "idle"; return; @@ -1206,17 +1285,19 @@ export async function resumeOrchBatch( persistedState.resilience.resumeForced = true; // Reset phase to paused so normal resume flow can proceed - execLog("resume", persistedState.batchId, `force-resume: phase ${persistedState.phase} → paused`, { - diagnosticChecks: diagnostics.checks.length, - diagnosticsPassed: diagnostics.passed, - }); + execLog( + "resume", + persistedState.batchId, + `force-resume: phase ${persistedState.phase} → paused`, + { + diagnosticChecks: diagnostics.checks.length, + diagnosticsPassed: diagnostics.passed, + }, + ); persistedState.phase = "paused"; } - onNotify( - ORCH_MESSAGES.resumeStarting(persistedState.batchId, persistedState.phase), - "info", - ); + onNotify(ORCH_MESSAGES.resumeStarting(persistedState.batchId, persistedState.phase), "info"); const segmentFrontierByTask = reconstructSegmentFrontier(persistedState); if (segmentFrontierByTask.size > 0) { @@ -1273,14 +1354,19 @@ export async function resumeOrchBatch( // ── 3b. Detect existing worktrees ──────────────────────────── const existingWorktreeTaskIds = new Set(); for (const task of persistedState.tasks) { - const laneRecord = persistedState.lanes.find(l => l.taskIds.includes(task.taskId)); + const laneRecord = persistedState.lanes.find((l) => l.taskIds.includes(task.taskId)); if (laneRecord && laneRecord.worktreePath && existsSync(laneRecord.worktreePath)) { existingWorktreeTaskIds.add(task.taskId); } } // ── 4. Reconcile task states ───────────────────────────────── - const reconciledTasks = reconcileTaskStates(persistedState, aliveSessions, doneTaskIds, existingWorktreeTaskIds); + const reconciledTasks = reconcileTaskStates( + persistedState, + aliveSessions, + doneTaskIds, + existingWorktreeTaskIds, + ); // ── 4b. Clear stale session allocation for tasks reconciled as pending ── // TP-037 (Bug #102b): Pending tasks that had a sessionName from a prior @@ -1292,9 +1378,13 @@ export async function resumeOrchBatch( const stalePendingTaskIds = new Set(); for (const reconciled of reconciledTasks) { if (reconciled.action === "pending") { - const persistedTask = persistedState.tasks.find(t => t.taskId === reconciled.taskId); + const persistedTask = persistedState.tasks.find((t) => t.taskId === reconciled.taskId); if (persistedTask && persistedTask.sessionName) { - execLog("resume", persistedState.batchId, `clear-stale-session: ${reconciled.taskId} had stale session "${persistedTask.sessionName}" (lane ${persistedTask.laneNumber})`); + execLog( + "resume", + persistedState.batchId, + `clear-stale-session: ${reconciled.taskId} had stale session "${persistedTask.sessionName}" (lane ${persistedTask.laneNumber})`, + ); stalePendingTaskIds.add(reconciled.taskId); persistedTask.sessionName = ""; persistedTask.laneNumber = 0; @@ -1305,7 +1395,7 @@ export async function resumeOrchBatch( // (and subsequent serializeBatchState()) won't map them back to the old lane. if (stalePendingTaskIds.size > 0) { for (const lane of persistedState.lanes) { - lane.taskIds = lane.taskIds.filter(id => !stalePendingTaskIds.has(id)); + lane.taskIds = lane.taskIds.filter((id) => !stalePendingTaskIds.has(id)); } } @@ -1329,22 +1419,16 @@ export async function resumeOrchBatch( ); if (resumePoint.reconnectTaskIds.length > 0) { - onNotify( - ORCH_MESSAGES.resumeReconnecting(resumePoint.reconnectTaskIds.length), - "info", - ); + onNotify(ORCH_MESSAGES.resumeReconnecting(resumePoint.reconnectTaskIds.length), "info"); } if (resumePoint.resumeWaveIndex > 0) { - onNotify( - ORCH_MESSAGES.resumeSkippedWaves(resumePoint.resumeWaveIndex), - "info", - ); + onNotify(ORCH_MESSAGES.resumeSkippedWaves(resumePoint.resumeWaveIndex), "info"); } if (resumePoint.mergeRetryWaveIndexes.length > 0) { onNotify( - `🔀 ${resumePoint.mergeRetryWaveIndexes.length} wave(s) need merge retry: ${resumePoint.mergeRetryWaveIndexes.map(i => `W${i + 1}`).join(", ")}`, + `🔀 ${resumePoint.mergeRetryWaveIndexes.length} wave(s) need merge retry: ${resumePoint.mergeRetryWaveIndexes.map((i) => `W${i + 1}`).join(", ")}`, "warning", ); } @@ -1358,8 +1442,8 @@ export async function resumeOrchBatch( if (!persistedState.orchBranch) { onNotify( `❌ Cannot resume batch ${persistedState.batchId}: persisted state has no orch branch. ` + - `This batch was created before orch-branch routing was implemented. ` + - `Use /orch-abort to clean up, then start a new batch.`, + `This batch was created before orch-branch routing was implemented. ` + + `Use /orch-abort to clean up, then start a new batch.`, "error", ); // TP-040 R006: Reset phase on pre-execution early return @@ -1380,7 +1464,9 @@ export async function resumeOrchBatch( // TP-166: Restore task-level wave metadata for correct display. // Normalize: fall back to totalWaves for pre-TP-166 state files. batchState.taskLevelWaveCount = persistedState.taskLevelWaveCount ?? persistedState.totalWaves; - batchState.roundToTaskWave = persistedState.roundToTaskWave ? [...persistedState.roundToTaskWave] : undefined; + batchState.roundToTaskWave = persistedState.roundToTaskWave + ? [...persistedState.roundToTaskWave] + : undefined; batchState.totalTasks = persistedState.totalTasks; batchState.succeededTasks = resumePoint.completedTaskIds.length; batchState.failedTasks = resumePoint.failedTaskIds.length; @@ -1410,7 +1496,11 @@ export async function resumeOrchBatch( } if (uncountedBlocked > 0) { batchState.blockedTasks += uncountedBlocked; - execLog("resume", persistedState.batchId, `blocked counter fix: ${uncountedBlocked} persisted-blocked task(s) in unvisited waves added to blockedTasks`); + execLog( + "resume", + persistedState.batchId, + `blocked counter fix: ${uncountedBlocked} persisted-blocked task(s) in unvisited waves added to blockedTasks`, + ); } } @@ -1453,7 +1543,8 @@ export async function resumeOrchBatch( "warning", ); } else { - const errMsg = `Failed to re-create orch branch "${batchState.orchBranch}" in repo "${repoId}": ${createRes.stderr}. ` + + const errMsg = + `Failed to re-create orch branch "${batchState.orchBranch}" in repo "${repoId}": ${createRes.stderr}. ` + `Cannot resume without orch branch isolation.`; execLog("resume", batchState.batchId, errMsg, { orchBranch: batchState.orchBranch, @@ -1501,11 +1592,10 @@ export async function resumeOrchBatch( } } - // ── 8. Handle alive sessions (reconnect) ───────────────────── // For tasks with alive sessions, we need to wait for them to complete. // We poll each alive session's .DONE file. - const reconnectTasks = reconciledTasks.filter(t => t.action === "reconnect"); + const reconnectTasks = reconciledTasks.filter((t) => t.action === "reconnect"); const reconnectFinalStatus = new Map(); if (reconnectTasks.length > 0) { @@ -1515,9 +1605,7 @@ export async function resumeOrchBatch( if (!parsedTask) continue; // Find the lane info from persisted state - const laneRecord = persistedState.lanes.find( - l => l.taskIds.includes(task.taskId), - ); + const laneRecord = persistedState.lanes.find((l) => l.taskIds.includes(task.taskId)); if (!laneRecord) continue; // Build a minimal AllocatedLane for polling @@ -1552,12 +1640,20 @@ export async function resumeOrchBatch( terminateAliveV2Agents(stateRoot, persistedState.batchId, laneRecord.laneSessionId); try { const laneResult = await executeLaneV2( - lane, orchConfig, laneRepoRoot, batchState.pauseSignal, - workspaceRoot, !!workspaceConfig, - { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer), ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions) }, + lane, + orchConfig, + laneRepoRoot, + batchState.pauseSignal, + workspaceRoot, + !!workspaceConfig, + { + ORCH_BATCH_ID: batchState.batchId, + ...buildReviewerEnv(runnerConfig.reviewer), + ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions), + }, emitAlert, ); - const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId); + const taskResult = laneResult.tasks.find((t) => t.taskId === task.taskId); if (taskResult?.status === "succeeded") { reconnectFinalStatus.set(task.taskId, "succeeded"); completedTaskSet.add(task.taskId); @@ -1577,13 +1673,17 @@ export async function resumeOrchBatch( completedTaskSet.delete(task.taskId); reconnectTaskSet.delete(task.taskId); batchState.failedTasks++; - execLog("resume", task.taskId, `V2 reconnect error: ${err instanceof Error ? err.message : String(err)}`); + execLog( + "resume", + task.taskId, + `V2 reconnect error: ${err instanceof Error ? err.message : String(err)}`, + ); } } } // ── 8b. Handle re-execute tasks (dead session + existing worktree) ── - const reExecuteTasks = reconciledTasks.filter(t => t.action === "re-execute"); + const reExecuteTasks = reconciledTasks.filter((t) => t.action === "re-execute"); const reExecuteFinalStatus = new Map(); const reExecAllocatedLanes: AllocatedLane[] = []; @@ -1597,9 +1697,7 @@ export async function resumeOrchBatch( const parsedTask = discovery.pending.get(task.taskId); if (!parsedTask) continue; - const laneRecord = persistedState.lanes.find( - l => l.taskIds.includes(task.taskId), - ); + const laneRecord = persistedState.lanes.find((l) => l.taskIds.includes(task.taskId)); if (!laneRecord) continue; const allocatedTask: AllocatedTask = { @@ -1634,12 +1732,20 @@ export async function resumeOrchBatch( // TP-112: Runtime V2 re-execution. terminateAliveV2Agents(stateRoot, batchState.batchId, laneRecord.laneSessionId); const laneResult = await executeLaneV2( - lane, orchConfig, reExecRepoRoot, batchState.pauseSignal, - workspaceRoot, !!workspaceConfig, - { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer), ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions) }, + lane, + orchConfig, + reExecRepoRoot, + batchState.pauseSignal, + workspaceRoot, + !!workspaceConfig, + { + ORCH_BATCH_ID: batchState.batchId, + ...buildReviewerEnv(runnerConfig.reviewer), + ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions), + }, emitAlert, ); - const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId); + const taskResult = laneResult.tasks.find((t) => t.taskId === task.taskId); const pollResult: { status: LaneTaskStatus; exitReason: string; doneFileFound: boolean } = { status: taskResult?.status ?? "failed", exitReason: taskResult?.exitReason ?? "V2 re-execution completed", @@ -1660,7 +1766,11 @@ export async function resumeOrchBatch( completedTaskSet.delete(task.taskId); reExecuteTaskSet.delete(task.taskId); batchState.failedTasks++; - execLog("resume", task.taskId, `re-executed task ${pollResult.status}: ${pollResult.exitReason}`); + execLog( + "resume", + task.taskId, + `re-executed task ${pollResult.status}: ${pollResult.exitReason}`, + ); } } catch (err: unknown) { reExecuteFinalStatus.set(task.taskId, "failed"); @@ -1683,16 +1793,13 @@ export async function resumeOrchBatch( .map(([taskId]) => taskId); if (succeededReExecTaskIds.length > 0) { - onNotify( - `🔀 Merging ${reExecAllocatedLanes.length} re-executed lane branch(es)...`, - "info", - ); + onNotify(`🔀 Merging ${reExecAllocatedLanes.length} re-executed lane branch(es)...`, "info"); // Build synthetic WaveExecutionResult for mergeWaveByRepo() - const syntheticLaneResults: LaneExecutionResult[] = reExecAllocatedLanes.map(lane => ({ + const syntheticLaneResults: LaneExecutionResult[] = reExecAllocatedLanes.map((lane) => ({ laneNumber: lane.laneNumber, laneId: lane.laneId, - tasks: lane.tasks.map(t => ({ + tasks: lane.tasks.map((t) => ({ taskId: t.taskId, status: "succeeded" as LaneTaskStatus, startTime: Date.now(), @@ -1758,7 +1865,10 @@ export async function resumeOrchBatch( // Clean up merged branches (resolve per-lane repo root for workspace mode) // TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup for (const lr of reExecMergeResult.laneResults) { - if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) { + if ( + !lr.error && + (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") + ) { const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig); deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot); } @@ -1788,39 +1898,53 @@ export async function resumeOrchBatch( // records with repo attribution (laneNumber, laneId, branch, repoId). // Without this, the `resume-reconciliation` checkpoint would serialize // empty lanes[], losing all lane context until a new wave allocates. - let latestAllocatedLanes: AllocatedLane[] = reconstructAllocatedLanes(persistedState.lanes, persistedState.tasks); + let latestAllocatedLanes: AllocatedLane[] = reconstructAllocatedLanes( + persistedState.lanes, + persistedState.tasks, + ); // Track all repo roots encountered during execution (persisted + newly allocated). // Used by inter-wave reset and terminal cleanup to cover repos introduced // after resume starts (not present in persisted lanes). // Initialized from collectRepoRoots() helper for parity with other callers. - const encounteredRepoRoots = new Set( - collectRepoRoots(persistedState, repoRoot, workspaceConfig), - ); + const encounteredRepoRoots = new Set(collectRepoRoots(persistedState, repoRoot, workspaceConfig)); // Build outcomes from reconciled tasks for (const task of reconciledTasks) { - const persistedTask = persistedState.tasks.find(t => t.taskId === task.taskId); + const persistedTask = persistedState.tasks.find((t) => t.taskId === task.taskId); const reconnectStatus = reconnectFinalStatus.get(task.taskId); const reExecuteStatus = reExecuteFinalStatus.get(task.taskId); - const status = task.action === "reconnect" - ? (reconnectStatus || "running") - : task.action === "re-execute" - ? (reExecuteStatus || "pending") - : task.liveStatus; - const isTerminal = status === "succeeded" || status === "failed" || status === "stalled" || status === "skipped"; + const status = + task.action === "reconnect" + ? reconnectStatus || "running" + : task.action === "re-execute" + ? reExecuteStatus || "pending" + : task.liveStatus; + const isTerminal = + status === "succeeded" || status === "failed" || status === "stalled" || status === "skipped"; allTaskOutcomes.push({ taskId: task.taskId, status, startTime: persistedTask?.startedAt ?? null, endTime: isTerminal ? Date.now() : null, - exitReason: task.action === "mark-complete" ? ".DONE file found on resume" - : task.action === "mark-failed" ? "Session dead, no .DONE file, no worktree on resume" - : task.action === "reconnect" - ? (status === "succeeded" ? "Reconnected task completed" : status === "failed" ? "Reconnected task failed" : "Reconnected to alive session") - : task.action === "re-execute" - ? (status === "succeeded" ? "Re-executed task completed" : status === "failed" ? "Re-executed task failed" : "Re-executing in existing worktree") - : persistedTask?.exitReason ?? "", + exitReason: + task.action === "mark-complete" + ? ".DONE file found on resume" + : task.action === "mark-failed" + ? "Session dead, no .DONE file, no worktree on resume" + : task.action === "reconnect" + ? status === "succeeded" + ? "Reconnected task completed" + : status === "failed" + ? "Reconnected task failed" + : "Reconnected to alive session" + : task.action === "re-execute" + ? status === "succeeded" + ? "Re-executed task completed" + : status === "failed" + ? "Re-executed task failed" + : "Re-executing in existing worktree" + : (persistedTask?.exitReason ?? ""), sessionName: persistedTask?.sessionName ?? "", doneFileFound: status === "succeeded" ? true : task.doneFileFound, laneNumber: persistedTask?.laneNumber, @@ -1842,14 +1966,27 @@ export async function resumeOrchBatch( batchState.blockedTaskIds.add(taskId); } if (reconciledBlocked.size > 0) { - execLog("resume", batchState.batchId, `skip-dependents: ${reconciledBlocked.size} task(s) blocked from reconciled failures`, { - blocked: [...reconciledBlocked].sort().join(","), - sources: [...failedTaskSet].sort().join(","), - }); + execLog( + "resume", + batchState.batchId, + `skip-dependents: ${reconciledBlocked.size} task(s) blocked from reconciled failures`, + { + blocked: [...reconciledBlocked].sort().join(","), + sources: [...failedTaskSet].sort().join(","), + }, + ); } } - persistRuntimeState("resume-reconciliation", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery ?? null, stateRoot); + persistRuntimeState( + "resume-reconciliation", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery ?? null, + stateRoot, + ); // ── 10. Continue wave execution ────────────────────────────── // We need to execute remaining waves starting from resumeWaveIndex. @@ -1868,33 +2005,53 @@ export async function resumeOrchBatch( // Check pause signal if (batchState.pauseSignal.paused) { batchState.phase = "paused"; - persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); - const { displayWave: pauseWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); + persistRuntimeState( + "pause-before-wave", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); + const { displayWave: pauseWave } = resolveDisplayWaveNumber( + waveIdx, + roundToTaskWave, + taskLevelWaveCount, + ); onNotify(`⏸️ Batch paused before wave ${pauseWave}.`, "warning"); break; } batchState.currentWaveIndex = waveIdx; - persistRuntimeState("wave-index-change", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "wave-index-change", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); // Get wave tasks, filtering out completed/failed/skipped/blocked ones. // Persisted "skipped" tasks are terminal and must never be re-executed. let waveTasks = wavePlan[waveIdx].filter( - taskId => !completedTaskSet.has(taskId) && + (taskId) => + !completedTaskSet.has(taskId) && !failedTaskSet.has(taskId) && persistedStatusByTaskId.get(taskId) !== "skipped" && !batchState.blockedTaskIds.has(taskId), ); // Also filter tasks where discovery doesn't have them as pending - waveTasks = waveTasks.filter(taskId => discovery.pending.has(taskId)); + waveTasks = waveTasks.filter((taskId) => discovery.pending.has(taskId)); // Count only newly blocked tasks (not already persisted) to avoid double-counting. // persistedState.blockedTaskIds were already counted in persistedState.blockedTasks // which initialized batchState.blockedTasks. const blockedInWave = wavePlan[waveIdx].filter( - taskId => batchState.blockedTaskIds.has(taskId) && - !persistedBlockedTaskIds.has(taskId), + (taskId) => batchState.blockedTaskIds.has(taskId) && !persistedBlockedTaskIds.has(taskId), ); if (blockedInWave.length > 0) { batchState.blockedTasks += blockedInWave.length; @@ -1904,13 +2061,20 @@ export async function resumeOrchBatch( // TP-037 Bug #102: Check if this wave needs merge retry. // All tasks are terminal but the merge may have failed/been interrupted. if (resumePoint.mergeRetryWaveIndexes.includes(waveIdx)) { - execLog("resume", batchState.batchId, `wave ${waveIdx + 1}: all tasks done but merge needs retry`); - onNotify(`🔀 Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}: retrying merge (tasks already complete, merge was missing/failed)`, "info"); + execLog( + "resume", + batchState.batchId, + `wave ${waveIdx + 1}: all tasks done but merge needs retry`, + ); + onNotify( + `🔀 Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}: retrying merge (tasks already complete, merge was missing/failed)`, + "info", + ); // Reconstruct lanes for this wave from persisted state const waveTaskIds = new Set(wavePlan[waveIdx]); - const waveLaneRecords = persistedState.lanes.filter( - lane => lane.taskIds.some(tid => waveTaskIds.has(tid)), + const waveLaneRecords = persistedState.lanes.filter((lane) => + lane.taskIds.some((tid) => waveTaskIds.has(tid)), ); const mergeRetryLanes = reconstructAllocatedLanes(waveLaneRecords, persistedState.tasks); @@ -1918,18 +2082,14 @@ export async function resumeOrchBatch( // Crucial for orch_force_merge: tasks intentionally marked "skipped" must // remain skipped here (not failed), otherwise mixed-outcome detection would // trigger again and block the forced merge recovery path. - const succeededTaskIds = wavePlan[waveIdx].filter( - taskId => completedTaskSet.has(taskId), - ); + const succeededTaskIds = wavePlan[waveIdx].filter((taskId) => completedTaskSet.has(taskId)); const skippedTaskIds = wavePlan[waveIdx].filter( - taskId => persistedStatusByTaskId.get(taskId) === "skipped", - ); - const failedTaskIds = wavePlan[waveIdx].filter( - taskId => { - const status = persistedStatusByTaskId.get(taskId); - return status === "failed" || status === "stalled"; - }, + (taskId) => persistedStatusByTaskId.get(taskId) === "skipped", ); + const failedTaskIds = wavePlan[waveIdx].filter((taskId) => { + const status = persistedStatusByTaskId.get(taskId); + return status === "failed" || status === "stalled"; + }); const syntheticLaneResults: LaneExecutionResult[] = mergeRetryLanes.map((lane) => { const laneTasks = lane.tasks.map((t) => { @@ -1953,10 +2113,13 @@ export async function resumeOrchBatch( startTime: Date.now(), endTime: Date.now(), exitReason: - status === "succeeded" ? "Task completed (merge retry)" - : status === "skipped" ? "Task skipped (merge retry)" - : status === "stalled" ? "Task stalled (merge retry)" - : "Task failed (merge retry)", + status === "succeeded" + ? "Task completed (merge retry)" + : status === "skipped" + ? "Task skipped (merge retry)" + : status === "stalled" + ? "Task stalled (merge retry)" + : "Task failed (merge retry)", sessionName: lane.laneSessionId, doneFileFound: status === "succeeded", laneNumber: lane.laneNumber, @@ -1968,7 +2131,9 @@ export async function resumeOrchBatch( ); const laneHasSucceeded = laneTasks.some((t) => t.status === "succeeded"); const overallStatus = laneHasHardFailure - ? (laneHasSucceeded ? "partial" : "failed") + ? laneHasSucceeded + ? "partial" + : "failed" : "succeeded"; return { @@ -1999,7 +2164,15 @@ export async function resumeOrchBatch( }; batchState.phase = "merging"; - persistRuntimeState("merge-retry-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "merge-retry-start", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); const mergeRetryResult = await mergeWaveByRepo( mergeRetryLanes, @@ -2020,10 +2193,16 @@ export async function resumeOrchBatch( batchState.mergeResults.push(mergeRetryResult); if (mergeRetryResult.status === "succeeded") { - onNotify(`✅ Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave} merge retry succeeded`, "info"); + onNotify( + `✅ Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave} merge retry succeeded`, + "info", + ); // Clean up merged branches for (const lr of mergeRetryResult.laneResults) { - if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) { + if ( + !lr.error && + (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") + ) { const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig); deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot); } @@ -2035,27 +2214,61 @@ export async function resumeOrchBatch( ); // Apply merge failure policy (same as normal wave merge failure) const policyResult = computeMergeFailurePolicy(mergeRetryResult, waveIdx, orchConfig); - execLog("batch", batchState.batchId, `merge retry failure — applying ${policyResult.policy} policy`, policyResult.logDetails); + execLog( + "batch", + batchState.batchId, + `merge retry failure — applying ${policyResult.policy} policy`, + policyResult.logDetails, + ); batchState.phase = policyResult.targetPhase; batchState.errors.push(policyResult.errorMessage); - persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + policyResult.persistTrigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(policyResult.notifyMessage, policyResult.notifyLevel); preserveWorktreesForResume = true; break; } batchState.phase = "executing"; - persistRuntimeState("merge-retry-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "merge-retry-complete", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); } else { - execLog("resume", batchState.batchId, `wave ${waveIdx + 1}: no tasks to execute (all completed/blocked)`); + execLog( + "resume", + batchState.batchId, + `wave ${waveIdx + 1}: no tasks to execute (all completed/blocked)`, + ); } continue; } { - const { displayWave, displayTotal } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); + const { displayWave, displayTotal } = resolveDisplayWaveNumber( + waveIdx, + roundToTaskWave, + taskLevelWaveCount, + ); onNotify( - ORCH_MESSAGES.orchWaveStart(displayWave, displayTotal, waveTasks.length, Math.min(waveTasks.length, orchConfig.orchestrator.max_lanes)), + ORCH_MESSAGES.orchWaveStart( + displayWave, + displayTotal, + waveTasks.length, + Math.min(waveTasks.length, orchConfig.orchestrator.max_lanes), + ), "info", ); } @@ -2063,7 +2276,15 @@ export async function resumeOrchBatch( const handleResumeMonitorUpdate: MonitorUpdateCallback = (monitorState) => { const changed = syncTaskOutcomesFromMonitor(monitorState, allTaskOutcomes); if (changed) { - persistRuntimeState("task-transition", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "task-transition", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); } onMonitorUpdate?.(monitorState); }; @@ -2088,7 +2309,15 @@ export async function resumeOrchBatch( encounteredRepoRoots.add(resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig)); } if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) { - persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "wave-lanes-allocated", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); } }, workspaceConfig, @@ -2135,8 +2364,8 @@ export async function resumeOrchBatch( // ── TP-076: Emit supervisor alerts for task failures ──── for (const taskId of waveResult.failedTaskIds) { - const outcome = allTaskOutcomes.find(o => o.taskId === taskId); - const laneForTask = latestAllocatedLanes.find(l => l.tasks.some(t => t.taskId === taskId)); + const outcome = allTaskOutcomes.find((o) => o.taskId === taskId); + const laneForTask = latestAllocatedLanes.find((l) => l.tasks.some((t) => t.taskId === taskId)); const taskRecord = batchState.tasks.find((task) => task.taskId === taskId); const exitReason = outcome?.exitReason || "unknown"; const hasPartialProgress = (outcome?.partialProgressCommits ?? 0) > 0; @@ -2147,12 +2376,14 @@ export async function resumeOrchBatch( batchState.segments, outcome?.segmentId, ); - const segmentId = outcome?.segmentId - ?? taskRecord?.activeSegmentId - ?? segmentFrontier?.activeSegmentId - ?? undefined; + const segmentId = + outcome?.segmentId ?? + taskRecord?.activeSegmentId ?? + segmentFrontier?.activeSegmentId ?? + undefined; const repoId = segmentId - ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? laneForTask?.repoId) + ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? + laneForTask?.repoId) : laneForTask?.repoId; const segmentSummary = segmentId ? ` Segment: ${segmentId}${repoId ? ` (repo: ${repoId})` : ""}\n` @@ -2197,11 +2428,23 @@ export async function resumeOrchBatch( }); } - persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "wave-execution-complete", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); const elapsedSec = Math.round((waveResult.endedAt - waveResult.startedAt) / 1000); { - const { displayWave: completeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); + const { displayWave: completeDisplayWave } = resolveDisplayWaveNumber( + waveIdx, + roundToTaskWave, + taskLevelWaveCount, + ); onNotify( ORCH_MESSAGES.orchWaveComplete( completeDisplayWave, @@ -2218,13 +2461,29 @@ export async function resumeOrchBatch( if (waveResult.stoppedEarly) { if (waveResult.policyApplied === "stop-all") { batchState.phase = "stopped"; - persistRuntimeState("stop-all", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "stop-all", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-all"), "error"); break; } if (waveResult.policyApplied === "stop-wave") { batchState.phase = "stopped"; - persistRuntimeState("stop-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "stop-wave", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-wave"), "error"); break; } @@ -2237,29 +2496,41 @@ export async function resumeOrchBatch( for (const lr of waveResult.laneResults) { laneOutcomeByNumber.set(lr.laneNumber, lr); } - const mixedOutcomeLanes = waveResult.laneResults.filter(lr => { - const hasSucceeded = lr.tasks.some(t => t.status === "succeeded"); - const hasHardFailure = lr.tasks.some( - t => t.status === "failed" || t.status === "stalled", - ); + const mixedOutcomeLanes = waveResult.laneResults.filter((lr) => { + const hasSucceeded = lr.tasks.some((t) => t.status === "succeeded"); + const hasHardFailure = lr.tasks.some((t) => t.status === "failed" || t.status === "stalled"); return hasSucceeded && hasHardFailure; }); if (waveResult.succeededTaskIds.length > 0) { - const mergeableLaneCount = waveResult.allocatedLanes.filter(lane => { + const mergeableLaneCount = waveResult.allocatedLanes.filter((lane) => { const outcome = laneOutcomeByNumber.get(lane.laneNumber); if (!outcome) return false; - const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded"); + const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded"); const hasHardFailure = outcome.tasks.some( - t => t.status === "failed" || t.status === "stalled", + (t) => t.status === "failed" || t.status === "stalled", ); return hasSucceeded && !hasHardFailure; }).length; if (mergeableLaneCount > 0) { batchState.phase = "merging"; - persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); - onNotify(ORCH_MESSAGES.orchMergeStart(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeableLaneCount), "info"); + persistRuntimeState( + "merge-start", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); + onNotify( + ORCH_MESSAGES.orchMergeStart( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergeableLaneCount, + ), + "info", + ); mergeResult = await mergeWaveByRepo( waveResult.allocatedLanes, @@ -2287,35 +2558,65 @@ export async function resumeOrchBatch( if (lr.error) { onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error"); } else if (lr.result?.status === "SUCCESS") { - onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info"); + onNotify( + ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), + "info", + ); } else if (lr.result?.status === "CONFLICT_RESOLVED") { - onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info"); - } else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") { + onNotify( + ORCH_MESSAGES.orchMergeLaneConflictResolved( + lr.laneNumber, + lr.result.conflicts.length, + durationSec, + ), + "info", + ); + } else if ( + lr.result?.status === "CONFLICT_UNRESOLVED" || + lr.result?.status === "BUILD_FAILURE" + ) { onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status), "error"); } } if (mixedOutcomeLanes.length > 0) { - const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); + const mixedIds = mixedOutcomeLanes.map((l) => `lane-${l.laneNumber}`).join(", "); const failureReason = `Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` + `Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`; - mergeResult = { ...mergeResult, status: "partial", failedLane: mixedOutcomeLanes[0].laneNumber, failureReason }; + mergeResult = { + ...mergeResult, + status: "partial", + failedLane: mixedOutcomeLanes[0].laneNumber, + failureReason, + }; // Update the already-pushed reference so persisted state reflects "partial" batchState.mergeResults[batchState.mergeResults.length - 1] = mergeResult; } // TP-032 R006-3: Exclude verification_new_failure lanes from success count const mergedCount = mergeResult.laneResults.filter( - r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), + (r) => + !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), ).length; const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000); if (mergeResult.status === "succeeded") { - onNotify(ORCH_MESSAGES.orchMergeComplete(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergedCount, mergeTotalSec), "info"); + onNotify( + ORCH_MESSAGES.orchMergeComplete( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergedCount, + mergeTotalSec, + ), + "info", + ); } else { onNotify( - ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"), + ORCH_MESSAGES.orchMergeFailed( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergeResult.failedLane ?? 0, + mergeResult.failureReason || "unknown", + ), "error", ); @@ -2329,9 +2630,17 @@ export async function resumeOrchBatch( } batchState.phase = "executing"; - persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "merge-complete", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); } else if (mixedOutcomeLanes.length > 0) { - const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); + const mixedIds = mixedOutcomeLanes.map((l) => `lane-${l.laneNumber}`).join(", "); mergeResult = { waveIndex: waveIdx + 1, status: "partial", @@ -2346,14 +2655,28 @@ export async function resumeOrchBatch( // Downstream retry/update paths assume the current wave has an entry. batchState.mergeResults.push(mergeResult); onNotify( - ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane, mergeResult.failureReason || "unknown"), + ORCH_MESSAGES.orchMergeFailed( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + mergeResult.failedLane, + mergeResult.failureReason || "unknown", + ), "error", ); } else { - onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info"); + onNotify( + ORCH_MESSAGES.orchMergeSkipped( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + ), + "info", + ); } } else { - onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info"); + onNotify( + ORCH_MESSAGES.orchMergeSkipped( + resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + ), + "info", + ); } // ── TP-033: Safe-stop on rollback failure ───────────────── @@ -2363,30 +2686,44 @@ export async function resumeOrchBatch( if (mergeResult?.rollbackFailed) { // TP-033 R004-2: Include persistence error warning when transaction // record files may be missing, so operator knows to inspect manually - const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; + const hasPersistErrors = + mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; const persistWarning = hasPersistErrors ? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.` : ""; - execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", { - waveIndex: waveIdx, - configPolicy: orchConfig.failure.on_merge_failure, - ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}), - }); + execLog( + "batch", + batchState.batchId, + "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", + { + waveIndex: waveIdx, + configPolicy: orchConfig.failure.on_merge_failure, + ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}), + }, + ); batchState.phase = "paused"; batchState.errors.push( `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` + - `Merge worktree and temp branch preserved for recovery. ` + - `Check transaction records in .pi/verification/ for recovery commands.` + - persistWarning + `Merge worktree and temp branch preserved for recovery. ` + + `Check transaction records in .pi/verification/ for recovery commands.` + + persistWarning, + ); + persistRuntimeState( + "merge-rollback-safe-stop", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, ); - persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); onNotify( `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` + - `Batch force-paused. Merge worktree preserved for manual recovery. ` + - `See .pi/verification/ transaction records for recovery commands.` + - persistWarning, + `Batch force-paused. Merge worktree preserved for manual recovery. ` + + `See .pi/verification/ transaction records for recovery commands.` + + persistWarning, "error", ); @@ -2448,7 +2785,16 @@ export async function resumeOrchBatch( resumeBackend, ); }, - persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot), + persist: (trigger) => + persistRuntimeState( + trigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ), log: (message, details) => execLog("batch", batchState.batchId, message, details), notify: (message, level) => onNotify(message, level), updateMergeResult: (result) => { @@ -2462,13 +2808,29 @@ export async function resumeOrchBatch( if (retryOutcome.kind === "retry_succeeded") { mergeResult = retryOutcome.mergeResult; batchState.phase = "executing"; - persistRuntimeState("merge-retry-succeeded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "merge-retry-succeeded", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); // Fall through to normal post-merge flow } else if (retryOutcome.kind === "safe_stop") { mergeResult = retryOutcome.mergeResult; batchState.phase = "paused"; batchState.errors.push(retryOutcome.errorMessage); - persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "merge-rollback-safe-stop", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(retryOutcome.notifyMessage, "error"); // ── TP-076: Emit supervisor alert for merge safe-stop ── @@ -2494,7 +2856,8 @@ export async function resumeOrchBatch( } else if (retryOutcome.kind === "exhausted") { // TP-033 R006-2: Force paused regardless of on_merge_failure config. mergeResult = retryOutcome.mergeResult; - const exhaustionMsg = retryOutcome.errorMessage + + const exhaustionMsg = + retryOutcome.errorMessage + ` [${retryOutcome.classification ?? "unknown"} ${retryOutcome.lastDecision.currentAttempt}/${retryOutcome.lastDecision.maxAttempts}, scope=${retryOutcome.scopeKey}]`; execLog("batch", batchState.batchId, `merge retry exhausted — forcing paused`, { @@ -2506,7 +2869,15 @@ export async function resumeOrchBatch( batchState.phase = "paused"; batchState.errors.push(exhaustionMsg); - persistRuntimeState("merge-retry-exhausted", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "merge-retry-exhausted", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(retryOutcome.notifyMessage, "error"); // ── TP-076: Emit supervisor alert for merge retry exhausted ── @@ -2539,11 +2910,24 @@ export async function resumeOrchBatch( ? ` [not retriable: ${retryOutcome.classification}, scope=${retryOutcome.scopeKey}]` : ""; - execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy${classNote}`, policyResult.logDetails); + execLog( + "batch", + batchState.batchId, + `merge failure — applying ${policyResult.policy} policy${classNote}`, + policyResult.logDetails, + ); batchState.phase = policyResult.targetPhase; batchState.errors.push(policyResult.errorMessage + classNote); - persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + policyResult.persistTrigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(policyResult.notifyMessage + classNote, policyResult.notifyLevel); // ── TP-076: Emit supervisor alert for merge failure (no-retry policy) ── @@ -2575,9 +2959,15 @@ export async function resumeOrchBatch( // TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup if (mergeResult && mergeResult.status === "succeeded") { for (const lr of mergeResult.laneResults) { - if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) { + if ( + !lr.error && + (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") + ) { const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig); - const ancestorCheck = runGit(["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch], laneRepoRoot); + const ancestorCheck = runGit( + ["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch], + laneRepoRoot, + ); if (ancestorCheck.ok) { deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot); } @@ -2601,30 +2991,46 @@ export async function resumeOrchBatch( let targetBranch = batchState.orchBranch; if (repoId && perRepoRoot !== repoRoot) { try { - targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); - } catch { /* fall back to orchBranch */ } + targetBranch = resolveBaseBranch( + repoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); + } catch { + /* fall back to orchBranch */ + } } return { repoRoot: perRepoRoot, targetBranch }; }, ); ppUnsafeBranches = ppResult.unsafeBranches; - if (ppResult.results.some(r => r.saved)) { - execLog("batch", batchState.batchId, - `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before inter-wave reset`); + if (ppResult.results.some((r) => r.saved)) { + execLog( + "batch", + batchState.batchId, + `preserved partial progress for ${ppResult.results.filter((r) => r.saved).length} failed task(s) before inter-wave reset`, + ); } // Log per-task warnings for failed preservation attempts for (const r of ppResult.results) { if (!r.saved && (r.commitCount > 0 || r.error)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: Failed to preserve partial progress for task ${r.taskId} ` + - `(${r.commitCount} commit(s) at risk on lane branch)`, - { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }); + `(${r.commitCount} commit(s) at risk on lane branch)`, + { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }, + ); } } if (ppUnsafeBranches.size > 0) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: ${ppUnsafeBranches.size} lane branch(es) could not be preserved — skipping reset for those lanes to prevent commit loss`, - { unsafeBranches: [...ppUnsafeBranches] }); + { unsafeBranches: [...ppUnsafeBranches] }, + ); } // TP-028: Stamp task outcomes with partial progress data for persistence applyPartialProgressToOutcomes(ppResult, allTaskOutcomes); @@ -2636,7 +3042,10 @@ export async function resumeOrchBatch( // TP-029 R006: Track worktrees that failed reset AND removal // so the cleanup gate only fires on true stale state, not // successfully-reset reusable worktrees. (Parity with engine.ts) - const failedRemovalWorktrees = new Map(); + const failedRemovalWorktrees = new Map< + string, + { repoId: string | undefined; paths: string[] } + >(); // Use encounteredRepoRoots which includes both persisted lanes // AND newly allocated lanes from resumed waves, ensuring repos @@ -2652,7 +3061,12 @@ export async function resumeOrchBatch( } else { const repoId = resolveRepoIdFromRoot(perRepoRoot, workspaceConfig); try { - targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); + targetBranch = resolveBaseBranch( + repoId, + perRepoRoot, + batchState.orchBranch, + workspaceConfig, + ); } catch { // If resolution fails, fall back to orchBranch (reset will // fail gracefully and trigger worktree removal) @@ -2663,9 +3077,12 @@ export async function resumeOrchBatch( // TP-028: Skip reset for worktrees whose lane branch has // unsaved partial progress (preservation failed with commits) if (ppUnsafeBranches.has(wt.branch)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `skipping worktree reset for lane ${wt.laneNumber} — branch "${wt.branch}" has unsaved partial progress`, - { path: wt.path, branch: wt.branch }); + { path: wt.path, branch: wt.branch }, + ); continue; } @@ -2676,9 +3093,8 @@ export async function resumeOrchBatch( } catch { forceCleanupWorktree(wt, perRepoRoot, batchState.batchId); // Track this worktree for the cleanup gate — it may still be registered - const perRepoId = perRepoRoot === repoRoot - ? undefined - : resolveRepoIdFromRoot(perRepoRoot, workspaceConfig); + const perRepoId = + perRepoRoot === repoRoot ? undefined : resolveRepoIdFromRoot(perRepoRoot, workspaceConfig); if (!failedRemovalWorktrees.has(perRepoRoot)) { failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] }); } @@ -2699,9 +3115,9 @@ export async function resumeOrchBatch( if (failedRemovalWorktrees.size > 0) { for (const [perRepoRoot, { repoId: perRepoId, paths: failedPaths }] of failedRemovalWorktrees) { const remaining = listWorktrees(wtPrefix, perRepoRoot, resetOpId, batchState.batchId); - const remainingPaths = new Set(remaining.map(wt => wt.path)); + const remainingPaths = new Set(remaining.map((wt) => wt.path)); // Only report worktrees that were targeted for removal but are still registered - const stale = failedPaths.filter(p => remainingPaths.has(p)); + const stale = failedPaths.filter((p) => remainingPaths.has(p)); if (stale.length > 0) { cleanupGateFailures.push({ repoRoot: perRepoRoot, @@ -2715,11 +3131,24 @@ export async function resumeOrchBatch( if (cleanupGateFailures.length > 0) { const gatePolicyResult = computeCleanupGatePolicy(waveIdx, cleanupGateFailures); - execLog("batch", batchState.batchId, `cleanup gate failed — pausing batch`, gatePolicyResult.logDetails); + execLog( + "batch", + batchState.batchId, + `cleanup gate failed — pausing batch`, + gatePolicyResult.logDetails, + ); batchState.phase = gatePolicyResult.targetPhase; batchState.errors.push(gatePolicyResult.errorMessage); - persistRuntimeState(gatePolicyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + gatePolicyResult.persistTrigger, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); onNotify(gatePolicyResult.notifyMessage, gatePolicyResult.notifyLevel); preserveWorktreesForResume = true; break; @@ -2731,11 +3160,18 @@ export async function resumeOrchBatch( // TP-031 (R006): Parity with engine.ts — this check MUST run before cleanup // so that worktrees survive when failedTasks > 0. Without this, cleanup // deletes worktrees before the batch is marked "paused", breaking resumability. - if (!preserveWorktreesForResume && - ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") && - batchState.failedTasks > 0) { + if ( + !preserveWorktreesForResume && + ((batchState.phase as OrchBatchPhase) === "executing" || + (batchState.phase as OrchBatchPhase) === "merging") && + batchState.failedTasks > 0 + ) { preserveWorktreesForResume = true; - execLog("resume", batchState.batchId, "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume"); + execLog( + "resume", + batchState.batchId, + "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume", + ); } // ── 11. Cleanup and terminal state ─────────────────────────── @@ -2754,24 +3190,32 @@ export async function resumeOrchBatch( if (repoId && perRepoRoot !== repoRoot) { try { targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig); - } catch { /* fall back to orchBranch */ } + } catch { + /* fall back to orchBranch */ + } } return { repoRoot: perRepoRoot, targetBranch }; }, ); - if (ppResult.results.some(r => r.saved)) { - execLog("batch", batchState.batchId, - `preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before terminal cleanup`); + if (ppResult.results.some((r) => r.saved)) { + execLog( + "batch", + batchState.batchId, + `preserved partial progress for ${ppResult.results.filter((r) => r.saved).length} failed task(s) before terminal cleanup`, + ); } // Log warnings for failed preservation attempts — at terminal cleanup // we cannot skip deletion (batch is ending), but operators need to know // that commits may become unreachable via reflog only. for (const r of ppResult.results) { if (!r.saved && (r.commitCount > 0 || r.error)) { - execLog("batch", batchState.batchId, + execLog( + "batch", + batchState.batchId, `WARNING: Failed to preserve partial progress for task ${r.taskId} ` + - `(${r.commitCount} commit(s) may become unreachable after cleanup)`, - { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }); + `(${r.commitCount} commit(s) may become unreachable after cleanup)`, + { taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" }, + ); } } // TP-028: Stamp task outcomes with partial progress data for persistence @@ -2813,14 +3257,24 @@ export async function resumeOrchBatch( targetBranch = undefined; } } - removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig); + removeAllWorktrees( + wtPrefix, + perRepoRoot, + cleanupOpId, + targetBranch, + batchState.batchId, + orchConfig, + ); } } batchState.endedAt = Date.now(); const totalElapsedSec = Math.round((batchState.endedAt - batchState.startedAt) / 1000); - if ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") { + if ( + (batchState.phase as OrchBatchPhase) === "executing" || + (batchState.phase as OrchBatchPhase) === "merging" + ) { if (batchState.failedTasks > 0) { // TP-031: Parity with engine.ts — default to "paused" so the batch is // resumable without --force. "failed" is reserved for unrecoverable @@ -2843,27 +3297,52 @@ export async function resumeOrchBatch( // handles all non-manual integration after batch_complete event. const mergedTaskCount = batchState.succeededTasks; const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed"; - if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) { - if (orchConfig.orchestrator.integration === "supervised" || orchConfig.orchestrator.integration === "auto") { + if ( + isTerminalPhase && + !preserveWorktreesForResume && + batchState.orchBranch && + mergedTaskCount > 0 + ) { + if ( + orchConfig.orchestrator.integration === "supervised" || + orchConfig.orchestrator.integration === "auto" + ) { // TP-043: Supervisor-managed integration modes. Defer to supervisor. - execLog("resume", batchState.batchId, `integration deferred to supervisor (mode: ${orchConfig.orchestrator.integration})`); + execLog( + "resume", + batchState.batchId, + `integration deferred to supervisor (mode: ${orchConfig.orchestrator.integration})`, + ); } else { // Manual mode (default): show integration guidance onNotify( - ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount), + ORCH_MESSAGES.orchIntegrationManual( + batchState.orchBranch, + batchState.baseBranch, + mergedTaskCount, + ), "info", ); } } - persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + persistRuntimeState( + "batch-terminal", + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + discovery, + stateRoot, + ); // ── TP-076: Emit supervisor alert for batch completion ────── if (batchState.phase === "completed" || batchState.phase === "failed") { const batchDurationMs = batchState.endedAt ? batchState.endedAt - batchState.startedAt : 0; - const durationStr = batchDurationMs > 0 - ? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s` - : "unknown"; + const durationStr = + batchDurationMs > 0 + ? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s` + : "unknown"; if (batchState.phase === "completed" && batchState.failedTasks === 0) { emitAlert({ category: "batch-complete", @@ -2900,10 +3379,21 @@ export async function resumeOrchBatch( // ── TP-031: Emit diagnostic reports (JSONL + markdown) ── // Non-fatal: errors are logged but never crash batch finalization. - emitDiagnosticReports(assembleDiagnosticInput(orchConfig, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, stateRoot)); + emitDiagnosticReports( + assembleDiagnosticInput( + orchConfig, + batchState, + wavePlan, + latestAllocatedLanes, + allTaskOutcomes, + stateRoot, + ), + ); if (batchState.phase === "paused" || batchState.phase === "stopped") { - execLog("resume", batchState.batchId, "resumed batch ended in non-terminal state", { phase: batchState.phase }); + execLog("resume", batchState.batchId, "resumed batch ended in non-terminal state", { + phase: batchState.phase, + }); } else { onNotify( ORCH_MESSAGES.resumeComplete( @@ -2928,9 +3418,7 @@ export async function resumeOrchBatch( } } - // TP-043: attemptAutoIntegration is no longer called from engine.ts or resume.ts. // Supervisor-managed integration ("supervised" and "auto" modes) is handled by // the supervisor agent after batch_complete. The helper remains in merge.ts for // use by the supervisor's integration flow. - diff --git a/extensions/taskplane/sessions.ts b/extensions/taskplane/sessions.ts index 4343c6dc..53eaa231 100644 --- a/extensions/taskplane/sessions.ts +++ b/extensions/taskplane/sessions.ts @@ -25,7 +25,7 @@ export function listOrchSessions( if (!batchState || batchState.currentLanes.length === 0) return []; return batchState.currentLanes - .map(lane => ({ + .map((lane) => ({ sessionName: lane.laneSessionId, laneId: lane.laneId, taskId: lane.tasks.length > 0 ? lane.tasks[0].taskId : null, diff --git a/extensions/taskplane/settings-tui.ts b/extensions/taskplane/settings-tui.ts index f013d3a4..c4d1dfef 100644 --- a/extensions/taskplane/settings-tui.ts +++ b/extensions/taskplane/settings-tui.ts @@ -19,7 +19,14 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent"; -import { Container, type SelectItem, SelectList, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui"; +import { + Container, + type SelectItem, + SelectList, + type SettingItem, + SettingsList, + Text, +} from "@mariozechner/pi-tui"; import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs"; import { join, dirname } from "path"; import { parse as yamlParse } from "yaml"; @@ -40,7 +47,6 @@ import { } from "./config-loader.ts"; import { loadPiSettingsPackages } from "./settings-loader.ts"; - // ── Types ──────────────────────────────────────────────────────────── /** Source of a field's current value */ @@ -84,7 +90,6 @@ export interface SectionDef { readOnly?: boolean; } - // ── Section & Field Definitions ────────────────────────────────────── /** @@ -95,114 +100,401 @@ export const SECTIONS: SectionDef[] = [ { name: "Orchestrator", fields: [ - { configPath: "orchestrator.orchestrator.maxLanes", label: "Max Lanes", control: "input", layer: "L1", fieldType: "number", description: "Maximum parallel execution lanes" }, - { configPath: "orchestrator.orchestrator.worktreeLocation", label: "Worktree Location", control: "toggle", layer: "L1", fieldType: "enum", values: ["sibling", "subdirectory"], description: "Where lane worktree directories are created" }, - { configPath: "orchestrator.orchestrator.worktreePrefix", label: "Worktree Prefix", control: "input", layer: "L1", fieldType: "string", description: "Prefix for worktree directory names" }, - { configPath: "orchestrator.orchestrator.batchIdFormat", label: "Batch ID Format", control: "toggle", layer: "L1", fieldType: "enum", values: ["timestamp", "sequential"], description: "Batch ID format for logs/branch naming" }, - { configPath: "orchestrator.orchestrator.sessionPrefix", label: "Session Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "sessionPrefix", description: "Prefix for orchestrator session names" }, - { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" }, - { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "picker", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." }, + { + configPath: "orchestrator.orchestrator.maxLanes", + label: "Max Lanes", + control: "input", + layer: "L1", + fieldType: "number", + description: "Maximum parallel execution lanes", + }, + { + configPath: "orchestrator.orchestrator.worktreeLocation", + label: "Worktree Location", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["sibling", "subdirectory"], + description: "Where lane worktree directories are created", + }, + { + configPath: "orchestrator.orchestrator.worktreePrefix", + label: "Worktree Prefix", + control: "input", + layer: "L1", + fieldType: "string", + description: "Prefix for worktree directory names", + }, + { + configPath: "orchestrator.orchestrator.batchIdFormat", + label: "Batch ID Format", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["timestamp", "sequential"], + description: "Batch ID format for logs/branch naming", + }, + { + configPath: "orchestrator.orchestrator.sessionPrefix", + label: "Session Prefix", + control: "input", + layer: "L1+L2", + fieldType: "string", + prefsKey: "sessionPrefix", + description: "Prefix for orchestrator session names", + }, + { + configPath: "orchestrator.orchestrator.operatorId", + label: "Operator ID", + control: "input", + layer: "L1+L2", + fieldType: "string", + prefsKey: "operatorId", + description: "Operator identifier (empty = auto-detect)", + }, + { + configPath: "orchestrator.orchestrator.integration", + label: "Integration", + control: "picker", + layer: "L1", + fieldType: "enum", + values: ["manual", "supervised", "auto"], + description: + "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking.", + }, ], }, { name: "Agent: Supervisor", fields: [ - { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" }, - { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "picker", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" }, + { + configPath: "orchestrator.supervisor.model", + label: "Supervisor Model", + control: "input", + layer: "L1+L2", + fieldType: "string", + prefsKey: "supervisorModel", + description: "Supervisor model (inherit = use session model)", + }, + { + configPath: "orchestrator.supervisor.autonomy", + label: "Autonomy Level", + control: "picker", + layer: "L1", + fieldType: "enum", + values: ["interactive", "supervised", "autonomous"], + description: "Recovery action confirmation behavior", + }, ], }, { name: "Agent: Worker", fields: [ - { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" }, - { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" }, - { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Worker thinking mode" }, + { + configPath: "taskRunner.worker.model", + label: "Worker Model", + control: "input", + layer: "L1+L2", + fieldType: "string", + prefsKey: "workerModel", + description: "Worker model (inherit = use session model)", + }, + { + configPath: "taskRunner.worker.tools", + label: "Worker Tools", + control: "input", + layer: "L1", + fieldType: "string", + description: "Worker tool allowlist", + }, + { + configPath: "taskRunner.worker.thinking", + label: "Worker Thinking", + control: "picker", + layer: "L1", + fieldType: "string", + description: "Worker thinking mode", + }, ], }, { name: "Agent: Reviewer", fields: [ - { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" }, - { configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" }, - { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" }, + { + configPath: "taskRunner.reviewer.model", + label: "Reviewer Model", + control: "input", + layer: "L1+L2", + fieldType: "string", + prefsKey: "reviewerModel", + description: "Reviewer model (inherit = use session model)", + }, + { + configPath: "taskRunner.reviewer.tools", + label: "Reviewer Tools", + control: "input", + layer: "L1", + fieldType: "string", + description: "Reviewer tool allowlist", + }, + { + configPath: "taskRunner.reviewer.thinking", + label: "Reviewer Thinking", + control: "picker", + layer: "L1", + fieldType: "string", + description: "Reviewer thinking mode", + }, ], }, { name: "Agent: Merge", fields: [ - { configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (inherit = use session model)" }, - { configPath: "orchestrator.merge.tools", label: "Merge Tools", control: "input", layer: "L1", fieldType: "string", description: "Merge-agent tool allowlist" }, - { configPath: "orchestrator.merge.thinking", label: "Merge Thinking", control: "picker", layer: "L1+L2", fieldType: "string", prefsKey: "mergeThinking", description: "Merge-agent thinking mode" }, - { configPath: "orchestrator.merge.order", label: "Merge Order", control: "toggle", layer: "L1", fieldType: "enum", values: ["fewest-files-first", "sequential"], description: "Lane merge ordering policy" }, - { configPath: "orchestrator.merge.timeoutMinutes", label: "Merge Timeout (minutes)", control: "input", layer: "L1", fieldType: "number", description: "Max time for merge agent to complete. Increase for large batches (default: 10)" }, + { + configPath: "orchestrator.merge.model", + label: "Merge Model", + control: "input", + layer: "L1+L2", + fieldType: "string", + prefsKey: "mergeModel", + description: "Merge-agent model (inherit = use session model)", + }, + { + configPath: "orchestrator.merge.tools", + label: "Merge Tools", + control: "input", + layer: "L1", + fieldType: "string", + description: "Merge-agent tool allowlist", + }, + { + configPath: "orchestrator.merge.thinking", + label: "Merge Thinking", + control: "picker", + layer: "L1+L2", + fieldType: "string", + prefsKey: "mergeThinking", + description: "Merge-agent thinking mode", + }, + { + configPath: "orchestrator.merge.order", + label: "Merge Order", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["fewest-files-first", "sequential"], + description: "Lane merge ordering policy", + }, + { + configPath: "orchestrator.merge.timeoutMinutes", + label: "Merge Timeout (minutes)", + control: "input", + layer: "L1", + fieldType: "number", + description: "Max time for merge agent to complete. Increase for large batches (default: 10)", + }, ], }, { name: "Agent Extensions", - readOnly: true, // Dynamically handled — no fixed fields + readOnly: true, // Dynamically handled — no fixed fields fields: [], }, { name: "Context Limits", fields: [ - { configPath: "taskRunner.context.workerContextWindow", label: "Context Window", control: "input", layer: "L1", fieldType: "number", description: "Worker context window size" }, - { configPath: "taskRunner.context.warnPercent", label: "Warn %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization warn threshold (%)" }, - { configPath: "taskRunner.context.killPercent", label: "Kill %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization hard-stop threshold (%)" }, - { configPath: "taskRunner.context.maxWorkerIterations", label: "Max Iterations", control: "input", layer: "L1", fieldType: "number", description: "Max worker iterations per step" }, - { configPath: "taskRunner.context.maxReviewCycles", label: "Max Review Cycles", control: "input", layer: "L1", fieldType: "number", description: "Max revise loops per review stage" }, - { configPath: "taskRunner.context.noProgressLimit", label: "No Progress Limit", control: "input", layer: "L1", fieldType: "number", description: "Max no-progress iterations before failure" }, - { configPath: "taskRunner.context.maxWorkerMinutes", label: "Max Worker Min (ctx)", control: "input", layer: "L1", fieldType: "number", optional: true, description: "Per-worker wall-clock cap (minutes, empty = no cap)" }, + { + configPath: "taskRunner.context.workerContextWindow", + label: "Context Window", + control: "input", + layer: "L1", + fieldType: "number", + description: "Worker context window size", + }, + { + configPath: "taskRunner.context.warnPercent", + label: "Warn %", + control: "input", + layer: "L1", + fieldType: "number", + description: "Context utilization warn threshold (%)", + }, + { + configPath: "taskRunner.context.killPercent", + label: "Kill %", + control: "input", + layer: "L1", + fieldType: "number", + description: "Context utilization hard-stop threshold (%)", + }, + { + configPath: "taskRunner.context.maxWorkerIterations", + label: "Max Iterations", + control: "input", + layer: "L1", + fieldType: "number", + description: "Max worker iterations per step", + }, + { + configPath: "taskRunner.context.maxReviewCycles", + label: "Max Review Cycles", + control: "input", + layer: "L1", + fieldType: "number", + description: "Max revise loops per review stage", + }, + { + configPath: "taskRunner.context.noProgressLimit", + label: "No Progress Limit", + control: "input", + layer: "L1", + fieldType: "number", + description: "Max no-progress iterations before failure", + }, + { + configPath: "taskRunner.context.maxWorkerMinutes", + label: "Max Worker Min (ctx)", + control: "input", + layer: "L1", + fieldType: "number", + optional: true, + description: "Per-worker wall-clock cap (minutes, empty = no cap)", + }, ], }, { name: "Failure Policy", fields: [ - { configPath: "orchestrator.failure.onTaskFailure", label: "On Task Failure", control: "toggle", layer: "L1", fieldType: "enum", values: ["skip-dependents", "stop-wave", "stop-all"], description: "Batch behavior when a task fails" }, - { configPath: "orchestrator.failure.onMergeFailure", label: "On Merge Failure", control: "toggle", layer: "L1", fieldType: "enum", values: ["pause", "abort"], description: "Behavior when a merge step fails" }, - { configPath: "orchestrator.failure.stallTimeout", label: "Stall Timeout (min)", control: "input", layer: "L1", fieldType: "number", description: "Stall detection threshold (minutes)" }, - { configPath: "orchestrator.failure.maxWorkerMinutes", label: "Max Worker Min", control: "input", layer: "L1", fieldType: "number", description: "Max worker runtime budget per task (minutes)" }, - { configPath: "orchestrator.failure.abortGracePeriod", label: "Abort Grace (sec)", control: "input", layer: "L1", fieldType: "number", description: "Graceful abort wait time (seconds)" }, + { + configPath: "orchestrator.failure.onTaskFailure", + label: "On Task Failure", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["skip-dependents", "stop-wave", "stop-all"], + description: "Batch behavior when a task fails", + }, + { + configPath: "orchestrator.failure.onMergeFailure", + label: "On Merge Failure", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["pause", "abort"], + description: "Behavior when a merge step fails", + }, + { + configPath: "orchestrator.failure.stallTimeout", + label: "Stall Timeout (min)", + control: "input", + layer: "L1", + fieldType: "number", + description: "Stall detection threshold (minutes)", + }, + { + configPath: "orchestrator.failure.maxWorkerMinutes", + label: "Max Worker Min", + control: "input", + layer: "L1", + fieldType: "number", + description: "Max worker runtime budget per task (minutes)", + }, + { + configPath: "orchestrator.failure.abortGracePeriod", + label: "Abort Grace (sec)", + control: "input", + layer: "L1", + fieldType: "number", + description: "Graceful abort wait time (seconds)", + }, ], }, { name: "Dependencies", fields: [ - { configPath: "orchestrator.dependencies.source", label: "Dep Source", control: "toggle", layer: "L1", fieldType: "enum", values: ["prompt", "agent"], description: "Dependency extraction source" }, - { configPath: "orchestrator.dependencies.cache", label: "Dep Cache", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Cache dependency analysis results" }, + { + configPath: "orchestrator.dependencies.source", + label: "Dep Source", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["prompt", "agent"], + description: "Dependency extraction source", + }, + { + configPath: "orchestrator.dependencies.cache", + label: "Dep Cache", + control: "toggle", + layer: "L1", + fieldType: "boolean", + values: ["true", "false"], + description: "Cache dependency analysis results", + }, ], }, { name: "Assignment", fields: [ - { configPath: "orchestrator.assignment.strategy", label: "Strategy", control: "toggle", layer: "L1", fieldType: "enum", values: ["affinity-first", "round-robin", "load-balanced"], description: "Lane assignment strategy" }, + { + configPath: "orchestrator.assignment.strategy", + label: "Strategy", + control: "toggle", + layer: "L1", + fieldType: "enum", + values: ["affinity-first", "round-robin", "load-balanced"], + description: "Lane assignment strategy", + }, ], }, { name: "Pre-Warm", fields: [ - { configPath: "orchestrator.preWarm.autoDetect", label: "Auto-Detect", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Enable automatic pre-warm command detection" }, + { + configPath: "orchestrator.preWarm.autoDetect", + label: "Auto-Detect", + control: "toggle", + layer: "L1", + fieldType: "boolean", + values: ["true", "false"], + description: "Enable automatic pre-warm command detection", + }, ], }, { name: "Monitoring", fields: [ - { configPath: "orchestrator.monitoring.pollInterval", label: "Poll Interval (sec)", control: "input", layer: "L1", fieldType: "number", description: "Poll interval for lane/task monitoring (seconds)" }, + { + configPath: "orchestrator.monitoring.pollInterval", + label: "Poll Interval (sec)", + control: "input", + layer: "L1", + fieldType: "number", + description: "Poll interval for lane/task monitoring (seconds)", + }, ], }, { name: "Global Preferences", fields: [ - { configPath: "preferences.dashboardPort", label: "Dashboard Port", control: "input", layer: "L2", fieldType: "number", prefsKey: "dashboardPort", optional: true, description: "Dashboard server port" }, + { + configPath: "preferences.dashboardPort", + label: "Dashboard Port", + control: "input", + layer: "L2", + fieldType: "number", + prefsKey: "dashboardPort", + optional: true, + description: "Dashboard server port", + }, ], }, { name: "Advanced (JSON Only)", readOnly: true, - fields: [], // Populated dynamically in getAdvancedItems() + fields: [], // Populated dynamically in getAdvancedItems() }, ]; - // ── Raw Config Readers (Source Detection) ──────────────────────────── /** @@ -256,7 +548,9 @@ export function readRawYamlConfigs(configRoot: string): Record | nu if (parsed && typeof parsed === "object") { result.taskRunner = convertYamlKeys(parsed, "taskRunner"); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } if (hasOrch) { @@ -266,7 +560,9 @@ export function readRawYamlConfigs(configRoot: string): Record | nu if (parsed && typeof parsed === "object") { result.orchestrator = convertYamlKeys(parsed, "orchestrator"); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } return Object.keys(result).length > 0 ? result : null; @@ -338,7 +634,6 @@ function readRawPreferences(): Record | null { } } - // ── Write-Back ─────────────────────────────────────────────────────── /** @@ -425,8 +720,8 @@ export function writeProjectConfigField( } catch (e: any) { throw new Error( `Cannot write settings: ${jsonPath} contains malformed JSON. ` + - `Please fix or delete the file and try again. ` + - `(Parse error: ${e.message ?? "unknown"})`, + `Please fix or delete the file and try again. ` + + `(Parse error: ${e.message ?? "unknown"})`, ); } } else { @@ -449,7 +744,11 @@ export function writeProjectConfigField( renameSync(tmpPath, jsonPath); } catch { writeFileSync(jsonPath, json, "utf-8"); - try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ } + try { + if (existsSync(tmpPath)) unlinkSync(tmpPath); + } catch { + /* cleanup best-effort */ + } } } @@ -488,7 +787,11 @@ export function writeGlobalPreference(path: string, value: any): void { renameSync(tmpPath, prefsPath); } catch { writeFileSync(prefsPath, json, "utf-8"); - try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ } + try { + if (existsSync(tmpPath)) unlinkSync(tmpPath); + } catch { + /* cleanup best-effort */ + } } } @@ -559,7 +862,6 @@ export function resolveWriteAction( return defaultDest; } - // ── Source Detection ───────────────────────────────────────────────── /** @@ -596,7 +898,6 @@ export function detectFieldSource( return "global"; } - // ── Value Formatting ───────────────────────────────────────────────── /** @@ -628,7 +929,6 @@ export function getFieldDisplayValue( return String(val); } - // ── Validation ─────────────────────────────────────────────────────── export interface ValidationResult { @@ -683,7 +983,6 @@ export function validateFieldInput(field: FieldDef, input: string): ValidationRe } } - // ── Advanced Section Items ─────────────────────────────────────────── export interface AdvancedItem { @@ -772,11 +1071,7 @@ export function getAdvancedItems(config: TaskplaneConfig): AdvancedItem[] { * Known subsection objects (like `taskRunner.worker`, `orchestrator.merge`) * are recursed into, not reported as leaves themselves. */ -function walkConfig( - obj: any, - prefix: string, - visitor: (path: string, value: any) => void, -): void { +function walkConfig(obj: any, prefix: string, visitor: (path: string, value: any) => void): void { if (obj === null || obj === undefined) return; for (const [key, value] of Object.entries(obj)) { @@ -861,7 +1156,6 @@ function summarizeArray(arr: any[]): string { return `${arr.length} items`; } - // ── TUI Rendering ──────────────────────────────────────────────────── /** @@ -890,7 +1184,10 @@ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise ]; function normalizeThinkingMode(value: unknown): ThinkingModeValue { - const cleaned = String(value ?? "").trim().toLowerCase(); + const cleaned = String(value ?? "") + .trim() + .toLowerCase(); if (!cleaned || cleaned === "inherit") return ""; if (cleaned === "on") return "high"; if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) { @@ -1014,15 +1313,17 @@ function resolveModelRecord(ctx: ExtensionContext, modelRef: string): any | unde if (slashIdx > 0) { const provider = trimmed.slice(0, slashIdx).toLowerCase(); const id = trimmed.slice(slashIdx + 1).toLowerCase(); - return available.find((m: any) => - String(m?.provider ?? "").toLowerCase() === provider - && String(m?.id ?? "").toLowerCase() === id, + return available.find( + (m: any) => + String(m?.provider ?? "").toLowerCase() === provider && + String(m?.id ?? "").toLowerCase() === id, ); } - return available.find((m: any) => - String(m?.id ?? "").toLowerCase() === lower - || `${String(m?.provider ?? "").toLowerCase()}/${String(m?.id ?? "").toLowerCase()}` === lower, + return available.find( + (m: any) => + String(m?.id ?? "").toLowerCase() === lower || + `${String(m?.provider ?? "").toLowerCase()}/${String(m?.id ?? "").toLowerCase()}` === lower, ); } @@ -1046,12 +1347,9 @@ export function modelSupportsThinking(model: any): boolean { "reasoning_tokens", ]; - const candidateObjects = [ - model, - model.capabilities, - model.features, - model.metadata, - ].filter((entry) => entry && typeof entry === "object"); + const candidateObjects = [model, model.capabilities, model.features, model.metadata].filter( + (entry) => entry && typeof entry === "object", + ); for (const candidate of candidateObjects) { for (const key of boolFlags) { @@ -1145,7 +1443,9 @@ async function selectScrollable( container.addChild(selectList); container.addChild(new Text("", 0, 0)); - container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc back"), 1, 0)); + container.addChild( + new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc back"), 1, 0), + ); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { @@ -1160,7 +1460,8 @@ async function selectScrollable( if (selectedValue === undefined) return undefined; const selectedIndex = Number(selectedValue); - if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= options.length) return undefined; + if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= options.length) + return undefined; return options[selectedIndex]; } @@ -1178,7 +1479,10 @@ export async function openSettingsTui( * Reload all config state from disk. Called after write-back to * refresh the TUI display. */ -function loadConfigState(configRoot: string, pointerConfigRoot?: string): { +function loadConfigState( + configRoot: string, + pointerConfigRoot?: string, +): { mergedConfig: TaskplaneConfig; prefs: GlobalPreferences; rawProject: Record | null; @@ -1211,11 +1515,12 @@ async function showSectionSelectorLoop( const sectionItems: SelectItem[] = SECTIONS.map((section, i) => ({ value: String(i), label: section.name, - description: section.name === "Agent Extensions" - ? "Toggle extensions per agent type" - : section.readOnly - ? "Read-only collection/record fields" - : `${section.fields.length} setting${section.fields.length === 1 ? "" : "s"}`, + description: + section.name === "Agent Extensions" + ? "Toggle extensions per agent type" + : section.readOnly + ? "Read-only collection/record fields" + : `${section.fields.length} setting${section.fields.length === 1 ? "" : "s"}`, })); const selectedSection = await ctx.ui.custom((tui, theme, _kb, done) => { @@ -1226,7 +1531,9 @@ async function showSectionSelectorLoop( // Title container.addChild(new Text(theme.fg("accent", theme.bold("⚙ Settings")), 1, 0)); - container.addChild(new Text(theme.fg("dim", "Navigate sections to view and edit configuration"), 1, 0)); + container.addChild( + new Text(theme.fg("dim", "Navigate sections to view and edit configuration"), 1, 0), + ); container.addChild(new Text("", 0, 0)); // SelectList @@ -1251,11 +1558,14 @@ async function showSectionSelectorLoop( return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), - handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, + handleInput: (data: string) => { + selectList.handleInput(data); + tui.requestRender(); + }, }; }); - if (selectedSection === null) return; // User pressed Esc + if (selectedSection === null) return; // User pressed Esc const sectionIndex = parseInt(selectedSection, 10); const section = SECTIONS[sectionIndex]; @@ -1295,15 +1605,17 @@ async function showAdvancedSection( // Title container.addChild(new Text(theme.fg("accent", theme.bold("Advanced (JSON Only)")), 1, 0)); - container.addChild(new Text(theme.fg("dim", "These fields can only be edited directly in the config file"), 1, 0)); + container.addChild( + new Text(theme.fg("dim", "These fields can only be edited directly in the config file"), 1, 0), + ); container.addChild(new Text("", 0, 0)); const settingsList = new SettingsList( settingsItems, Math.min(settingsItems.length + 2, 20), getSettingsListTheme(), - () => {}, // onChange — no-op (read-only) - () => done(undefined), // onCancel + () => {}, // onChange — no-op (read-only) + () => done(undefined), // onCancel ); container.addChild(settingsList); @@ -1317,7 +1629,10 @@ async function showAdvancedSection( return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), - handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); }, + handleInput: (data: string) => { + settingsList.handleInput?.(data); + tui.requestRender(); + }, }; }); } @@ -1351,14 +1666,18 @@ async function showExtensionsSection( container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0)); container.addChild(new Text("", 0, 0)); container.addChild(new Text(theme.fg("dim", "No third-party extensions found."), 1, 0)); - container.addChild(new Text(theme.fg("dim", "Install extensions via pi settings to see them here."), 1, 0)); + container.addChild( + new Text(theme.fg("dim", "Install extensions via pi settings to see them here."), 1, 0), + ); container.addChild(new Text("", 0, 0)); container.addChild(new Text(theme.fg("dim", "esc back"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), - handleInput: (data: string) => { if (data === "\x1b" || data === "\x1b\x1b") done(undefined); }, + handleInput: (data: string) => { + if (data === "\x1b" || data === "\x1b\x1b") done(undefined); + }, }; }); return; @@ -1371,7 +1690,11 @@ async function showExtensionsSection( const agentTypes = [ { name: "Worker", exclude: workerExclude, configPath: "taskRunner.worker.excludeExtensions" }, - { name: "Reviewer", exclude: reviewerExclude, configPath: "taskRunner.reviewer.excludeExtensions" }, + { + name: "Reviewer", + exclude: reviewerExclude, + configPath: "taskRunner.reviewer.excludeExtensions", + }, { name: "Merger", exclude: mergeExclude, configPath: "orchestrator.merge.excludeExtensions" }, ]; @@ -1391,32 +1714,37 @@ async function showExtensionsSection( } } - const result = await ctx.ui.custom<{ id: string; value: string } | null>((tui, theme, _kb, done) => { - const container = new Container(); - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0)); - container.addChild(new Text(theme.fg("dim", "Toggle extensions on/off per agent type"), 1, 0)); - container.addChild(new Text("", 0, 0)); + const result = await ctx.ui.custom<{ id: string; value: string } | null>( + (tui, theme, _kb, done) => { + const container = new Container(); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0)); + container.addChild(new Text(theme.fg("dim", "Toggle extensions on/off per agent type"), 1, 0)); + container.addChild(new Text("", 0, 0)); - const settingsList = new SettingsList( - settingsItems, - Math.min(settingsItems.length + 2, 20), - getSettingsListTheme(), - (id, newValue) => done({ id, value: newValue }), - () => done(null), - ); - container.addChild(settingsList); + const settingsList = new SettingsList( + settingsItems, + Math.min(settingsItems.length + 2, 20), + getSettingsListTheme(), + (id, newValue) => done({ id, value: newValue }), + () => done(null), + ); + container.addChild(settingsList); - container.addChild(new Text("", 0, 0)); - container.addChild(new Text(theme.fg("dim", "↑↓ navigate • space toggle • esc back"), 1, 0)); - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text("", 0, 0)); + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • space toggle • esc back"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - return { - render: (w: number) => container.render(w), - invalidate: () => container.invalidate(), - handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); }, - }; - }); + return { + render: (w: number) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data: string) => { + settingsList.handleInput?.(data); + tui.requestRender(); + }, + }; + }, + ); if (!result) return; // User pressed Esc @@ -1428,7 +1756,8 @@ async function showExtensionsSection( // Read current exclusion array from merged effective config (handles YAML+JSON) const freshConfig = loadProjectConfig(configRoot, pointerConfigRoot); - const currentExcludeList: string[] = (getNestedValue(freshConfig, configPath) as string[] | undefined) ?? []; + const currentExcludeList: string[] = + (getNestedValue(freshConfig, configPath) as string[] | undefined) ?? []; let newExcludeList: string[]; if (enabling) { @@ -1444,7 +1773,11 @@ async function showExtensionsSection( try { writeProjectConfigField(configRoot, configPath, newExcludeList, pointerConfigRoot); if (onConfigChanged) { - try { onConfigChanged(); } catch { /* non-fatal */ } + try { + onConfigChanged(); + } catch { + /* non-fatal */ + } } ctx.ui.notify( `${enabling ? "✅ Enabled" : "❌ Disabled"} ${pkg} for ${configPath.includes("worker") ? "Worker" : configPath.includes("reviewer") ? "Reviewer" : "Merger"}`, @@ -1463,8 +1796,10 @@ async function showExtensionsSection( */ function formatSourceBadge(source: FieldSource): string { switch (source) { - case "project": return "(project)"; - case "global": return "(global)"; + case "project": + return "(project)"; + case "global": + return "(global)"; } } @@ -1487,18 +1822,28 @@ async function showSectionSettingsLoop( ): Promise { while (true) { const state = loadConfigState(configRoot, pointerConfigRoot); - const result = await showSectionSettingsOnce(ctx, section, state.mergedConfig, state.prefs, state.rawProject, state.rawPrefs); + const result = await showSectionSettingsOnce( + ctx, + section, + state.mergedConfig, + state.prefs, + state.rawProject, + state.rawPrefs, + ); - if (result === null) return; // User pressed Esc → back to sections + if (result === null) return; // User pressed Esc → back to sections // Process the pending change const field = section.fields.find((f) => f.configPath === result.fieldId); - if (!field) continue; // Safety: field not found + if (!field) continue; // Safety: field not found let previousModelValue = ""; // Input/picker fields: the submenu returned a sentinel — open the editor picker. - if (result.rawValue === "__EDIT_REQUESTED__" && (field.control === "input" || field.control === "picker")) { + if ( + result.rawValue === "__EDIT_REQUESTED__" && + (field.control === "input" || field.control === "picker") + ) { const state = loadConfigState(configRoot, pointerConfigRoot); const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs); const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|global)\)$/, ""); @@ -1508,31 +1853,30 @@ async function showSectionSettingsLoop( if (field.configPath.endsWith(".model")) { previousModelValue = normalizedCurrent; const selected = await pickModel(ctx, normalizedCurrent); - if (selected === undefined) continue; // Cancelled + if (selected === undefined) continue; // Cancelled result.rawValue = selected; } else if (field.control === "picker" && field.configPath.endsWith(".thinking")) { const note = buildThinkingUnsupportedNoteForThinkingField(ctx, field, state.mergedConfig); if (note) ctx.ui.notify(note, "info"); const selected = await pickThinkingMode(ctx, normalizedCurrent); - if (selected === undefined) continue; // Cancelled + if (selected === undefined) continue; // Cancelled result.rawValue = selected; } else if (field.control === "picker" && field.values && field.values.length > 0) { // Enum picker: show scrollable list of allowed values - const options = field.values.map((v) => - `${v}${v === normalizedCurrent ? " ✓ current" : ""}` - ); + const options = field.values.map((v) => `${v}${v === normalizedCurrent ? " ✓ current" : ""}`); const selected = await selectScrollable(ctx, field.label, options); - if (!selected) continue; // Cancelled + if (!selected) continue; // Cancelled result.rawValue = selected.replace(/\s+✓ current$/, ""); } else { - const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean; + const placeholder = + currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean; const newValue = await ctx.ui.input( `${field.label}${field.description ? ` — ${field.description}` : ""}`, placeholder, ); - if (newValue === null || newValue === undefined) continue; // Cancelled + if (newValue === null || newValue === undefined) continue; // Cancelled // Validate const validation = validateFieldInput(field, newValue); @@ -1585,13 +1929,14 @@ async function showSectionSettingsLoop( } // Notify caller to reload in-memory config from disk if (onConfigChanged) { - try { onConfigChanged(); } catch { /* non-fatal */ } + try { + onConfigChanged(); + } catch { + /* non-fatal */ + } } - ctx.ui.notify( - `✅ ${field.label} updated.`, - "info", - ); + ctx.ui.notify(`✅ ${field.label} updated.`, "info"); const refreshedState = loadConfigState(configRoot, pointerConfigRoot); const suggestion = buildThinkingSuggestionForModelChange( @@ -1657,17 +2002,13 @@ async function showSectionSettingsOnce( })); // Return SelectList directly — Container doesn't forward // handleInput to children, which would freeze the TUI. - const list = new SelectList( - selectItems, - Math.min(selectItems.length + 1, 10), - { - selectedPrefix: (t: string) => `\x1b[36m${t}\x1b[0m`, - selectedText: (t: string) => `\x1b[36m${t}\x1b[0m`, - description: (t: string) => `\x1b[2m${t}\x1b[0m`, - scrollInfo: (t: string) => `\x1b[2m${t}\x1b[0m`, - noMatch: (t: string) => `\x1b[33m${t}\x1b[0m`, - }, - ); + const list = new SelectList(selectItems, Math.min(selectItems.length + 1, 10), { + selectedPrefix: (t: string) => `\x1b[36m${t}\x1b[0m`, + selectedText: (t: string) => `\x1b[36m${t}\x1b[0m`, + description: (t: string) => `\x1b[2m${t}\x1b[0m`, + scrollInfo: (t: string) => `\x1b[2m${t}\x1b[0m`, + noMatch: (t: string) => `\x1b[33m${t}\x1b[0m`, + }); const currentIdx = field.values!.indexOf(displayValue); if (currentIdx >= 0) list.setSelectedIndex(currentIdx); list.onSelect = (selected) => done(selected.value); @@ -1710,7 +2051,7 @@ async function showSectionSettingsOnce( // Exit TUI with the change so the caller can handle write-back done({ fieldId: id, rawValue: newValue }); }, - () => done(null), // onCancel → back to section selector + () => done(null), // onCancel → back to section selector { enableSearch: settingsItems.length > 5 }, ); container.addChild(settingsList); @@ -1723,10 +2064,9 @@ async function showSectionSettingsOnce( // Help text container.addChild(new Text("", 0, 0)); - container.addChild(new Text( - theme.fg("dim", "↑↓ navigate • ←→/space cycle • enter edit • esc back"), - 1, 0, - )); + container.addChild( + new Text(theme.fg("dim", "↑↓ navigate • ←→/space cycle • enter edit • esc back"), 1, 0), + ); // Bottom border container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); @@ -1734,12 +2074,14 @@ async function showSectionSettingsOnce( return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), - handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); }, + handleInput: (data: string) => { + settingsList.handleInput?.(data); + tui.requestRender(); + }, }; }); } - // ── Input Submenu ──────────────────────────────────────────────────── /** @@ -1761,7 +2103,7 @@ function createInputSubmenu( render(width: number): string[] { const lines: string[] = []; const prompt = ` Enter ${field.label}: `; - const inputDisplay = inputBuffer + "█"; // Simple cursor + const inputDisplay = inputBuffer + "█"; // Simple cursor lines.push(truncateLine(prompt + inputDisplay, width)); if (field.optional) { @@ -1818,7 +2160,6 @@ function truncateLine(text: string, width: number): string { return text.substring(0, width - 3) + "..."; } - // ── JSON-Only Footer ───────────────────────────────────────────────── /** @@ -1826,17 +2167,17 @@ function truncateLine(text: string, width: number): string { * Used to dynamically discover JSON-only sibling fields. */ const SECTION_CONFIG_PREFIXES: Record = { - "Orchestrator": ["orchestrator.orchestrator"], + Orchestrator: ["orchestrator.orchestrator"], "Agent: Supervisor": ["orchestrator.supervisor"], "Agent: Worker": ["taskRunner.worker"], "Agent: Reviewer": ["taskRunner.reviewer"], "Agent: Merge": ["orchestrator.merge"], "Context Limits": ["taskRunner.context"], "Failure Policy": ["orchestrator.failure"], - "Dependencies": ["orchestrator.dependencies"], - "Assignment": ["orchestrator.assignment"], + Dependencies: ["orchestrator.dependencies"], + Assignment: ["orchestrator.assignment"], "Pre-Warm": ["orchestrator.preWarm"], - "Monitoring": ["orchestrator.monitoring"], + Monitoring: ["orchestrator.monitoring"], }; /** diff --git a/extensions/taskplane/sidecar-telemetry.ts b/extensions/taskplane/sidecar-telemetry.ts index 75864e0e..43dc5d64 100644 --- a/extensions/taskplane/sidecar-telemetry.ts +++ b/extensions/taskplane/sidecar-telemetry.ts @@ -107,12 +107,25 @@ export interface SidecarTelemetryDelta { * * The caller (poll loop) accumulates the returned deltas into TaskState. */ -export function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): SidecarTelemetryDelta { +export function tailSidecarJsonl( + filePath: string, + tailState: SidecarTailState, +): SidecarTelemetryDelta { const delta: SidecarTelemetryDelta = { - inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, - cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "", - retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "", - hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0, + latestTotalTokens: 0, + toolCalls: 0, + lastTool: "", + retryActive: tailState.retryActive, + retriesStarted: 0, + lastRetryError: "", + hadEvents: false, + contextUsage: null, + sawStatsResponseWithoutContextUsage: false, }; // Gracefully handle missing file (wrapper hasn't written yet) @@ -176,16 +189,18 @@ export function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): delta.cacheReadTokens += usage.cacheRead || 0; delta.cacheWriteTokens += usage.cacheWrite || 0; if (usage.cost) { - delta.cost += typeof usage.cost === "object" - ? (usage.cost.total || 0) - : (typeof usage.cost === "number" ? usage.cost : 0); + delta.cost += + typeof usage.cost === "object" + ? usage.cost.total || 0 + : typeof usage.cost === "number" + ? usage.cost + : 0; } // totalTokens is cumulative (grows each turn) — use latest value. // Include cacheRead tokens: pi's totalTokens and the // input+output fallback both exclude cache reads, but cached // tokens still consume context window capacity. - const rawTotal = usage.totalTokens - || ((usage.input || 0) + (usage.output || 0)); + const rawTotal = usage.totalTokens || (usage.input || 0) + (usage.output || 0); const totalTokens = rawTotal + (usage.cacheRead || 0); if (totalTokens > delta.latestTotalTokens) { delta.latestTotalTokens = totalTokens; diff --git a/extensions/taskplane/supervisor.ts b/extensions/taskplane/supervisor.ts index bd35589b..f232e338 100644 --- a/extensions/taskplane/supervisor.ts +++ b/extensions/taskplane/supervisor.ts @@ -28,12 +28,37 @@ import { join, dirname } from "path"; import { fileURLToPath } from "url"; -import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync, openSync, readSync, closeSync, appendFileSync } from "fs"; -import { stat as fsStat, open as fsOpen, readFile as fsReadFile, writeFile as fsWriteFile, rename as fsRename } from "fs/promises"; +import { + existsSync, + readFileSync, + readdirSync, + writeFileSync, + unlinkSync, + mkdirSync, + renameSync, + statSync, + openSync, + readSync, + closeSync, + appendFileSync, +} from "fs"; +import { + stat as fsStat, + open as fsOpen, + readFile as fsReadFile, + writeFile as fsWriteFile, + rename as fsRename, +} from "fs/promises"; import { execFileSync } from "child_process"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { Model, Api } from "@mariozechner/pi-ai"; -import type { OrchBatchRuntimeState, OrchestratorConfig, PersistedBatchState, EngineEvent, EngineEventType } from "./types.ts"; +import type { + OrchBatchRuntimeState, + OrchestratorConfig, + PersistedBatchState, + EngineEvent, + EngineEventType, +} from "./types.ts"; import type { Tier0Event, Tier0EventType } from "./persistence.ts"; // ── Recovery Action Classification (TP-041 Step 4) ─────────────────── @@ -99,7 +124,9 @@ export function requiresConfirmation( * * @since TP-041 */ -export const ACTION_CLASSIFICATION_EXAMPLES: Readonly> = { +export const ACTION_CLASSIFICATION_EXAMPLES: Readonly< + Record +> = { diagnostic: [ "Reading batch-state.json, STATUS.md, events.jsonl, merge results", "Running git status, git log, git diff", @@ -126,7 +153,6 @@ export const ACTION_CLASSIFICATION_EXAMPLES: Readonly 0 ? `, ${plan.failedTasks} failed` : ""}`); + lines.push( + `- **Tasks:** ${plan.succeededTasks} succeeded${plan.failedTasks > 0 ? `, ${plan.failedTasks} failed` : ""}`, + ); lines.push(`- **Rationale:** ${plan.rationale}`); if (plan.branchProtection === "protected") { @@ -571,7 +602,8 @@ export function formatIntegrationOutcome( detail: string, ): string { if (success) { - const modeLabel = plan.mode === "ff" ? "Fast-forwarded" : plan.mode === "merge" ? "Merged" : "Created PR for"; + const modeLabel = + plan.mode === "ff" ? "Fast-forwarded" : plan.mode === "merge" ? "Merged" : "Created PR for"; return `✅ **Integration complete!** ${modeLabel} \`${plan.orchBranch}\` → \`${plan.baseBranch}\`.\n${detail}`; } return `❌ **Integration failed** (\`${plan.orchBranch}\` → \`${plan.baseBranch}\`).\n${detail}`; @@ -587,8 +619,20 @@ export function formatIntegrationOutcome( */ export type IntegrationExecutor = ( mode: "ff" | "merge" | "pr", - context: { orchBranch: string; baseBranch: string; batchId: string; currentBranch: string; notices: string[] }, -) => { success: boolean; integratedLocally: boolean; commitCount: string; message: string; error?: string }; + context: { + orchBranch: string; + baseBranch: string; + batchId: string; + currentBranch: string; + notices: string[]; + }, +) => { + success: boolean; + integratedLocally: boolean; + commitCount: string; + message: string; + error?: string; +}; /** * Dependencies for programmatic CI polling and PR merge (R002-2). @@ -631,11 +675,15 @@ export async function pollPrCiStatus( for (let attempt = 1; attempt <= maxAttempts; attempt++) { // Wait before polling (except first attempt — check immediately) if (attempt > 1) { - await new Promise(resolve => setTimeout(resolve, delayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } const result = deps.runCommand("gh", [ - "pr", "checks", orchBranch, "--json", "name,state,conclusion", + "pr", + "checks", + orchBranch, + "--json", + "name,state,conclusion", ]); if (!result.ok) { @@ -661,16 +709,18 @@ export async function pollPrCiStatus( } // Check if all checks are complete - const allComplete = checks.every(c => - c.state === "COMPLETED" || c.state === "completed", - ); + const allComplete = checks.every((c) => c.state === "COMPLETED" || c.state === "completed"); if (!allComplete) continue; // Some still pending — keep polling // All complete — check conclusions - const allPassing = checks.every(c => - c.conclusion === "SUCCESS" || c.conclusion === "success" || - c.conclusion === "NEUTRAL" || c.conclusion === "neutral" || - c.conclusion === "SKIPPED" || c.conclusion === "skipped", + const allPassing = checks.every( + (c) => + c.conclusion === "SUCCESS" || + c.conclusion === "success" || + c.conclusion === "NEUTRAL" || + c.conclusion === "neutral" || + c.conclusion === "SKIPPED" || + c.conclusion === "skipped", ); if (allPassing) { @@ -678,16 +728,23 @@ export async function pollPrCiStatus( } // Some checks failed - const failed = checks.filter(c => - c.conclusion !== "SUCCESS" && c.conclusion !== "success" && - c.conclusion !== "NEUTRAL" && c.conclusion !== "neutral" && - c.conclusion !== "SKIPPED" && c.conclusion !== "skipped", + const failed = checks.filter( + (c) => + c.conclusion !== "SUCCESS" && + c.conclusion !== "success" && + c.conclusion !== "NEUTRAL" && + c.conclusion !== "neutral" && + c.conclusion !== "SKIPPED" && + c.conclusion !== "skipped", ); - const failedNames = failed.map(c => `${c.name}: ${c.conclusion}`).join(", "); + const failedNames = failed.map((c) => `${c.name}: ${c.conclusion}`).join(", "); return { status: "fail", detail: `CI check(s) failed: ${failedNames}` }; } - return { status: "timeout", detail: `CI checks did not complete within ${maxAttempts} polling attempts.` }; + return { + status: "timeout", + detail: `CI checks did not complete within ${maxAttempts} polling attempts.`, + }; } /** @@ -706,13 +763,14 @@ export async function pollPrCiStatus( * * @since TP-043 */ -export function mergePr( - orchBranch: string, - deps: CiDeps, -): { success: boolean; detail: string } { +export function mergePr(orchBranch: string, deps: CiDeps): { success: boolean; detail: string } { // Try regular merge first (preserves per-commit history) const mergeResult = deps.runCommand("gh", [ - "pr", "merge", orchBranch, "--merge", "--delete-branch", + "pr", + "merge", + orchBranch, + "--merge", + "--delete-branch", ]); if (mergeResult.ok) { return { success: true, detail: "PR merged and remote branch deleted." }; @@ -720,7 +778,11 @@ export function mergePr( // Regular merge not allowed — try squash as fallback const squashResult = deps.runCommand("gh", [ - "pr", "merge", orchBranch, "--squash", "--delete-branch", + "pr", + "merge", + orchBranch, + "--squash", + "--delete-branch", ]); if (squashResult.ok) { return { success: true, detail: "PR merged (squash) and remote branch deleted." }; @@ -744,9 +806,17 @@ export interface SummaryDeps { /** Operator identifier for file naming */ opId: string; /** Batch diagnostics (taskExits, batchCost) — null if unavailable */ - diagnostics: { taskExits: Record; batchCost: number } | null; + diagnostics: { + taskExits: Record; + batchCost: number; + } | null; /** Merge results for cost breakdown */ - mergeResults: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>; + mergeResults: Array<{ + waveIndex: number; + status: string; + failedLane: number | null; + failureReason: string | null; + }>; } /** @@ -787,12 +857,14 @@ async function handlePrLifecycle( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: - `✅ **Integration complete!** PR merged into \`${plan.baseBranch}\`.\n` + - `${ciResult.detail}\n${mergeOutcome.detail}`, - }], + content: [ + { + type: "text", + text: + `✅ **Integration complete!** PR merged into \`${plan.baseBranch}\`.\n` + + `${ciResult.detail}\n${mergeOutcome.detail}`, + }, + ], display: "Integration complete — PR merged", }, { triggerTurn: false }, @@ -801,12 +873,14 @@ async function handlePrLifecycle( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: - `⚠️ **CI passed but merge failed.** ${mergeOutcome.detail}\n` + - `The PR is still open — merge manually on GitHub.`, - }], + content: [ + { + type: "text", + text: + `⚠️ **CI passed but merge failed.** ${mergeOutcome.detail}\n` + + `The PR is still open — merge manually on GitHub.`, + }, + ], display: "CI passed but PR merge failed", }, { triggerTurn: false }, @@ -816,12 +890,14 @@ async function handlePrLifecycle( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: - `❌ **CI checks failed.** ${ciResult.detail}\n` + - `The PR is still open. Fix the issues and merge manually, or close and retry.`, - }], + content: [ + { + type: "text", + text: + `❌ **CI checks failed.** ${ciResult.detail}\n` + + `The PR is still open. Fix the issues and merge manually, or close and retry.`, + }, + ], display: "CI checks failed — manual intervention needed", }, { triggerTurn: false }, @@ -831,12 +907,14 @@ async function handlePrLifecycle( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: - `⏰ **CI check timeout.** ${ciResult.detail}\n` + - `The PR is still open. Check CI status manually and merge when ready.`, - }], + content: [ + { + type: "text", + text: + `⏰ **CI check timeout.** ${ciResult.detail}\n` + + `The PR is still open. Check CI status manually and merge when ready.`, + }, + ], display: "CI check timeout — check manually", }, { triggerTurn: false }, @@ -845,7 +923,14 @@ async function handlePrLifecycle( // TP-043: Generate batch summary before deactivation if (batchState && summaryDeps && state.stateRoot) { - presentBatchSummary(pi, batchState, state.stateRoot, summaryDeps.opId, summaryDeps.diagnostics, summaryDeps.mergeResults); + presentBatchSummary( + pi, + batchState, + state.stateRoot, + summaryDeps.opId, + summaryDeps.diagnostics, + summaryDeps.mergeResults, + ); } // Always deactivate after PR lifecycle completes (R002 issue #3) @@ -897,7 +982,14 @@ export function triggerSupervisorIntegration( // TP-043: Helper to generate summary before deactivation const summarizeAndDeactivate = () => { if (summaryDeps && state.stateRoot) { - presentBatchSummary(pi, batchState, state.stateRoot, summaryDeps.opId, summaryDeps.diagnostics, summaryDeps.mergeResults); + presentBatchSummary( + pi, + batchState, + state.stateRoot, + summaryDeps.opId, + summaryDeps.diagnostics, + summaryDeps.mergeResults, + ); } deactivateSupervisor(pi, state); }; @@ -910,10 +1002,12 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration", - content: [{ - type: "text", - text: `📋 **Batch complete.** No integration needed (no orch branch or no succeeded tasks). Supervisor deactivating.`, - }], + content: [ + { + type: "text", + text: `📋 **Batch complete.** No integration needed (no orch branch or no succeeded tasks). Supervisor deactivating.`, + }, + ], display: "No integration needed — supervisor deactivating", }, { triggerTurn: false }, @@ -932,23 +1026,26 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration", - content: [{ - type: "text", - text: - `🏁 **Batch complete!** Ready to integrate.\n\n` + - planText + `\n\n` + - `**Action required:** Ask the operator for confirmation.\n\n` + - `Say something like: "The batch completed successfully. I'd like to integrate ` + - `the changes from \`${plan.orchBranch}\` into \`${plan.baseBranch}\` using ` + - `${plan.mode === "ff" ? "fast-forward" : plan.mode === "merge" ? "a merge commit" : "a pull request"}. ` + - `${plan.rationale} Shall I proceed?"\n\n` + - `If the operator confirms, run: \`/orch-integrate${modeFlag}\`\n` + - `If the operator declines, acknowledge and deactivate.\n` + - `If the operator wants a different mode, adjust the flag:\n` + - ` - Fast-forward: \`/orch-integrate\`\n` + - ` - Merge commit: \`/orch-integrate --merge\`\n` + - ` - Pull request: \`/orch-integrate --pr\``, - }], + content: [ + { + type: "text", + text: + `🏁 **Batch complete!** Ready to integrate.\n\n` + + planText + + `\n\n` + + `**Action required:** Ask the operator for confirmation.\n\n` + + `Say something like: "The batch completed successfully. I'd like to integrate ` + + `the changes from \`${plan.orchBranch}\` into \`${plan.baseBranch}\` using ` + + `${plan.mode === "ff" ? "fast-forward" : plan.mode === "merge" ? "a merge commit" : "a pull request"}. ` + + `${plan.rationale} Shall I proceed?"\n\n` + + `If the operator confirms, run: \`/orch-integrate${modeFlag}\`\n` + + `If the operator declines, acknowledge and deactivate.\n` + + `If the operator wants a different mode, adjust the flag:\n` + + ` - Fast-forward: \`/orch-integrate\`\n` + + ` - Merge commit: \`/orch-integrate --merge\`\n` + + ` - Pull request: \`/orch-integrate --pr\``, + }, + ], display: "Integration plan ready — awaiting operator confirmation", }, { triggerTurn: true }, @@ -972,13 +1069,16 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration", - content: [{ - type: "text", - text: - `🏁 **Batch complete!** Integration executor unavailable.\n\n` + - planText + `\n\n` + - `Run \`/orch-integrate${modeFlag}\` to integrate manually.`, - }], + content: [ + { + type: "text", + text: + `🏁 **Batch complete!** Integration executor unavailable.\n\n` + + planText + + `\n\n` + + `Run \`/orch-integrate${modeFlag}\` to integrate manually.`, + }, + ], display: "Auto-integration fallback — run /orch-integrate", }, { triggerTurn: false }, @@ -1017,10 +1117,12 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration-progress", - content: [{ - type: "text", - text: `${outcomeText}\n\n⏳ Waiting for CI checks to complete...`, - }], + content: [ + { + type: "text", + text: `${outcomeText}\n\n⏳ Waiting for CI checks to complete...`, + }, + ], display: "PR created — polling CI status", }, { triggerTurn: false }, @@ -1034,10 +1136,12 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: `❌ **CI monitoring crashed:** ${msg}\nThe PR is still open — check status and merge manually.`, - }], + content: [ + { + type: "text", + text: `❌ **CI monitoring crashed:** ${msg}\nThe PR is still open — check status and merge manually.`, + }, + ], display: "CI monitoring crashed", }, { triggerTurn: false }, @@ -1049,10 +1153,12 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: `PR created. CI polling unavailable — check status and merge manually on GitHub.`, - }], + content: [ + { + type: "text", + text: `PR created. CI polling unavailable — check status and merge manually on GitHub.`, + }, + ], display: "PR created — merge manually", }, { triggerTurn: false }, @@ -1066,10 +1172,12 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: outcomeText, - }], + content: [ + { + type: "text", + text: outcomeText, + }, + ], display: `Integration complete (${plan.mode})`, }, { triggerTurn: false }, @@ -1083,12 +1191,13 @@ export function triggerSupervisorIntegration( pi.sendMessage( { customType: "supervisor-integration-result", - content: [{ - type: "text", - text: - outcomeText + `\n\n` + - `Run \`/orch-integrate\` manually to retry with a different mode.`, - }], + content: [ + { + type: "text", + text: + outcomeText + `\n\n` + `Run \`/orch-integrate\` manually to retry with a different mode.`, + }, + ], display: "Integration failed — run /orch-integrate manually", }, { triggerTurn: false }, @@ -1097,7 +1206,6 @@ export function triggerSupervisorIntegration( } } - // ── Batch Summary Generation (TP-043 Step 2) ──────────────────────── /** @@ -1236,10 +1344,7 @@ const TIER0_SUMMARY_TYPES = new Set([ * * @since TP-043 */ -export function readTier0EventsForBatch( - stateRoot: string, - batchId: string, -): Tier0EventSummary[] { +export function readTier0EventsForBatch(stateRoot: string, batchId: string): Tier0EventSummary[] { const eventsPath = join(stateRoot, ".pi", "supervisor", "events.jsonl"); if (!existsSync(eventsPath)) return []; @@ -1324,24 +1429,36 @@ function computeV2BatchCost(stateRoot: string, batchId: string): number { try { const lanesDir = join(stateRoot, ".pi", "runtime", batchId, "lanes"); if (!existsSync(lanesDir)) return 0; - const files = readdirSync(lanesDir).filter(f => f.startsWith("lane-") && f.endsWith(".json")); + const files = readdirSync(lanesDir).filter((f) => f.startsWith("lane-") && f.endsWith(".json")); let total = 0; for (const f of files) { try { const snap = JSON.parse(readFileSync(join(lanesDir, f), "utf-8")); total += snap.worker?.costUsd || 0; total += snap.reviewer?.costUsd || 0; - } catch { /* skip */ } + } catch { + /* skip */ + } } return total; - } catch { return 0; } + } catch { + return 0; + } } export function collectBatchSummaryData( batchState: OrchBatchRuntimeState, stateRoot: string, - diagnostics?: { taskExits: Record; batchCost: number } | null, - mergeResults?: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>, + diagnostics?: { + taskExits: Record; + batchCost: number; + } | null, + mergeResults?: Array<{ + waveIndex: number; + status: string; + failedLane: number | null; + failureReason: string | null; + }>, ): BatchSummaryData { // Read audit trail for incidents const auditEntries = readAuditTrail(stateRoot, { batchId: batchState.batchId }); @@ -1350,7 +1467,7 @@ export function collectBatchSummaryData( const tier0Events = readTier0EventsForBatch(stateRoot, batchState.batchId); // Extract wave results (may not exist if batch failed during planning) - const waveResults = (batchState.waveResults || []).map(wr => ({ + const waveResults = (batchState.waveResults || []).map((wr) => ({ waveIndex: wr.waveIndex, startedAt: wr.startedAt, endedAt: wr.endedAt, @@ -1370,8 +1487,11 @@ export function collectBatchSummaryData( byTaskId.set(segment.taskId, existing); } - const multiSegmentTasks: NonNullable["multiSegmentTasks"] = []; - for (const [taskId, taskSegments] of [...byTaskId.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const multiSegmentTasks: NonNullable["multiSegmentTasks"] = + []; + for (const [taskId, taskSegments] of [...byTaskId.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + )) { if (taskSegments.length <= 1) continue; const succeeded = taskSegments.filter((segment) => segment.status === "succeeded").length; const failed = taskSegments.filter((segment) => segment.status === "failed").length; @@ -1415,9 +1535,10 @@ export function collectBatchSummaryData( failedTasks: batchState.failedTasks, skippedTasks: batchState.skippedTasks, blockedTasks: batchState.blockedTasks, - batchCost: (diagnostics?.batchCost ?? 0) > 0 - ? diagnostics!.batchCost - : computeV2BatchCost(stateRoot, batchState.batchId), + batchCost: + (diagnostics?.batchCost ?? 0) > 0 + ? diagnostics!.batchCost + : computeV2BatchCost(stateRoot, batchState.batchId), wavePlan: [], // Not directly available on runtime state — use waveResults waveResults, taskExits: diagnostics?.taskExits ?? {}, @@ -1453,9 +1574,8 @@ export function formatBatchSummary(data: BatchSummaryData): string { lines.push(""); // Duration - const duration = data.endedAt && data.startedAt - ? formatDurationMs(data.endedAt - data.startedAt) - : "In progress"; + const duration = + data.endedAt && data.startedAt ? formatDurationMs(data.endedAt - data.startedAt) : "In progress"; lines.push(`**Duration:** ${duration}`); // Cost @@ -1484,11 +1604,12 @@ export function formatBatchSummary(data: BatchSummaryData): string { } else { for (const wave of data.waveResults) { const waveNum = wave.waveIndex + 1; - const taskCount = wave.succeededTaskIds.length + wave.failedTaskIds.length + wave.skippedTaskIds.length; + const taskCount = + wave.succeededTaskIds.length + wave.failedTaskIds.length + wave.skippedTaskIds.length; const waveDuration = formatDurationMs(wave.endedAt - wave.startedAt); // Check for merge result for this wave - const mergeResult = data.mergeResults.find(mr => mr.waveIndex === wave.waveIndex); + const mergeResult = data.mergeResults.find((mr) => mr.waveIndex === wave.waveIndex); let mergeInfo = ""; if (mergeResult) { if (mergeResult.status === "succeeded") { @@ -1500,11 +1621,16 @@ export function formatBatchSummary(data: BatchSummaryData): string { } } - const statusIcon = wave.overallStatus === "succeeded" ? "✅" - : wave.overallStatus === "failed" ? "❌" - : wave.overallStatus === "partial" ? "⚠️" - : wave.overallStatus === "aborted" ? "🛑" - : "❓"; + const statusIcon = + wave.overallStatus === "succeeded" + ? "✅" + : wave.overallStatus === "failed" + ? "❌" + : wave.overallStatus === "partial" + ? "⚠️" + : wave.overallStatus === "aborted" + ? "🛑" + : "❓"; lines.push(`- Wave ${waveNum} (${taskCount} tasks): ${waveDuration} ${statusIcon}${mergeInfo}`); @@ -1522,7 +1648,9 @@ export function formatBatchSummary(data: BatchSummaryData): string { if (!data.segmentOutcomes) { lines.push("Segment data not available."); } else if (data.segmentOutcomes.multiSegmentTasks.length === 0) { - lines.push(`No multi-segment task outcomes recorded (${data.segmentOutcomes.totalSegments} segment record(s) total).`); + lines.push( + `No multi-segment task outcomes recorded (${data.segmentOutcomes.totalSegments} segment record(s) total).`, + ); } else { const statusParts = [ `${data.segmentOutcomes.succeeded} succeeded`, @@ -1541,7 +1669,9 @@ export function formatBatchSummary(data: BatchSummaryData): string { if (task.pending > 0) taskParts.push(`${task.pending} pending`); if (task.skipped > 0) taskParts.push(`${task.skipped} skipped`); if (task.stalled > 0) taskParts.push(`${task.stalled} stalled`); - lines.push(` - ${task.taskId}: ${task.terminalSegments}/${task.totalSegments} terminal (${taskParts.join(", ")})`); + lines.push( + ` - ${task.taskId}: ${task.terminalSegments}/${task.totalSegments} terminal (${taskParts.join(", ")})`, + ); } } lines.push(""); @@ -1552,7 +1682,7 @@ export function formatBatchSummary(data: BatchSummaryData): string { // Extract incidents from audit trail: non-diagnostic actions const incidents = data.auditEntries.filter( - e => e.classification !== "diagnostic" && e.result !== "pending", + (e) => e.classification !== "diagnostic" && e.result !== "pending", ); const hasTier0Events = data.tier0Events.length > 0; @@ -1576,16 +1706,16 @@ export function formatBatchSummary(data: BatchSummaryData): string { } for (const [pattern, events] of byPattern) { - const attempts = events.filter(e => e.type === "tier0_recovery_attempt").length; - const successes = events.filter(e => e.type === "tier0_recovery_success").length; - const exhausted = events.filter(e => e.type === "tier0_recovery_exhausted").length; - const escalations = events.filter(e => e.type === "tier0_escalation").length; + const attempts = events.filter((e) => e.type === "tier0_recovery_attempt").length; + const successes = events.filter((e) => e.type === "tier0_recovery_success").length; + const exhausted = events.filter((e) => e.type === "tier0_recovery_exhausted").length; + const escalations = events.filter((e) => e.type === "tier0_escalation").length; - const statusIcon = exhausted > 0 || escalations > 0 ? "❌" - : successes > 0 ? "✅" - : "⏳"; + const statusIcon = exhausted > 0 || escalations > 0 ? "❌" : successes > 0 ? "✅" : "⏳"; - lines.push(`- **${pattern}** ${statusIcon} — ${attempts} attempt(s), ${successes} success(es), ${exhausted} exhausted`); + lines.push( + `- **${pattern}** ${statusIcon} — ${attempts} attempt(s), ${successes} success(es), ${exhausted} exhausted`, + ); // Show affected tasks const taskIds = new Set(); @@ -1600,21 +1730,21 @@ export function formatBatchSummary(data: BatchSummaryData): string { } // Show escalation details - for (const evt of events.filter(e => e.type === "tier0_escalation")) { + for (const evt of events.filter((e) => e.type === "tier0_escalation")) { if (evt.suggestion) { lines.push(` - Escalation: ${evt.suggestion}`); } } // Show resolution details - for (const evt of events.filter(e => e.type === "tier0_recovery_success")) { + for (const evt of events.filter((e) => e.type === "tier0_recovery_success")) { if (evt.resolution) { lines.push(` - Resolution: ${evt.resolution}`); } } // Show error details for exhausted - for (const evt of events.filter(e => e.type === "tier0_recovery_exhausted")) { + for (const evt of events.filter((e) => e.type === "tier0_recovery_exhausted")) { if (evt.error) { lines.push(` - Error: ${evt.error}`); } @@ -1633,10 +1763,14 @@ export function formatBatchSummary(data: BatchSummaryData): string { let incidentNum = 0; for (const entry of incidents) { incidentNum++; - const resultIcon = entry.result === "success" ? "✅" - : entry.result === "failure" ? "❌" - : entry.result === "skipped" ? "⏭️" - : "❓"; + const resultIcon = + entry.result === "success" + ? "✅" + : entry.result === "failure" + ? "❌" + : entry.result === "skipped" + ? "⏭️" + : "❓"; lines.push(`${incidentNum}. **${entry.action}** (${entry.classification}) ${resultIcon}`); lines.push(` ${entry.context}`); if (entry.detail && entry.detail !== entry.context) { @@ -1666,16 +1800,22 @@ export function formatBatchSummary(data: BatchSummaryData): string { const recommendations: string[] = []; // Timeout recommendations: look for merge failures in audit trail - const mergeFailures = data.mergeResults.filter(mr => mr.status === "failed"); + const mergeFailures = data.mergeResults.filter((mr) => mr.status === "failed"); if (mergeFailures.length > 0) { - recommendations.push("- Consider increasing `merge.timeoutMinutes` — merge failures were detected during this batch."); + recommendations.push( + "- Consider increasing `merge.timeoutMinutes` — merge failures were detected during this batch.", + ); } // Failure rate recommendations if (data.totalTasks > 0 && data.failedTasks > 0) { const failureRate = data.failedTasks / data.totalTasks; if (failureRate > 0.3) { - recommendations.push("- High failure rate (" + Math.round(failureRate * 100) + "%) — consider reducing task scope or adding more context to PROMPT.md files."); + recommendations.push( + "- High failure rate (" + + Math.round(failureRate * 100) + + "%) — consider reducing task scope or adding more context to PROMPT.md files.", + ); } } @@ -1683,18 +1823,28 @@ export function formatBatchSummary(data: BatchSummaryData): string { const longTasks = Object.entries(data.taskExits).filter(([, exit]) => exit.durationSec > 3600); if (longTasks.length > 0) { const names = longTasks.map(([id]) => id).join(", "); - recommendations.push(`- Long-running tasks detected (${names}): ${longTasks.length} task(s) exceeded 1 hour — consider splitting into smaller tasks.`); + recommendations.push( + `- Long-running tasks detected (${names}): ${longTasks.length} task(s) exceeded 1 hour — consider splitting into smaller tasks.`, + ); } // Recovery recommendations — check both audit trail and Tier 0 events - const recoveryExhaustedAudit = data.auditEntries.filter(e => e.action === "tier0_recovery_exhausted" || (e.classification === "tier0_known" && e.result === "failure")); - const recoveryExhaustedTier0 = data.tier0Events.filter(e => e.type === "tier0_recovery_exhausted"); - const escalationsTier0 = data.tier0Events.filter(e => e.type === "tier0_escalation"); + const recoveryExhaustedAudit = data.auditEntries.filter( + (e) => + e.action === "tier0_recovery_exhausted" || + (e.classification === "tier0_known" && e.result === "failure"), + ); + const recoveryExhaustedTier0 = data.tier0Events.filter( + (e) => e.type === "tier0_recovery_exhausted", + ); + const escalationsTier0 = data.tier0Events.filter((e) => e.type === "tier0_escalation"); if (recoveryExhaustedAudit.length > 0 || recoveryExhaustedTier0.length > 0) { - recommendations.push("- Recovery budget was exhausted for some issues — review recurring failures and consider addressing root causes."); + recommendations.push( + "- Recovery budget was exhausted for some issues — review recurring failures and consider addressing root causes.", + ); } if (escalationsTier0.length > 0) { - const uniqueSuggestions = [...new Set(escalationsTier0.map(e => e.suggestion).filter(Boolean))]; + const uniqueSuggestions = [...new Set(escalationsTier0.map((e) => e.suggestion).filter(Boolean))]; if (uniqueSuggestions.length > 0) { for (const suggestion of uniqueSuggestions) { recommendations.push(`- Tier 0 escalation: ${suggestion}`); @@ -1704,7 +1854,9 @@ export function formatBatchSummary(data: BatchSummaryData): string { // Blocked tasks recommendations if (data.blockedTasks > 0) { - recommendations.push(`- ${data.blockedTasks} task(s) were blocked due to upstream failures — fix failed tasks and re-run with \`/orch-resume\`.`); + recommendations.push( + `- ${data.blockedTasks} task(s) were blocked due to upstream failures — fix failed tasks and re-run with \`/orch-resume\`.`, + ); } if (recommendations.length === 0) { @@ -1744,10 +1896,14 @@ export function formatBatchSummary(data: BatchSummaryData): string { totalCost += waveCost; const waveDurationStr = formatDurationMs(waveDurationSec * 1000); - lines.push(`| ${waveNum} | ${allTaskIds.length} | $${waveCost.toFixed(2)} | ${waveDurationStr} |`); + lines.push( + `| ${waveNum} | ${allTaskIds.length} | $${waveCost.toFixed(2)} | ${waveDurationStr} |`, + ); } - lines.push(`| **Total** | **${data.totalTasks}** | **$${totalCost.toFixed(2)}** | **${duration}** |`); + lines.push( + `| **Total** | **${data.totalTasks}** | **$${totalCost.toFixed(2)}** | **${duration}** |`, + ); } lines.push(""); @@ -1780,8 +1936,16 @@ export function generateBatchSummary( batchState: OrchBatchRuntimeState, stateRoot: string, opId: string, - diagnostics?: { taskExits: Record; batchCost: number } | null, - mergeResults?: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>, + diagnostics?: { + taskExits: Record; + batchCost: number; + } | null, + mergeResults?: Array<{ + waveIndex: number; + status: string; + failedLane: number | null; + failureReason: string | null; + }>, ): string { const data = collectBatchSummaryData(batchState, stateRoot, diagnostics, mergeResults); const markdown = formatBatchSummary(data); @@ -1822,19 +1986,29 @@ export function presentBatchSummary( batchState: OrchBatchRuntimeState, stateRoot: string, opId: string, - diagnostics?: { taskExits: Record; batchCost: number } | null, - mergeResults?: Array<{ waveIndex: number; status: string; failedLane: number | null; failureReason: string | null }>, + diagnostics?: { + taskExits: Record; + batchCost: number; + } | null, + mergeResults?: Array<{ + waveIndex: number; + status: string; + failedLane: number | null; + failureReason: string | null; + }>, ): void { const summary = generateBatchSummary(batchState, stateRoot, opId, diagnostics, mergeResults); // Build a concise conversation message (full details in the file) - const duration = batchState.endedAt && batchState.startedAt - ? formatDurationMs(batchState.endedAt - batchState.startedAt) - : "in progress"; + const duration = + batchState.endedAt && batchState.startedAt + ? formatDurationMs(batchState.endedAt - batchState.startedAt) + : "in progress"; // TP-115: Use V2 lane snapshot cost when diagnostics.batchCost is zero - const rawCost = (diagnostics?.batchCost ?? 0) > 0 - ? diagnostics!.batchCost - : computeV2BatchCost(stateRoot, batchState.batchId); + const rawCost = + (diagnostics?.batchCost ?? 0) > 0 + ? diagnostics!.batchCost + : computeV2BatchCost(stateRoot, batchState.batchId); const cost = rawCost > 0 ? `$${rawCost.toFixed(2)}` : "not tracked"; const filename = `${opId}-${batchState.batchId}-summary.md`; @@ -1856,7 +2030,6 @@ export function presentBatchSummary( ); } - // ── Supervisor Config Types ────────────────────────────────────────── /** @@ -1906,7 +2079,6 @@ function resolvePrimerPath(): string { } } - // ── Template Loading (TP-058) ──────────────────────────────────────── /** @@ -1937,7 +2109,9 @@ function resolveBaseTemplatePath(name: string): string { * * @since TP-058 */ -function parseSupervisorTemplate(filePath: string): { fm: Record; body: string } | null { +function parseSupervisorTemplate( + filePath: string, +): { fm: Record; body: string } | null { if (!existsSync(filePath)) return null; const raw = readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n"); const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); @@ -1947,7 +2121,8 @@ function parseSupervisorTemplate(filePath: string): { fm: Record const idx = line.indexOf(":"); if (idx > 0) { const key = line.slice(0, idx).trim(); - if (!key.startsWith("#")) { // Skip commented-out frontmatter + if (!key.startsWith("#")) { + // Skip commented-out frontmatter fm[key] = line.slice(idx + 1).trim(); } } @@ -1970,7 +2145,11 @@ function parseSupervisorTemplate(filePath: string): { fm: Record * * @since TP-058 */ -export function loadSupervisorTemplate(name: string, stateRoot: string, localName?: string): string | null { +export function loadSupervisorTemplate( + name: string, + stateRoot: string, + localName?: string, +): string | null { const basePath = resolveBaseTemplatePath(name); const baseDef = parseSupervisorTemplate(basePath); @@ -2013,7 +2192,6 @@ function replaceTemplateVars(template: string, vars: Record): st }); } - /** * Build the guardrails section dynamically based on integration mode (TP-043). * Extracted as a helper so both the template path and inline fallback can reuse it. @@ -2021,9 +2199,10 @@ function replaceTemplateVars(template: string, vars: Record): st */ function buildGuardrailsSection(integrationMode: string): string { if (integrationMode === "supervised" || integrationMode === "auto") { - const modeNote = integrationMode === "supervised" - ? `**Supervised mode:** Before executing integration, describe your plan and ask the operator for confirmation.` - : `**Auto mode:** Execute integration directly. Report the outcome to the operator. Pause only on errors or conflicts.`; + const modeNote = + integrationMode === "supervised" + ? `**Supervised mode:** Before executing integration, describe your plan and ask the operator for confirmation.` + : `**Auto mode:** Execute integration directly. Report the outcome to the operator. Pause only on errors or conflicts.`; return `## What You Must NEVER Do 1. Never delete \`.pi/batch-state.json\` without operator approval @@ -2105,9 +2284,10 @@ export function buildSupervisorSystemPrompt( const autonomyLabel = supervisorConfig.autonomy; // Build wave plan summary - const waveSummary = batchState.totalWaves > 0 - ? `${batchState.currentWaveIndex + 1}/${batchState.totalWaves} waves` - : "planning"; + const waveSummary = + batchState.totalWaves > 0 + ? `${batchState.currentWaveIndex + 1}/${batchState.totalWaves} waves` + : "planning"; const actionsPath = auditTrailPath(stateRoot); const integrationMode = config.orchestrator.integration; @@ -2311,7 +2491,6 @@ Now that you've activated: return prompt; } - // ── Routing System Prompt (TP-042) ─────────────────────────────────── /** @@ -2615,7 +2794,6 @@ outcomes, and can handle failures. return prompt; } - // ── Activation ─────────────────────────────────────────────────────── /** @@ -2868,7 +3046,11 @@ export async function activateSupervisor( // Idempotent — safe even if called from takeover paths that may have // started a tailer previously (stopEventTailer is called in deactivate). startEventTailer(pi, state.eventTailer, state, (key, text) => { - try { ctx.ui.setStatus(key, text); } catch { /* non-fatal */ } + try { + ctx.ui.setStatus(key, text); + } catch { + /* non-fatal */ + } }); // Send activation message to trigger the supervisor's first turn. @@ -2941,7 +3123,14 @@ export async function deactivateSupervisor( // confirmation), present it now — before we clear state refs. if (state.pendingSummaryDeps && state.batchStateRef && state.stateRoot) { const deps = state.pendingSummaryDeps; - presentBatchSummary(pi, state.batchStateRef, state.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults); + presentBatchSummary( + pi, + state.batchStateRef, + state.stateRoot, + deps.opId, + deps.diagnostics, + deps.mergeResults, + ); state.pendingSummaryDeps = null; } @@ -3012,7 +3201,14 @@ export async function transitionToRoutingMode( // Present deferred batch summary if any if (state.pendingSummaryDeps && state.batchStateRef && state.stateRoot) { const deps = state.pendingSummaryDeps; - presentBatchSummary(pi, state.batchStateRef, state.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults); + presentBatchSummary( + pi, + state.batchStateRef, + state.stateRoot, + deps.opId, + deps.diagnostics, + deps.mergeResults, + ); state.pendingSummaryDeps = null; } @@ -3028,14 +3224,16 @@ export async function transitionToRoutingMode( pi.sendMessage( { customType: "supervisor-routing-transition", - content: [{ - type: "text", - text: - `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + - `🔀 **Ready for your input.**\n\n` + - routingContext.contextMessage + - `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, - }], + content: [ + { + type: "text", + text: + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + + `🔀 **Ready for your input.**\n\n` + + routingContext.contextMessage + + `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, + }, + ], display: `Supervisor — ${routingContext.routingState}`, }, { triggerTurn: true }, @@ -3058,10 +3256,7 @@ export async function transitionToRoutingMode( * * @since TP-041 */ -export function registerSupervisorPromptHook( - pi: ExtensionAPI, - state: SupervisorState, -): void { +export function registerSupervisorPromptHook(pi: ExtensionAPI, state: SupervisorState): void { pi.on("before_agent_start", (_event) => { if (!state.active) { return undefined; // No-op: don't modify system prompt @@ -3072,10 +3267,7 @@ export function registerSupervisorPromptHook( // batch planning, etc.), not batch monitoring. Use the routing prompt // which includes script guidance from the primer. if (state.routingContext) { - const systemPrompt = buildRoutingSystemPrompt( - state.routingContext, - state.stateRoot, - ); + const systemPrompt = buildRoutingSystemPrompt(state.routingContext, state.stateRoot); return { systemPrompt }; } @@ -3127,7 +3319,6 @@ export function resolveSupervisorConfig( }; } - // ── Lockfile Types + Helpers (TP-041 Step 2) ───────────────────────── /** Heartbeat interval in milliseconds (30 seconds). */ @@ -3279,7 +3470,10 @@ export async function readLockfileAsync(stateRoot: string): Promise { +export async function writeLockfileAsync( + stateRoot: string, + lock: SupervisorLockfile, +): Promise { const dir = join(stateRoot, ".pi", "supervisor"); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -3357,9 +3551,7 @@ export function isLockStale(lock: SupervisorLockfile): boolean { * If batch-state.json has one of these phases, there's no active batch * and no lockfile arbitration is needed. */ -const TERMINAL_PHASES = new Set([ - "idle", "completed", "failed", "stopped", -]); +const TERMINAL_PHASES = new Set(["idle", "completed", "failed", "stopped"]); /** * Check whether a batch phase is terminal (no active batch). @@ -3443,16 +3635,15 @@ export function checkSupervisorLockOnStartup( * * @since TP-041 */ -export function buildTakeoverSummary( - stateRoot: string, - batchState: PersistedBatchState, -): string { +export function buildTakeoverSummary(stateRoot: string, batchState: PersistedBatchState): string { const lines: string[] = []; lines.push(`📋 **Taking over batch ${batchState.batchId}**`); lines.push(""); lines.push(`**Phase:** ${batchState.phase}`); - lines.push(`**Wave:** ${batchState.currentWaveIndex + 1}/${batchState.wavePlan?.length ?? batchState.totalWaves ?? "?"}`); + lines.push( + `**Wave:** ${batchState.currentWaveIndex + 1}/${batchState.wavePlan?.length ?? batchState.totalWaves ?? "?"}`, + ); lines.push(`**Base branch:** ${batchState.baseBranch}`); // Task summary from persisted state @@ -3461,7 +3652,9 @@ export function buildTakeoverSummary( const failed = tasks.filter((t) => t.status === "failed").length; const running = tasks.filter((t) => t.status === "running").length; const pending = tasks.filter((t) => t.status === "pending").length; - lines.push(`**Tasks:** ${succeeded} succeeded, ${failed} failed, ${running} running, ${pending} pending`); + lines.push( + `**Tasks:** ${succeeded} succeeded, ${failed} failed, ${running} running, ${pending} pending`, + ); // Recent actions from audit trail (using readAuditTrail helper) const recentActions = readAuditTrail(stateRoot, { limit: 5 }); @@ -3543,10 +3736,12 @@ export function startHeartbeat( pi.sendMessage( { customType: "supervisor-yield", - content: [{ - type: "text", - text: "⚡ Another session has taken over supervisor duties. Yielding.", - }], + content: [ + { + type: "text", + text: "⚡ Another session has taken over supervisor duties. Yielding.", + }, + ], display: "Supervisor yielded to another session", }, { triggerTurn: false }, @@ -3583,7 +3778,6 @@ export function startHeartbeat( return timer; } - // ── Engine Event Consumption + Notifications (TP-041 Step 3) ───────── /** @@ -3825,7 +4019,11 @@ export function readNewBytes(eventsPath: string, byteOffset: number): [string, n return ["", byteOffset]; } finally { if (fd !== null) { - try { closeSync(fd); } catch { /* best-effort */ } + try { + closeSync(fd); + } catch { + /* best-effort */ + } } } @@ -3843,7 +4041,10 @@ export function readNewBytes(eventsPath: string, byteOffset: number): [string, n * * @since TP-070 */ -export async function readNewBytesAsync(eventsPath: string, byteOffset: number): Promise<[string, number]> { +export async function readNewBytesAsync( + eventsPath: string, + byteOffset: number, +): Promise<[string, number]> { try { const stats = await fsStat(eventsPath); const fileSize = stats.size; @@ -3879,10 +4080,7 @@ export async function readNewBytesAsync(eventsPath: string, byteOffset: number): * * @since TP-041 */ -export function parseJsonlLines( - data: string, - partialLine: string, -): [ParsedEvent[], string] { +export function parseJsonlLines(data: string, partialLine: string): [ParsedEvent[], string] { const combined = partialLine + data; const lines = combined.split("\n"); @@ -3939,9 +4137,7 @@ export function formatEventNotification( return `🔀 Wave ${waveNum} merge starting...`; } case "merge_success": { - const waveProg = event.totalWaves - ? ` (${waveNum}/${event.totalWaves})` - : ""; + const waveProg = event.totalWaves ? ` (${waveNum}/${event.totalWaves})` : ""; const testInfo = event.testCount ? ` Tests pass (${event.testCount}).` : " Tests pass."; return `✅ **Wave ${waveNum} merged successfully**${waveProg}.${testInfo}`; } @@ -3951,8 +4147,10 @@ export function formatEventNotification( if (autonomy === "autonomous") { return `⚠️ Wave ${waveNum} merge failed${laneInfo}: ${reason}. Attempting recovery...`; } - return `⚠️ **Wave ${waveNum} merge failed**${laneInfo}: ${reason}.\n` + - ` Recovery may be needed. Check the merge logs for details.`; + return ( + `⚠️ **Wave ${waveNum} merge failed**${laneInfo}: ${reason}.\n` + + ` Recovery may be needed. Check the merge logs for details.` + ); } case "merge_health_warning": { const lane = event.laneNumber !== undefined ? event.laneNumber : "?"; @@ -3971,20 +4169,23 @@ export function formatEventNotification( case "batch_complete": { const parts: string[] = []; if (event.succeededTasks !== undefined) parts.push(`${event.succeededTasks} succeeded`); - if (event.failedTasks !== undefined && event.failedTasks > 0) parts.push(`${event.failedTasks} failed`); - if (event.skippedTasks !== undefined && event.skippedTasks > 0) parts.push(`${event.skippedTasks} skipped`); - if (event.blockedTasks !== undefined && event.blockedTasks > 0) parts.push(`${event.blockedTasks} blocked`); + if (event.failedTasks !== undefined && event.failedTasks > 0) + parts.push(`${event.failedTasks} failed`); + if (event.skippedTasks !== undefined && event.skippedTasks > 0) + parts.push(`${event.skippedTasks} skipped`); + if (event.blockedTasks !== undefined && event.blockedTasks > 0) + parts.push(`${event.blockedTasks} blocked`); const summary = parts.length > 0 ? parts.join(", ") : "all tasks processed"; - const duration = event.batchDurationMs - ? ` in ${formatDuration(event.batchDurationMs)}` - : ""; + const duration = event.batchDurationMs ? ` in ${formatDuration(event.batchDurationMs)}` : ""; return `🏁 **Batch complete!** ${summary}${duration}.`; } case "batch_paused": { const reason = event.reason || "unknown reason"; if (autonomy === "interactive") { - return `⏸️ **Batch paused:** ${reason}\n` + - ` What would you like to do? Options: fix the issue, skip the task, or abort.`; + return ( + `⏸️ **Batch paused:** ${reason}\n` + + ` What would you like to do? Options: fix the issue, skip the task, or abort.` + ); } return `⏸️ **Batch paused:** ${reason}`; } @@ -3995,12 +4196,16 @@ export function formatEventNotification( return `⚡ **Tier 0 escalation** (${pattern}): Investigating automatically. ${suggestion}`; } if (autonomy === "interactive") { - return `❌ **Tier 0 escalation** (${pattern}): ${suggestion}\n` + - ` Need your input on how to proceed.`; + return ( + `❌ **Tier 0 escalation** (${pattern}): ${suggestion}\n` + + ` Need your input on how to proceed.` + ); } // supervised - return `⚡ **Tier 0 escalation** (${pattern}): ${suggestion}\n` + - ` Diagnosing — will ask if novel recovery is needed.`; + return ( + `⚡ **Tier 0 escalation** (${pattern}): ${suggestion}\n` + + ` Diagnosing — will ask if novel recovery is needed.` + ); } default: return `📌 Event: ${event.type} (wave ${waveNum})`; @@ -4039,9 +4244,7 @@ export function formatTaskDigest( } if (buf.recoveryAttempts > 0 && autonomy !== "autonomous") { - const successRate = buf.recoverySuccesses > 0 - ? ` (${buf.recoverySuccesses} succeeded)` - : ""; + const successRate = buf.recoverySuccesses > 0 ? ` (${buf.recoverySuccesses} succeeded)` : ""; parts.push(`🔄 ${buf.recoveryAttempts} recovery attempt(s)${successRate}`); } @@ -4305,7 +4508,11 @@ export function startEventTailer( if (tailer.pollTimer && typeof tailer.pollTimer === "object" && "unref" in tailer.pollTimer) { tailer.pollTimer.unref(); } - if (tailer.digestTimer && typeof tailer.digestTimer === "object" && "unref" in tailer.digestTimer) { + if ( + tailer.digestTimer && + typeof tailer.digestTimer === "object" && + "unref" in tailer.digestTimer + ) { tailer.digestTimer.unref(); } } diff --git a/extensions/taskplane/task-executor-core.ts b/extensions/taskplane/task-executor-core.ts index 8842beeb..572a1a27 100644 --- a/extensions/taskplane/task-executor-core.ts +++ b/extensions/taskplane/task-executor-core.ts @@ -80,10 +80,16 @@ export function parsePromptMd(content: string, promptPath: string): CoreParsedTa const taskFolder = dirname(resolve(promptPath)); // Task ID and name - let taskId = "", taskName = ""; + let taskId = "", + taskName = ""; const titleMatch = text.match(/^#\s+(?:Task:\s*)?(\S+-\d+)\s*[-–:]\s*(.+)/m); - if (titleMatch) { taskId = titleMatch[1]; taskName = titleMatch[2].trim(); } - else { taskId = basename(taskFolder); taskName = taskId; } + if (titleMatch) { + taskId = titleMatch[1]; + taskName = titleMatch[2].trim(); + } else { + taskId = basename(taskFolder); + taskName = taskId; + } // Review level let reviewLevel = 0; @@ -104,7 +110,10 @@ export function parsePromptMd(content: string, promptPath: string): CoreParsedTa positions.push({ number: parseInt(m[1]), name: m[2].trim(), start: m.index }); } for (let i = 0; i < positions.length; i++) { - const section = text.slice(positions[i].start, i + 1 < positions.length ? positions[i + 1].start : text.length); + const section = text.slice( + positions[i].start, + i + 1 < positions.length ? positions[i + 1].start : text.length, + ); const checkboxes: { text: string; checked: boolean }[] = []; const cbRegex = /^\s*-\s*\[([ xX])\]\s*(.*)/gm; let cb: RegExpExecArray | null; @@ -112,9 +121,11 @@ export function parsePromptMd(content: string, promptPath: string): CoreParsedTa checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" }); } steps.push({ - number: positions[i].number, name: positions[i].name, - status: "not-started", checkboxes, - totalChecked: checkboxes.filter(c => c.checked).length, + number: positions[i].number, + name: positions[i].name, + status: "not-started", + checkboxes, + totalChecked: checkboxes.filter((c) => c.checked).length, totalItems: checkboxes.length, }); } @@ -145,7 +156,8 @@ export function parseStatusMd(content: string): ParsedStatus { const text = content.replace(/\r\n/g, "\n"); const steps: StepInfo[] = []; let currentStep: StepInfo | null = null; - let reviewCounter = 0, iteration = 0; + let reviewCounter = 0, + iteration = 0; for (const line of text.split("\n")) { const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/); @@ -156,11 +168,18 @@ export function parseStatusMd(content: string): ParsedStatus { const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/); if (stepMatch) { if (currentStep) { - currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length; + currentStep.totalChecked = currentStep.checkboxes.filter((c) => c.checked).length; currentStep.totalItems = currentStep.checkboxes.length; steps.push(currentStep); } - currentStep = { number: parseInt(stepMatch[1]), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], totalChecked: 0, totalItems: 0 }; + currentStep = { + number: parseInt(stepMatch[1]), + name: stepMatch[2].trim(), + status: "not-started", + checkboxes: [], + totalChecked: 0, + totalItems: 0, + }; continue; } if (currentStep) { @@ -168,14 +187,16 @@ export function parseStatusMd(content: string): ParsedStatus { if (ss) { const s = ss[1]; if (s.includes("✅") || s.toLowerCase().includes("complete")) currentStep.status = "complete"; - else if (s.includes("🟨") || s.toLowerCase().includes("progress")) currentStep.status = "in-progress"; + else if (s.includes("🟨") || s.toLowerCase().includes("progress")) + currentStep.status = "in-progress"; } const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/); - if (cb) currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" }); + if (cb) + currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" }); } } if (currentStep) { - currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length; + currentStep.totalChecked = currentStep.checkboxes.filter((c) => c.checked).length; currentStep.totalItems = currentStep.checkboxes.length; steps.push(currentStep); } @@ -190,17 +211,27 @@ export function parseStatusMd(content: string): ParsedStatus { * @param task - Parsed task (from parsePromptMd or orchestrator ParsedTask) * @returns Complete STATUS.md content string */ -export function generateStatusMd(task: { taskId: string; taskName: string; reviewLevel: number; size: string; steps: StepInfo[] }): string { +export function generateStatusMd(task: { + taskId: string; + taskName: string; + reviewLevel: number; + size: string; + steps: StepInfo[]; +}): string { const now = new Date().toISOString().slice(0, 10); const lines: string[] = [ - `# ${task.taskId}: ${task.taskName} — Status`, "", + `# ${task.taskId}: ${task.taskName} — Status`, + "", `**Current Step:** Not Started`, `**Status:** 🔵 Ready for Execution`, `**Last Updated:** ${now}`, `**Review Level:** ${task.reviewLevel}`, `**Review Counter:** 0`, `**Iteration:** 0`, - `**Size:** ${task.size}`, "", "---", "", + `**Size:** ${task.size}`, + "", + "---", + "", ]; for (const step of task.steps) { lines.push(`### Step ${step.number}: ${step.name}`, `**Status:** ⬜ Not Started`, ""); @@ -208,11 +239,37 @@ export function generateStatusMd(task: { taskId: string; taskName: string; revie lines.push("", "---", ""); } lines.push( - "## Reviews", "", "| # | Type | Step | Verdict | File |", "|---|------|------|---------|------|", "", "---", "", - "## Discoveries", "", "| Discovery | Disposition | Location |", "|-----------|-------------|----------|", "", "---", "", - "## Execution Log", "", "| Timestamp | Action | Outcome |", "|-----------|--------|---------|", - `| ${now} | Task staged | STATUS.md auto-generated by task-runner |`, "", "---", "", - "## Blockers", "", "*None*", "", "---", "", "## Notes", "", "*Reserved for execution notes*", + "## Reviews", + "", + "| # | Type | Step | Verdict | File |", + "|---|------|------|---------|------|", + "", + "---", + "", + "## Discoveries", + "", + "| Discovery | Disposition | Location |", + "|-----------|-------------|----------|", + "", + "---", + "", + "## Execution Log", + "", + "| Timestamp | Action | Outcome |", + "|-----------|--------|---------|", + `| ${now} | Task staged | STATUS.md auto-generated by task-runner |`, + "", + "---", + "", + "## Blockers", + "", + "*None*", + "", + "---", + "", + "## Notes", + "", + "*Reserved for execution notes*", ); return lines.join("\n"); } @@ -230,7 +287,9 @@ export function generateStatusMd(task: { taskId: string; taskName: string; revie */ export function updateStatusField(statusPath: string, field: string, value: string): void { let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n"); - const pattern = new RegExp(`(\\*\\*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*\\s*)(.+)`); + const pattern = new RegExp( + `(\\*\\*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*\\s*)(.+)`, + ); if (pattern.test(content)) { content = content.replace(pattern, `$1${value}`); } else { @@ -246,9 +305,18 @@ export function updateStatusField(statusPath: string, field: string, value: stri * @param stepNum - Step number to update * @param status - New status */ -export function updateStepStatus(statusPath: string, stepNum: number, status: "not-started" | "in-progress" | "complete"): void { +export function updateStepStatus( + statusPath: string, + stepNum: number, + status: "not-started" | "in-progress" | "complete", +): void { let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n"); - const emoji = status === "complete" ? "✅ Complete" : status === "in-progress" ? "🟨 In Progress" : "⬜ Not Started"; + const emoji = + status === "complete" + ? "✅ Complete" + : status === "in-progress" + ? "🟨 In Progress" + : "⬜ Not Started"; const lines = content.split("\n"); let inTarget = false; for (let i = 0; i < lines.length; i++) { @@ -272,7 +340,9 @@ export function updateStepStatus(statusPath: string, stepNum: number, status: "n export function appendTableRow(statusPath: string, sectionName: string, row: string): void { let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n"); const lines = content.split("\n"); - let insertIdx = -1, inSection = false, lastTableRow = -1; + let insertIdx = -1, + inSection = false, + lastTableRow = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].match(new RegExp(`^##\\s+${sectionName}`))) { inSection = true; @@ -306,8 +376,19 @@ export function logExecution(statusPath: string, action: string, outcome: string /** * Log a review entry to the Reviews table in STATUS.md. */ -export function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void { - appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`); +export function logReview( + statusPath: string, + num: string, + type: string, + stepNum: number, + verdict: string, + file: string, +): void { + appendTableRow( + statusPath, + "Reviews", + `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`, + ); } /** @@ -370,8 +451,18 @@ export function extractVerdict(reviewContent: string): string { // Tolerate non-standard verdict formats const lower = reviewContent.toLowerCase(); - if (lower.includes("changes requested") || lower.includes("request changes") || lower.includes("needs revision")) return "REVISE"; - if (lower.includes("approve") && !lower.includes("do not approve") && !lower.includes("cannot approve")) return "APPROVE"; + if ( + lower.includes("changes requested") || + lower.includes("request changes") || + lower.includes("needs revision") + ) + return "REVISE"; + if ( + lower.includes("approve") && + !lower.includes("do not approve") && + !lower.includes("cannot approve") + ) + return "APPROVE"; if (lower.includes("rethink") || lower.includes("re-think")) return "RETHINK"; return "UNKNOWN"; @@ -409,7 +500,17 @@ export function getHeadCommitSha(): string { */ export function findStepBoundaryCommit(stepNumber: number, taskId: string, since?: string): string { try { - const args = ["log", "--oneline", "--grep", `complete Step ${stepNumber}`, "--grep", taskId, "--all-match", "-1", "--format=%H"]; + const args = [ + "log", + "--oneline", + "--grep", + `complete Step ${stepNumber}`, + "--grep", + taskId, + "--all-match", + "-1", + "--format=%H", + ]; if (since) args.push(`${since}..HEAD`); const result = spawnSync("git", args, { encoding: "utf-8", @@ -443,7 +544,7 @@ export interface StandardsConfig { export function resolveStandards( globalStandards: StandardsConfig, overrides: Record>, - taskAreas: Record, + taskAreas: Record, taskFolder: string, ): StandardsConfig { const normalizedFolder = taskFolder.replace(/\\/g, "/"); @@ -488,44 +589,60 @@ export function generateReviewRequest( outputPath: string, stepBaselineCommit?: string, ): string { - const standardsDocs = standards.docs.map(d => ` - ${d}`).join("\n"); - const standardsRules = standards.rules.map(r => `- ${r}`).join("\n"); + const standardsDocs = standards.docs.map((d) => ` - ${d}`).join("\n"); + const standardsRules = standards.rules.map((r) => `- ${r}`).join("\n"); const statusPath = join(taskFolder, "STATUS.md"); if (type === "plan") { return [ - `# Review Request: Plan Review`, "", + `# Review Request: Plan Review`, + "", `You are reviewing an implementation plan for a ${projectName} task.`, - `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "", - `## Task Context`, "", + `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, + "", + `## Task Context`, + "", `- **Task PROMPT:** ${taskPromptPath}`, `- **Task STATUS:** ${statusPath}`, - `- **Step being planned:** Step ${stepNum}: ${stepName}`, "", - `## Instructions`, "", + `- **Step being planned:** Step ${stepNum}: ${stepName}`, + "", + `## Instructions`, + "", `1. Read the PROMPT.md for full requirements`, `2. Read STATUS.md for progress so far`, `3. Check relevant source files for existing patterns:`, - standardsDocs, "", - `## Project Standards`, "", standardsRules, "", - `## Output`, "", + standardsDocs, + "", + `## Project Standards`, + "", + standardsRules, + "", + `## Output`, + "", `Write your review to: \`${outputPath}\``, ].join("\n"); } - const diffCmd = stepBaselineCommit ? `git diff ${stepBaselineCommit}..HEAD --name-only` : `git diff --name-only`; + const diffCmd = stepBaselineCommit + ? `git diff ${stepBaselineCommit}..HEAD --name-only` + : `git diff --name-only`; const diffFullCmd = stepBaselineCommit ? `git diff ${stepBaselineCommit}..HEAD` : `git diff`; return [ - `# Review Request: Code Review`, "", + `# Review Request: Code Review`, + "", `You are reviewing code changes for a ${projectName} task.`, - `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, "", - `## Task Context`, "", + `You have full tool access — use \`read\` to examine files and \`bash\` to run commands.`, + "", + `## Task Context`, + "", `- **Task PROMPT:** ${taskPromptPath}`, `- **Task STATUS:** ${statusPath}`, `- **Step reviewed:** Step ${stepNum}: ${stepName}`, ...(stepBaselineCommit ? [`- **Step baseline commit:** ${stepBaselineCommit}`] : []), "", - `## Instructions`, "", + `## Instructions`, + "", `1. Run \`${diffCmd}\` to see files changed in this step`, ` Then \`${diffFullCmd}\` for the full diff`, ` **Important:** The worker commits code via checkpoints, so plain \`git diff\` may show nothing.`, @@ -533,9 +650,14 @@ export function generateReviewRequest( `2. Read changed files in full for context`, `3. Check neighboring files for pattern consistency`, `4. Check standards:`, - standardsDocs, "", - `## Project Standards`, "", standardsRules, "", - `## Output`, "", + standardsDocs, + "", + `## Project Standards`, + "", + standardsRules, + "", + `## Output`, + "", `Write your review to: \`${outputPath}\``, ].join("\n"); } @@ -546,5 +668,8 @@ export function generateReviewRequest( * Convert a kebab-case name to Title Case for display. */ export function displayName(name: string): string { - return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + return name + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); } diff --git a/extensions/taskplane/types.ts b/extensions/taskplane/types.ts index 0981fc92..3d239006 100644 --- a/extensions/taskplane/types.ts +++ b/extensions/taskplane/types.ts @@ -158,7 +158,11 @@ export function parseSegmentIdRepo(segment: { repoId: string }): string { /** Build a dynamic segment expansion request ID (`exp-{timestamp}-{random5}`). */ export function buildExpansionRequestId(timestamp = Date.now()): string { const ts = Number.isFinite(timestamp) ? Math.floor(timestamp) : Date.now(); - const base = Math.random().toString(36).slice(2).toLowerCase().replace(/[^a-z0-9]/g, ""); + const base = Math.random() + .toString(36) + .slice(2) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); const random5 = (base + "00000").slice(0, 5); return `exp-${ts}-${random5}`; } @@ -365,7 +369,6 @@ export interface PreflightCheck { hint?: string; } - // ── Defaults ───────────────────────────────────────────────────────── export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = { @@ -428,7 +431,6 @@ export const DEFAULT_TASK_RUNNER_CONFIG: TaskRunnerConfig = { model_fallback: "inherit", }; - // ── Helpers ────────────────────────────────────────────────────────── export function freshBatchState(): BatchState { @@ -598,7 +600,12 @@ export interface RemoveAllWorktreesResult { /** All per-worktree outcomes in order */ outcomes: RemoveWorktreeOutcome[]; /** Branches preserved (had unmerged commits) */ - preserved: Array<{ branch: string; savedBranch: string; laneNumber: number; unmergedCount?: number }>; + preserved: Array<{ + branch: string; + savedBranch: string; + laneNumber: number; + unmergedCount?: number; + }>; } // ── Discovery Types ────────────────────────────────────────────────── @@ -656,7 +663,6 @@ export interface DiscoveryResult { errors: DiscoveryError[]; } - // ── Wave Computation Types ─────────────────────────────────────────── /** Dependency graph: adjacency list (task → tasks it depends on) */ @@ -683,7 +689,6 @@ export interface WaveComputationResult { segmentPlans?: TaskSegmentPlanMap; } - // ── Lane Allocation (Phase 3) ──────────────────────────────────────── /** @@ -760,7 +765,6 @@ export interface AllocatedLane { repoId?: string; } - // ── Execution Types & Contracts ────────────────────────────────────── /** @@ -938,7 +942,6 @@ export class ExecutionError extends Error { } } - // ── Monitoring Types & Contracts ───────────────────────────────────── /** @@ -1050,7 +1053,6 @@ export interface MtimeTracker { stallTimerStart: number | null; } - // ── Wave Execution Types & Contracts ───────────────────────────────── /** @@ -1122,7 +1124,6 @@ export interface WaveExecutionResult { } | null; } - // ── Orchestrator Runtime State ─────────────────────────────────────── /** @@ -1135,7 +1136,16 @@ export interface WaveExecutionResult { * → paused (via /orch-pause) * Any active state → idle (via cleanup after completion/failure) */ -export type OrchBatchPhase = "idle" | "launching" | "planning" | "executing" | "merging" | "paused" | "stopped" | "completed" | "failed"; +export type OrchBatchPhase = + | "idle" + | "launching" + | "planning" + | "executing" + | "merging" + | "paused" + | "stopped" + | "completed" + | "failed"; /** * Runtime state for a batch execution. @@ -1288,14 +1298,17 @@ export function freshOrchBatchState(): OrchBatchRuntimeState { }; } - // ── Merge Types ────────────────────────────────────────────────────── /** * Valid merge result statuses. * Matches the contract in .pi/agents/task-merger.md. */ -export type MergeResultStatus = "SUCCESS" | "CONFLICT_RESOLVED" | "CONFLICT_UNRESOLVED" | "BUILD_FAILURE"; +export type MergeResultStatus = + | "SUCCESS" + | "CONFLICT_RESOLVED" + | "CONFLICT_UNRESOLVED" + | "BUILD_FAILURE"; /** All valid status strings for runtime validation. */ export const VALID_MERGE_STATUSES: ReadonlySet = new Set([ @@ -1686,7 +1699,6 @@ export interface MergeSessionHealthState { deadEmitted: boolean; } - // ── Merge Retry Policy Matrix (TP-033 Step 2) ─────────────────────── /** @@ -1743,7 +1755,9 @@ export interface MergeRetryPolicy { * * @since TP-033 */ -export const MERGE_RETRY_POLICY_MATRIX: Readonly> = { +export const MERGE_RETRY_POLICY_MATRIX: Readonly< + Record +> = { verification_new_failure: { retriable: true, maxAttempts: 1, @@ -1788,7 +1802,6 @@ export const MERGE_FAILURE_CLASSIFICATIONS: readonly MergeFailureClassification[ "git_lock_file", ] as const; - // ── Tier 0 Watchdog Recovery Types (TP-039) ────────────────────────── /** @@ -1923,7 +1936,11 @@ export interface EscalationContext { * * @since TP-039 */ -export function tier0ScopeKey(pattern: Tier0RecoveryPattern, taskId: string, waveIndex: number): string { +export function tier0ScopeKey( + pattern: Tier0RecoveryPattern, + taskId: string, + waveIndex: number, +): string { return `t0:${pattern}:${taskId}:w${waveIndex}`; } @@ -2066,7 +2083,6 @@ export interface EngineEvent { */ export type EngineEventCallback = (event: EngineEvent) => void; - // ── Supervisor Alert Types (TP-076) ────────────────────────────────── /** @@ -2278,7 +2294,10 @@ export function buildSupervisorSegmentFrontierSnapshot( preferredSegmentId?: string | null, ): SupervisorSegmentFrontierSnapshot | undefined { const orderedSegmentIds = Array.isArray(segmentIds) - ? segmentIds.filter((segmentId): segmentId is string => typeof segmentId === "string" && segmentId.trim().length > 0) + ? segmentIds.filter( + (segmentId): segmentId is string => + typeof segmentId === "string" && segmentId.trim().length > 0, + ) : []; if (orderedSegmentIds.length === 0) return undefined; @@ -2289,16 +2308,17 @@ export function buildSupervisorSegmentFrontierSnapshot( } } - const resolvedActiveSegmentId = (activeSegmentId && orderedSegmentIds.includes(activeSegmentId)) - ? activeSegmentId - : (preferredSegmentId && orderedSegmentIds.includes(preferredSegmentId) - ? preferredSegmentId - : null); + const resolvedActiveSegmentId = + activeSegmentId && orderedSegmentIds.includes(activeSegmentId) + ? activeSegmentId + : preferredSegmentId && orderedSegmentIds.includes(preferredSegmentId) + ? preferredSegmentId + : null; const segments = orderedSegmentIds.map((segmentId) => { const persisted = bySegmentId.get(segmentId); - const status: PersistedSegmentStatus = persisted?.status - ?? (resolvedActiveSegmentId === segmentId ? "running" : "pending"); + const status: PersistedSegmentStatus = + persisted?.status ?? (resolvedActiveSegmentId === segmentId ? "running" : "pending"); return { segmentId, repoId: persisted ? parseSegmentIdRepo(persisted) : "unknown", @@ -2307,11 +2327,12 @@ export function buildSupervisorSegmentFrontierSnapshot( }; }); - const terminalSegments = segments.filter((segment) => - segment.status === "succeeded" - || segment.status === "failed" - || segment.status === "stalled" - || segment.status === "skipped", + const terminalSegments = segments.filter( + (segment) => + segment.status === "succeeded" || + segment.status === "failed" || + segment.status === "stalled" || + segment.status === "skipped", ).length; return { @@ -2346,7 +2367,6 @@ export function buildEngineEventBase( }; } - /** * Decision output from the merge retry policy evaluator. * @@ -2383,50 +2403,50 @@ export interface MergeRetryDecision { */ export type MergeRetryLoopOutcome = | { - /** Retry succeeded — caller should continue normal post-merge flow */ - kind: "retry_succeeded"; - mergeResult: MergeWaveResult; - /** Classification of the failure that was retried */ - classification: MergeFailureClassification | null; - /** Scope key used for retry counter tracking */ - scopeKey: string; - /** Last retry decision (carries attempt/maxAttempts for event emission) */ - lastDecision: MergeRetryDecision; - } + /** Retry succeeded — caller should continue normal post-merge flow */ + kind: "retry_succeeded"; + mergeResult: MergeWaveResult; + /** Classification of the failure that was retried */ + classification: MergeFailureClassification | null; + /** Scope key used for retry counter tracking */ + scopeKey: string; + /** Last retry decision (carries attempt/maxAttempts for event emission) */ + lastDecision: MergeRetryDecision; + } | { - /** Safe-stop triggered during retry — caller should break the wave loop */ - kind: "safe_stop"; - mergeResult: MergeWaveResult; - /** Classification of the failure that was retried */ - classification: MergeFailureClassification | null; - /** Scope key used for retry counter tracking */ - scopeKey: string; - /** Last retry decision (carries attempt/maxAttempts for event emission) */ - lastDecision: MergeRetryDecision; - errorMessage: string; - notifyMessage: string; - } + /** Safe-stop triggered during retry — caller should break the wave loop */ + kind: "safe_stop"; + mergeResult: MergeWaveResult; + /** Classification of the failure that was retried */ + classification: MergeFailureClassification | null; + /** Scope key used for retry counter tracking */ + scopeKey: string; + /** Last retry decision (carries attempt/maxAttempts for event emission) */ + lastDecision: MergeRetryDecision; + errorMessage: string; + notifyMessage: string; + } | { - /** - * Retry exhausted or failure is non-retriable — caller should - * force `paused` regardless of on_merge_failure config. - */ - kind: "exhausted"; - mergeResult: MergeWaveResult; - classification: MergeFailureClassification | null; - scopeKey: string; - lastDecision: MergeRetryDecision; - errorMessage: string; - notifyMessage: string; - } + /** + * Retry exhausted or failure is non-retriable — caller should + * force `paused` regardless of on_merge_failure config. + */ + kind: "exhausted"; + mergeResult: MergeWaveResult; + classification: MergeFailureClassification | null; + scopeKey: string; + lastDecision: MergeRetryDecision; + errorMessage: string; + notifyMessage: string; + } | { - /** No retry attempted (unclassifiable or non-retriable with 0 attempts). - * Caller should fall through to standard on_merge_failure policy. */ - kind: "no_retry"; - mergeResult: MergeWaveResult; - classification: MergeFailureClassification | null; - scopeKey: string; - }; + /** No retry attempted (unclassifiable or non-retriable with 0 attempts). + * Caller should fall through to standard on_merge_failure policy. */ + kind: "no_retry"; + mergeResult: MergeWaveResult; + classification: MergeFailureClassification | null; + scopeKey: string; + }; /** * Callbacks provided to `applyMergeRetryLoop()` for side effects @@ -2510,7 +2530,6 @@ export interface OrchDashboardViewModel { failurePolicy: string | null; // e.g., "stop-wave" if stopped by policy } - // ── State Persistence Types (TS-009) ───────────────────────────────── // ── v3 Resilience & Diagnostics Sections (TP-030) ──────────────────── @@ -2832,7 +2851,13 @@ export interface PersistedTaskRecord { * * @since v4 (TP-081) */ -export type PersistedSegmentStatus = "pending" | "running" | "succeeded" | "failed" | "stalled" | "skipped"; +export type PersistedSegmentStatus = + | "pending" + | "running" + | "succeeded" + | "failed" + | "stalled" + | "skipped"; /** * Persisted record of a single segment's execution state. @@ -3095,7 +3120,6 @@ export interface PersistedBatchState { _extraFields?: Record; } - // ── Resume (TS-009 Step 4) ─────────────────────────────────────────── /** @@ -3313,10 +3337,7 @@ export const DURATION_BASE_MINUTES = 30; * Get estimated duration in minutes for a task size. * Uses explicit mapping, falling back to weight × base. */ -export function getTaskDurationMinutes( - size: string, - sizeWeights: Record, -): number { +export function getTaskDurationMinutes(size: string, sizeWeights: Record): number { if (SIZE_DURATION_MINUTES[size] !== undefined) { return SIZE_DURATION_MINUTES[size]; } @@ -3324,7 +3345,6 @@ export function getTaskDurationMinutes( return weight * DURATION_BASE_MINUTES; } - // ── Batch History ──────────────────────────────────────────────────── /** Token counts for a task, wave, or batch. */ @@ -3341,8 +3361,8 @@ export interface BatchTaskSummary { taskId: string; taskName: string; status: "succeeded" | "failed" | "skipped" | "blocked" | "stalled" | "pending"; - wave: number; // 1-based - lane: number; // 1-based + wave: number; // 1-based + lane: number; // 1-based durationMs: number; tokens: TokenCounts; exitReason: string | null; @@ -3350,8 +3370,8 @@ export interface BatchTaskSummary { /** Per-wave summary for history. */ export interface BatchWaveSummary { - wave: number; // 1-based - tasks: string[]; // task IDs + wave: number; // 1-based + tasks: string[]; // task IDs mergeStatus: "succeeded" | "failed" | "partial" | "skipped"; durationMs: number; tokens: TokenCounts; @@ -3380,7 +3400,6 @@ export interface BatchHistorySummary { /** Max number of batch history entries to retain. */ export const BATCH_HISTORY_MAX_ENTRIES = 100; - // ── Workspace Mode Types ───────────────────────────────────────────── /** @@ -3518,7 +3537,6 @@ export interface ExecutionContext { pointer: PointerResolution | null; } - // ── Workspace Validation Error Types ───────────────────────────────── /** @@ -3560,7 +3578,7 @@ export type WorkspaceConfigErrorCode = | "WORKSPACE_TASK_AREA_OUTSIDE_TASKS_ROOT" | "WORKSPACE_SETUP_REQUIRED" | "WORKSPACE_DUPLICATE_REPO_PATH" - | "WORKSPACE_SCHEMA_INVALID";/** + | "WORKSPACE_SCHEMA_INVALID"; /** * Typed error class for workspace configuration failures. * * Thrown during workspace config loading/validation when the config file @@ -3577,7 +3595,12 @@ export class WorkspaceConfigError extends Error { /** Optional filesystem path related to the error */ relatedPath?: string; - constructor(code: WorkspaceConfigErrorCode, message: string, repoId?: string, relatedPath?: string) { + constructor( + code: WorkspaceConfigErrorCode, + message: string, + repoId?: string, + relatedPath?: string, + ) { super(message); this.name = "WorkspaceConfigError"; this.code = code; @@ -3586,7 +3609,6 @@ export class WorkspaceConfigError extends Error { } } - // ── Pointer Resolution Types ───────────────────────────────────────── /** @@ -3653,7 +3675,6 @@ export interface PointerResolution { warning?: string; } - // ── Workspace Defaults ─────────────────────────────────────────────── /** @@ -3697,7 +3718,6 @@ export function createRepoModeContext( }; } - // ── Agent Mailbox Types (TP-089) ───────────────────────────────────── /** @@ -3735,7 +3755,12 @@ export type MailboxMessageType = "steer" | "query" | "abort" | "info" | "reply" * @since TP-089 */ export const MAILBOX_MESSAGE_TYPES: ReadonlySet = new Set([ - "steer", "query", "abort", "info", "reply", "escalate", + "steer", + "query", + "abort", + "info", + "reply", + "escalate", ]); /** @@ -3838,7 +3863,10 @@ export type RuntimeAgentStatus = /** Set of terminal agent statuses (process is no longer alive). @since TP-102 */ export const TERMINAL_AGENT_STATUSES: ReadonlySet = new Set([ - "exited", "crashed", "timed_out", "killed", + "exited", + "crashed", + "timed_out", + "killed", ]); /** @@ -4173,7 +4201,11 @@ export function runtimeRoot(stateRoot: string, batchId: string): string { * * @since TP-102 */ -export function runtimeAgentDir(stateRoot: string, batchId: string, agentId: RuntimeAgentId): string { +export function runtimeAgentDir( + stateRoot: string, + batchId: string, + agentId: RuntimeAgentId, +): string { return `${stateRoot}/.pi/runtime/${batchId}/agents/${agentId}`; } @@ -4182,7 +4214,11 @@ export function runtimeAgentDir(stateRoot: string, batchId: string, agentId: Run * * @since TP-102 */ -export function runtimeManifestPath(stateRoot: string, batchId: string, agentId: RuntimeAgentId): string { +export function runtimeManifestPath( + stateRoot: string, + batchId: string, + agentId: RuntimeAgentId, +): string { return `${runtimeAgentDir(stateRoot, batchId, agentId)}/manifest.json`; } @@ -4191,7 +4227,11 @@ export function runtimeManifestPath(stateRoot: string, batchId: string, agentId: * * @since TP-102 */ -export function runtimeAgentEventsPath(stateRoot: string, batchId: string, agentId: RuntimeAgentId): string { +export function runtimeAgentEventsPath( + stateRoot: string, + batchId: string, + agentId: RuntimeAgentId, +): string { return `${runtimeAgentDir(stateRoot, batchId, agentId)}/events.jsonl`; } @@ -4200,7 +4240,11 @@ export function runtimeAgentEventsPath(stateRoot: string, batchId: string, agent * * @since TP-102 */ -export function runtimeLaneSnapshotPath(stateRoot: string, batchId: string, laneNumber: number): string { +export function runtimeLaneSnapshotPath( + stateRoot: string, + batchId: string, + laneNumber: number, +): string { return `${stateRoot}/.pi/runtime/${batchId}/lanes/lane-${laneNumber}.json`; } @@ -4244,7 +4288,11 @@ export interface RuntimeMergeSnapshot { * * @since TP-164 */ -export function runtimeMergeSnapshotPath(stateRoot: string, batchId: string, mergeNumber: number): string { +export function runtimeMergeSnapshotPath( + stateRoot: string, + batchId: string, + mergeNumber: number, +): string { return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-${mergeNumber}.json`; } @@ -4309,15 +4357,28 @@ export function validateAgentManifest(manifest: unknown): string[] { if (typeof m.role !== "string") errors.push("role must be a string"); else { const validRoles: ReadonlySet = new Set(["worker", "reviewer", "merger", "lane-runner"]); - if (!validRoles.has(m.role as string)) errors.push(`role must be one of: ${[...validRoles].join(", ")}`); + if (!validRoles.has(m.role as string)) + errors.push(`role must be one of: ${[...validRoles].join(", ")}`); } - if (typeof m.pid !== "number" || !Number.isFinite(m.pid) || m.pid <= 0) errors.push("pid must be a positive finite number"); - if (typeof m.parentPid !== "number" || !Number.isFinite(m.parentPid) || m.parentPid <= 0) errors.push("parentPid must be a positive finite number"); - if (typeof m.startedAt !== "number" || !Number.isFinite(m.startedAt)) errors.push("startedAt must be a finite number"); + if (typeof m.pid !== "number" || !Number.isFinite(m.pid) || m.pid <= 0) + errors.push("pid must be a positive finite number"); + if (typeof m.parentPid !== "number" || !Number.isFinite(m.parentPid) || m.parentPid <= 0) + errors.push("parentPid must be a positive finite number"); + if (typeof m.startedAt !== "number" || !Number.isFinite(m.startedAt)) + errors.push("startedAt must be a finite number"); if (typeof m.status !== "string") errors.push("status must be a string"); else { - const validStatuses: ReadonlySet = new Set(["spawning", "running", "wrapping_up", "exited", "crashed", "timed_out", "killed"]); - if (!validStatuses.has(m.status as string)) errors.push(`status must be one of: ${[...validStatuses].join(", ")}`); + const validStatuses: ReadonlySet = new Set([ + "spawning", + "running", + "wrapping_up", + "exited", + "crashed", + "timed_out", + "killed", + ]); + if (!validStatuses.has(m.status as string)) + errors.push(`status must be one of: ${[...validStatuses].join(", ")}`); } if (typeof m.cwd !== "string" || !m.cwd) errors.push("cwd must be a non-empty string"); if (typeof m.repoId !== "string") errors.push("repoId must be a string"); @@ -4339,7 +4400,13 @@ export function validatePacketPaths(packet: unknown): string[] { } const p = packet as Record; - for (const field of ["promptPath", "statusPath", "donePath", "reviewsDir", "taskFolder"] as const) { + for (const field of [ + "promptPath", + "statusPath", + "donePath", + "reviewsDir", + "taskFolder", + ] as const) { if (typeof p[field] !== "string" || !(p[field] as string)) { errors.push(`${field} must be a non-empty string`); } @@ -4347,4 +4414,3 @@ export function validatePacketPaths(packet: unknown): string[] { return errors; } - diff --git a/extensions/taskplane/verification.ts b/extensions/taskplane/verification.ts index d5875f1f..5d8a06dd 100644 --- a/extensions/taskplane/verification.ts +++ b/extensions/taskplane/verification.ts @@ -112,7 +112,6 @@ export interface FingerprintDiff { fixed: TestFingerprint[]; } - // ── Normalization Helpers ──────────────────────────────────────────── /** Max length for normalized message strings */ @@ -178,7 +177,6 @@ export function fingerprintKey(fp: TestFingerprint): string { return `${fp.commandId}\0${fp.file}\0${fp.case}\0${fp.kind}\0${fp.messageNorm}`; } - // ── Command Runner ─────────────────────────────────────────────────── /** Default timeout for verification commands: 5 minutes */ @@ -261,7 +259,6 @@ export function runVerificationCommands( return results; } - // ── Test Output Parsers ────────────────────────────────────────────── /** @@ -377,7 +374,7 @@ export function parseVitestOutput(commandId: string, stdout: string): TestFinger // This covers setup/import/runtime-at-file-load errors where Vitest marks the file as // failed but produces no assertionResults (or only non-failed ones). if (testFile.status === "failed") { - const hasFailedAssertions = hasAssertions && assertions!.some(a => a.status === "failed"); + const hasFailedAssertions = hasAssertions && assertions!.some((a) => a.status === "failed"); if (!hasFailedAssertions) { // No assertion-level failures captured — emit suite-level runtime_error fingerprint const suiteMessage = testFile.message || "Suite failed with no message"; @@ -413,13 +410,15 @@ export function parseTestOutput(commandResult: CommandResult): TestFingerprint[] // If command had a spawn/timeout error, produce a command_error fingerprint if (error) { - return [{ - commandId, - file: "", - case: "", - kind: "command_error", - messageNorm: normalizeMessage(error), - }]; + return [ + { + commandId, + file: "", + case: "", + kind: "command_error", + messageNorm: normalizeMessage(error), + }, + ]; } // If exit code is 0, no failures to fingerprint @@ -439,16 +438,17 @@ export function parseTestOutput(commandResult: CommandResult): TestFingerprint[] // Fallback: command_error fingerprint with stderr (or stdout if stderr is empty) const fallbackMessage = stderr.trim() || stdout.trim() || "Command failed with no output"; - return [{ - commandId, - file: "", - case: "", - kind: "command_error", - messageNorm: normalizeMessage(fallbackMessage), - }]; + return [ + { + commandId, + file: "", + case: "", + kind: "command_error", + messageNorm: normalizeMessage(fallbackMessage), + }, + ]; } - // ── Fingerprint Diffing ────────────────────────────────────────────── /** @@ -515,7 +515,6 @@ export function diffFingerprints( return { newFailures, preExisting, fixed }; } - // ── Baseline Capture ───────────────────────────────────────────────── /** diff --git a/extensions/taskplane/waves.ts b/extensions/taskplane/waves.ts index 6bdc163f..c6c76c52 100644 --- a/extensions/taskplane/waves.ts +++ b/extensions/taskplane/waves.ts @@ -7,7 +7,23 @@ import { join } from "path"; import { parseDependencyReference } from "./discovery.ts"; import { resolveOperatorId } from "./naming.ts"; import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts"; -import type { AllocatedLane, AllocatedTask, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts"; +import type { + AllocatedLane, + AllocatedTask, + AllocationErrorCode, + DependencyGraph, + DiscoveryError, + GraphValidationResult, + LaneAssignment, + OrchestratorConfig, + ParsedTask, + TaskSegmentPlan, + TaskSegmentPlanMap, + WaveAssignment, + WaveComputationResult, + WorkspaceConfig, + WorktreeInfo, +} from "./types.ts"; import { getCurrentBranch, runGit } from "./git.ts"; import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts"; @@ -56,7 +72,6 @@ export function buildDependencyGraph( return { dependencies, dependents, nodes }; } - // ── Graph Validation ───────────────────────────────────────────────── /** @@ -182,7 +197,6 @@ export function validateGraph( }; } - // ── Wave Computation (Topological Sort) ────────────────────────────── /** @@ -259,7 +273,6 @@ export function computeWaves( return { waves, errors }; } - // ── File Scope Affinity ────────────────────────────────────────────── /** @@ -403,7 +416,6 @@ export function applyFileScopeAffinity( return result; } - // ── Repo-Scoped Lane Helpers ───────────────────────────────────────── /** @@ -505,14 +517,18 @@ export function generateLaneId(laneLocalNumber: number, repoId?: string): string * @param opId - Operator identifier (sanitized, e.g., "henrylach") * @param repoId - Repo identifier (undefined in repo mode) */ -export function generateLaneSessionId(sessionPrefix: string, laneLocalNumber: number, opId: string, repoId?: string): string { +export function generateLaneSessionId( + sessionPrefix: string, + laneLocalNumber: number, + opId: string, + repoId?: string, +): string { if (repoId) { return `${sessionPrefix}-${opId}-${repoId}-lane-${laneLocalNumber}`; } return `${sessionPrefix}-${opId}-lane-${laneLocalNumber}`; } - // ── Repo-Scoped Worktree Resolution ───────────────────────────────── /** @@ -583,7 +599,7 @@ export function resolveBaseBranch( // instead of the orch branch, bypassing batch isolation. console.error( `[taskplane] resolveBaseBranch WARNING: orch branch "${batchBaseBranch}" not found in repo "${repoId}" at ${repoRoot} — falling back to repo HEAD. ` + - `This bypasses orch branch isolation. Ensure the orch branch was created in all workspace repos.`, + `This bypasses orch branch isolation. Ensure the orch branch was created in all workspace repos.`, ); } catch (err) { console.error( @@ -621,16 +637,15 @@ export function resolveBaseBranch( if (repoId && batchBaseBranch.startsWith("orch/")) { throw new Error( `Cannot resolve base branch for repo "${repoId}" at ${repoRoot}: ` + - `HEAD is detached and no defaultBranch is configured. ` + - `The batch base branch "${batchBaseBranch}" is an orch branch that does not exist in this repo. ` + - `Configure a defaultBranch for this repo in task-orchestrator.yaml workspace settings.`, + `HEAD is detached and no defaultBranch is configured. ` + + `The batch base branch "${batchBaseBranch}" is an orch branch that does not exist in this repo. ` + + `Configure a defaultBranch for this repo in task-orchestrator.yaml workspace settings.`, ); } return batchBaseBranch; } - // ── Segment Planning (TP-080) ─────────────────────────────────────── const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; @@ -778,7 +793,7 @@ function buildSegmentNodes(taskId: string, repoIds: string[]) { repoId, order, })); - return nodes.sort((a, b) => (a.order - b.order) || a.repoId.localeCompare(b.repoId)); + return nodes.sort((a, b) => a.order - b.order || a.repoId.localeCompare(b.repoId)); } export function buildSegmentPlanForTask( @@ -839,7 +854,6 @@ export function buildTaskSegmentPlans( return plans; } - // ── Lane Assignment ────────────────────────────────────────────────── /** @@ -877,9 +891,7 @@ export function assignTasksToLanes( // Step 3: Initialize lane weights (for load-balanced assignment) const laneWeights: number[] = new Array(laneCount).fill(0); - const laneAssignments: LaneAssignment[][] = new Array(laneCount) - .fill(null) - .map(() => []); + const laneAssignments: LaneAssignment[][] = new Array(laneCount).fill(null).map(() => []); function getWeight(taskId: string): number { const task = pending.get(taskId); @@ -970,7 +982,6 @@ export function assignTasksToLanes( return result; } - // ── Global Lane Cap (TP-148) ───────────────────────────────────────── /** @@ -1044,8 +1055,8 @@ export function enforceGlobalLaneCap( if (finalTotal > maxLanes) { console.error( `[taskplane] warning: global maxLanes=${maxLanes} could not be enforced — ` + - `${byRepo.size} repos each need at least 1 lane (total: ${finalTotal}). ` + - `Increase maxLanes to at least ${byRepo.size} to avoid this.`, + `${byRepo.size} repos each need at least 1 lane (total: ${finalTotal}). ` + + `Increase maxLanes to at least ${byRepo.size} to avoid this.`, ); } @@ -1061,7 +1072,6 @@ export function enforceGlobalLaneCap( } } - /** * Result of `allocateLanes()`. * @@ -1145,16 +1155,13 @@ export function validateAllocationInputs( return new AllocationError( "ALLOC_INVALID_CONFIG", `Unknown assignment strategy: "${config.assignment.strategy}". ` + - `Valid strategies: ${validStrategies.join(", ")}`, + `Valid strategies: ${validStrategies.join(", ")}`, ); } // Validate worktree prefix is non-empty if (!config.orchestrator.worktree_prefix?.trim()) { - return new AllocationError( - "ALLOC_INVALID_CONFIG", - `worktree_prefix must be a non-empty string`, - ); + return new AllocationError("ALLOC_INVALID_CONFIG", `worktree_prefix must be a non-empty string`); } return null; @@ -1336,7 +1343,12 @@ export function allocateLanes( const groupLaneNumbers = repoLaneGroups.get(groupKey)!; const groupRepoId = repoIdForGroup.get(groupKey); const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig); - const groupBaseBranch = resolveBaseBranch(groupRepoId, groupRepoRoot, baseBranch, workspaceConfig); + const groupBaseBranch = resolveBaseBranch( + groupRepoId, + groupRepoRoot, + baseBranch, + workspaceConfig, + ); const worktreeResult = ensureLaneWorktrees( groupLaneNumbers, @@ -1370,16 +1382,17 @@ export function allocateLanes( const failedLanes = worktreeResult.errors .map((e) => `Lane ${e.laneNumber}: [${e.code}] ${e.message}`) .join("\n"); - const withinGroupRollbackIssues = worktreeResult.rollbackErrors.length > 0 - ? "\nWithin-group rollback issues:\n" + - worktreeResult.rollbackErrors - .map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`) - .join("\n") - : ""; - const crossRepoRollbackIssues = rollbackErrors.length > 0 - ? "\nCross-repo rollback issues:\n" + - rollbackErrors.map((e) => ` ${e}`).join("\n") - : ""; + const withinGroupRollbackIssues = + worktreeResult.rollbackErrors.length > 0 + ? "\nWithin-group rollback issues:\n" + + worktreeResult.rollbackErrors + .map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`) + .join("\n") + : ""; + const crossRepoRollbackIssues = + rollbackErrors.length > 0 + ? "\nCross-repo rollback issues:\n" + rollbackErrors.map((e) => ` ${e}`).join("\n") + : ""; return { success: false, @@ -1420,7 +1433,14 @@ export function allocateLanes( for (const groupKey of createdGroupKeys) { const groupRepoId = repoIdForGroup.get(groupKey); const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig); - removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId, undefined, batchId, config); + removeAllWorktrees( + config.orchestrator.worktree_prefix, + groupRepoRoot, + opId, + undefined, + batchId, + config, + ); } return { success: false, @@ -1447,10 +1467,7 @@ export function allocateLanes( (sum, t) => sum + (sizeWeights[t.task.size] || sizeWeights["M"] || 2), 0, ); - const estimatedMinutes = allocatedTasks.reduce( - (sum, t) => sum + t.estimatedMinutes, - 0, - ); + const estimatedMinutes = allocatedTasks.reduce((sum, t) => sum + t.estimatedMinutes, 0); const laneSessionId = generateLaneSessionId(sessionPrefix, entry.localLane, opId, entry.repoId); allocatedLanes.push({ @@ -1480,7 +1497,6 @@ export function allocateLanes( }; } - // ── Full Wave Pipeline ─────────────────────────────────────────────── /** diff --git a/extensions/taskplane/workspace.ts b/extensions/taskplane/workspace.ts index c82ba3c1..58faddc0 100644 --- a/extensions/taskplane/workspace.ts +++ b/extensions/taskplane/workspace.ts @@ -54,7 +54,6 @@ import { type PointerResolution, } from "./types.ts"; - // ── Path Canonicalization ──────────────────────────────────────────── /** @@ -108,7 +107,6 @@ function isPathWithinContainer(childPath: string, parentPath: string): boolean { return child === parent || child.startsWith(`${parent}/`); } - // ── Pointer Resolution ─────────────────────────────────────────────── /** @@ -287,7 +285,6 @@ export function resolvePointer( }; } - // ── Workspace Config Loading ───────────────────────────────────────── /** @@ -453,9 +450,10 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu normalizedPaths.set(normalizedPath, repoId); // Build repo config - const defaultBranch = typeof repoEntry.default_branch === "string" && repoEntry.default_branch.trim() - ? repoEntry.default_branch.trim() - : undefined; + const defaultBranch = + typeof repoEntry.default_branch === "string" && repoEntry.default_branch.trim() + ? repoEntry.default_branch.trim() + : undefined; repos.set(repoId, { id: repoId, @@ -587,7 +585,6 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu }; } - // ── Cross-Config Validation ───────────────────────────────────────── /** @@ -603,7 +600,7 @@ export function validateTaskAreasWithinTasksRoot( ): void { const tasksRoot = workspaceConfig.routing.tasksRoot; const areaEntries = Object.entries(taskRunnerConfig.task_areas ?? {}).sort((a, b) => - a[0].localeCompare(b[0]) + a[0].localeCompare(b[0]), ); for (const [areaName, area] of areaEntries) { @@ -620,7 +617,6 @@ export function validateTaskAreasWithinTasksRoot( } } - // ── Execution Context Builder ──────────────────────────────────────── /** @@ -643,8 +639,14 @@ function isInsideGitRepo(cwd: string): boolean { export function buildExecutionContext( cwd: string, - loadOrchConfig: (root: string, pointerConfigRoot?: string) => import("./types.ts").OrchestratorConfig, - loadTaskConfig: (root: string, pointerConfigRoot?: string) => import("./types.ts").TaskRunnerConfig, + loadOrchConfig: ( + root: string, + pointerConfigRoot?: string, + ) => import("./types.ts").OrchestratorConfig, + loadTaskConfig: ( + root: string, + pointerConfigRoot?: string, + ) => import("./types.ts").TaskRunnerConfig, ): import("./types.ts").ExecutionContext { const workspaceConfig = loadWorkspaceConfig(cwd); @@ -656,7 +658,7 @@ export function buildExecutionContext( throw new WorkspaceConfigError( "WORKSPACE_SETUP_REQUIRED", `No workspace config found at ${wsConfigFile}, and current directory is not a git repository: ${cwd}. ` + - `Run Taskplane from a git repository, or create ${wsConfigFile} (taskplane init) to use workspace mode.`, + `Run Taskplane from a git repository, or create ${wsConfigFile} (taskplane init) to use workspace mode.`, undefined, cwd, ); diff --git a/extensions/taskplane/worktree.ts b/extensions/taskplane/worktree.ts index 9a77d833..5bd5c536 100644 --- a/extensions/taskplane/worktree.ts +++ b/extensions/taskplane/worktree.ts @@ -10,7 +10,20 @@ import { execLog } from "./execution.ts"; import { runGit } from "./git.ts"; import { resolveOperatorId } from "./naming.ts"; import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts"; -import type { AllocatedLane, BulkWorktreeError, CreateLaneWorktreesResult, CreateWorktreeOptions, LaneTaskOutcome, OrchestratorConfig, PreflightCheck, PreflightResult, RemoveAllWorktreesResult, RemoveWorktreeOutcome, RemoveWorktreeResult, WorktreeInfo } from "./types.ts"; +import type { + AllocatedLane, + BulkWorktreeError, + CreateLaneWorktreesResult, + CreateWorktreeOptions, + LaneTaskOutcome, + OrchestratorConfig, + PreflightCheck, + PreflightResult, + RemoveAllWorktreesResult, + RemoveWorktreeOutcome, + RemoveWorktreeResult, + WorktreeInfo, +} from "./types.ts"; // ── Worktree Helpers ───────────────────────────────────────────────── @@ -42,10 +55,7 @@ export function generateBranchName(laneNumber: number, batchId: string, opId: st * @param repoRoot - Absolute path to the main repository root * @param config - Orchestrator config (reads `worktree_location`) */ -export function resolveWorktreeBasePath( - repoRoot: string, - config: OrchestratorConfig, -): string { +export function resolveWorktreeBasePath(repoRoot: string, config: OrchestratorConfig): string { const location = config.orchestrator.worktree_location; if (location === "sibling") { return resolve(repoRoot, ".."); @@ -301,12 +311,9 @@ export function normalizePath(p: string): string { export function isRegisteredWorktree(targetPath: string, cwd: string): boolean { const entries = parseWorktreeList(cwd); const normalized = normalizePath(targetPath); - return entries.some( - (e) => normalizePath(e.path) === normalized, - ); + return entries.some((e) => normalizePath(e.path) === normalized); } - // ── Worktree CRUD Operations ───────────────────────────────────────── /** @@ -337,15 +344,12 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId); // ── Pre-check 1: Validate base branch exists ───────────────── - const baseBranchCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${baseBranch}`], - repoRoot, - ); + const baseBranchCheck = runGit(["rev-parse", "--verify", `refs/heads/${baseBranch}`], repoRoot); if (!baseBranchCheck.ok) { throw new WorktreeError( "WORKTREE_INVALID_BASE", `Base branch "${baseBranch}" does not exist locally. ` + - `Verify the branch exists: git branch --list ${baseBranch}`, + `Verify the branch exists: git branch --list ${baseBranch}`, ); } const baseBranchHead = baseBranchCheck.stdout.trim(); @@ -355,7 +359,7 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W throw new WorktreeError( "WORKTREE_PATH_IS_WORKTREE", `Path "${worktreePath}" is already registered as a git worktree. ` + - `Remove it first: git worktree remove "${worktreePath}"`, + `Remove it first: git worktree remove "${worktreePath}"`, ); } @@ -367,7 +371,7 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W throw new WorktreeError( "WORKTREE_PATH_NOT_EMPTY", `Path "${worktreePath}" exists and is not empty. ` + - `It is not a registered git worktree. Remove or rename it before creating a worktree here.`, + `It is not a registered git worktree. Remove or rename it before creating a worktree here.`, ); } } catch (err) { @@ -381,16 +385,13 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W } // ── Pre-check 4: Check if branch already exists ────────────── - const branchCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${branch}`], - repoRoot, - ); + const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot); if (branchCheck.ok) { throw new WorktreeError( "WORKTREE_BRANCH_EXISTS", `Branch "${branch}" already exists. ` + - `This may indicate a stale worktree from a previous batch. ` + - `Delete it: git branch -D ${branch}`, + `This may indicate a stale worktree from a previous batch. ` + + `Delete it: git branch -D ${branch}`, ); } @@ -401,29 +402,23 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W ensureBatchContainerDir(containerDir); // ── Create worktree ────────────────────────────────────────── - const createResult = runGit( - ["worktree", "add", "-b", branch, worktreePath, baseBranch], - repoRoot, - ); + const createResult = runGit(["worktree", "add", "-b", branch, worktreePath, baseBranch], repoRoot); if (!createResult.ok) { throw new WorktreeError( "WORKTREE_GIT_ERROR", `Failed to create worktree at "${worktreePath}" on branch "${branch}" ` + - `from "${baseBranch}": ${createResult.stderr}`, + `from "${baseBranch}": ${createResult.stderr}`, ); } // ── Post-creation verification (R002 requirements) ─────────── // Verify 1: Correct branch is checked out - const headBranchResult = runGit( - ["rev-parse", "--abbrev-ref", "HEAD"], - worktreePath, - ); + const headBranchResult = runGit(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath); if (!headBranchResult.ok || headBranchResult.stdout !== branch) { throw new WorktreeError( "WORKTREE_VERIFY_FAILED", `Verification failed: expected branch "${branch}" checked out ` + - `in worktree, but got "${headBranchResult.stdout || "(unknown)"}".`, + `in worktree, but got "${headBranchResult.stdout || "(unknown)"}".`, ); } @@ -433,7 +428,7 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W throw new WorktreeError( "WORKTREE_VERIFY_FAILED", `Verification failed: worktree HEAD (${headCommitResult.stdout?.slice(0, 8) || "?"}) ` + - `does not match baseBranch "${baseBranch}" HEAD (${baseBranchHead.slice(0, 8)}).`, + `does not match baseBranch "${baseBranch}" HEAD (${baseBranchHead.slice(0, 8)}).`, ); } @@ -484,7 +479,7 @@ export function resetWorktree( throw new WorktreeError( "WORKTREE_NOT_FOUND", `Worktree path "${worktreePath}" does not exist on disk. ` + - `It may have been removed externally.`, + `It may have been removed externally.`, ); } @@ -493,20 +488,17 @@ export function resetWorktree( throw new WorktreeError( "WORKTREE_NOT_REGISTERED", `Path "${worktreePath}" exists but is not a registered git worktree. ` + - `It may have been removed from git tracking. Check: git worktree list`, + `It may have been removed from git tracking. Check: git worktree list`, ); } // ── Pre-check 3: Target branch resolves ────────────────────── - const targetCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${targetBranch}`], - repoRoot, - ); + const targetCheck = runGit(["rev-parse", "--verify", `refs/heads/${targetBranch}`], repoRoot); if (!targetCheck.ok) { throw new WorktreeError( "WORKTREE_INVALID_BASE", `Target branch "${targetBranch}" does not exist locally. ` + - `Verify the branch exists: git branch --list ${targetBranch}`, + `Verify the branch exists: git branch --list ${targetBranch}`, ); } const targetCommit = targetCheck.stdout.trim(); @@ -523,35 +515,29 @@ export function resetWorktree( throw new WorktreeError( "WORKTREE_DIRTY", `Worktree at "${worktreePath}" has uncommitted changes. ` + - `Workers must commit or discard all changes before a reset can proceed. ` + - `Dirty files:\n${statusCheck.stdout}`, + `Workers must commit or discard all changes before a reset can proceed. ` + + `Dirty files:\n${statusCheck.stdout}`, ); } // ── Reset: git checkout -B ─────── - const resetResult = runGit( - ["checkout", "-B", branch, targetBranch], - worktreePath, - ); + const resetResult = runGit(["checkout", "-B", branch, targetBranch], worktreePath); if (!resetResult.ok) { throw new WorktreeError( "WORKTREE_RESET_FAILED", `Failed to reset worktree at "${worktreePath}" ` + - `(branch "${branch}" → "${targetBranch}"): ${resetResult.stderr}`, + `(branch "${branch}" → "${targetBranch}"): ${resetResult.stderr}`, ); } // ── Post-reset verification ────────────────────────────────── // Verify 1: Current branch equals expected lane branch - const headBranchResult = runGit( - ["rev-parse", "--abbrev-ref", "HEAD"], - worktreePath, - ); + const headBranchResult = runGit(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath); if (!headBranchResult.ok || headBranchResult.stdout !== branch) { throw new WorktreeError( "WORKTREE_VERIFY_FAILED", `Post-reset verification failed: expected branch "${branch}" ` + - `checked out, but got "${headBranchResult.stdout || "(unknown)"}".`, + `checked out, but got "${headBranchResult.stdout || "(unknown)"}".`, ); } @@ -561,8 +547,8 @@ export function resetWorktree( throw new WorktreeError( "WORKTREE_VERIFY_FAILED", `Post-reset verification failed: worktree HEAD ` + - `(${headCommitResult.stdout?.slice(0, 8) || "?"}) does not match ` + - `target "${targetBranch}" commit (${targetCommit.slice(0, 8)}).`, + `(${headCommitResult.stdout?.slice(0, 8) || "?"}) does not match ` + + `target "${targetBranch}" commit (${targetCommit.slice(0, 8)}).`, ); } @@ -669,16 +655,20 @@ export function isWindowsMaxPathError(stderr: string): boolean { * @returns { ok, stdout, stderr } * @since TP-188 (#543) */ -export function runWindowsCmdRd( - absolutePath: string, -): { ok: boolean; stdout: string; stderr: string } { +export function runWindowsCmdRd(absolutePath: string): { + ok: boolean; + stdout: string; + stderr: string; +} { const winPath = absolutePath.replace(/\//g, "\\"); try { const stdout = execFileSync("cmd", ["/c", "rd", "/s", "/q", winPath], { encoding: "utf-8", timeout: 60_000, stdio: ["pipe", "pipe", "pipe"], - }).toString().trim(); + }) + .toString() + .trim(); return { ok: true, stdout, stderr: "" }; } catch (err: unknown) { const e = err as { stdout?: string; stderr?: string; message?: string }; @@ -772,10 +762,7 @@ export function removeWorktree( let lastError = ""; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - const removeResult = runGit( - ["worktree", "remove", "--force", worktreePath], - repoRoot, - ); + const removeResult = runGit(["worktree", "remove", "--force", worktreePath], repoRoot); if (removeResult.ok) { // Successful removal — proceed to branch cleanup @@ -793,12 +780,10 @@ export function removeWorktree( // the error as terminal/retriable so other error classes still // surface unchanged. if (isWindowsMaxPathError(lastError)) { - execLog( - "cleanup", - "worktree", - `Windows MAX_PATH detected — falling back to cmd "rd /s /q"`, - { path: worktreePath, attempt }, - ); + execLog("cleanup", "worktree", `Windows MAX_PATH detected — falling back to cmd "rd /s /q"`, { + path: worktreePath, + attempt, + }); const fallback = runWindowsCmdRd(worktreePath); if (fallback.ok) { execLog( @@ -817,12 +802,10 @@ export function removeWorktree( // attempts, then fall through to the existing terminal/retry // classification (which will throw because "Filename too long" // is non-retriable per isRetriableRemoveError). - execLog( - "cleanup", - "worktree", - `cmd "rd /s /q" fallback failed`, - { path: worktreePath, error: fallback.stderr.slice(0, 200) }, - ); + execLog("cleanup", "worktree", `cmd "rd /s /q" fallback failed`, { + path: worktreePath, + error: fallback.stderr.slice(0, 200), + }); lastError = `git worktree remove failed: ${lastError}; ` + `cmd rd /s /q fallback failed: ${fallback.stderr}`; @@ -833,7 +816,7 @@ export function removeWorktree( throw new WorktreeError( "WORKTREE_REMOVE_FAILED", `Failed to remove worktree at "${worktreePath}" ` + - `(terminal error, not retried): ${lastError}`, + `(terminal error, not retried): ${lastError}`, ); } @@ -842,9 +825,9 @@ export function removeWorktree( throw new WorktreeError( "WORKTREE_REMOVE_RETRY_EXHAUSTED", `Failed to remove worktree at "${worktreePath}" after ` + - `${MAX_ATTEMPTS} attempts. Last error: ${lastError}. ` + - `This is likely a Windows file locking issue. ` + - `Close any programs accessing "${worktreePath}" and try again.`, + `${MAX_ATTEMPTS} attempts. Last error: ${lastError}. ` + + `This is likely a Windows file locking issue. ` + + `Close any programs accessing "${worktreePath}" and try again.`, ); } @@ -858,7 +841,7 @@ export function removeWorktree( throw new WorktreeError( "WORKTREE_VERIFY_FAILED", `Post-removal verification failed: path "${worktreePath}" ` + - `still exists on disk after successful git worktree remove.`, + `still exists on disk after successful git worktree remove.`, ); } @@ -869,7 +852,7 @@ export function removeWorktree( throw new WorktreeError( "WORKTREE_VERIFY_FAILED", `Post-removal verification failed: path "${worktreePath}" ` + - `is still registered as a git worktree after removal and prune.`, + `is still registered as a git worktree after removal and prune.`, ); } } @@ -959,7 +942,7 @@ export function ensureBranchDeleted( throw new WorktreeError( "WORKTREE_BRANCH_DELETE_FAILED", `Worktree "${worktreePath}" was removed, but failed to delete lane branch ` + - `"${branch}". Delete it manually: git branch -D ${branch}`, + `"${branch}". Delete it manually: git branch -D ${branch}`, ); } return { deleted: true, preserved: false }; @@ -979,10 +962,7 @@ export function ensureBranchDeleted( */ export function deleteBranchBestEffort(branch: string, repoRoot: string): boolean { // Check if branch exists first - const branchCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${branch}`], - repoRoot, - ); + const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot); if (!branchCheck.ok) { // Branch doesn't exist — idempotent success @@ -997,10 +977,7 @@ export function deleteBranchBestEffort(branch: string, repoRoot: string): boolea } // If delete failed but branch is now gone (race condition), treat as success - const recheckResult = runGit( - ["rev-parse", "--verify", `refs/heads/${branch}`], - repoRoot, - ); + const recheckResult = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot); if (!recheckResult.ok) { return true; } @@ -1009,7 +986,6 @@ export function deleteBranchBestEffort(branch: string, repoRoot: string): boolea return false; } - // ── Branch Protection Helpers ──────────────────────────────────────── /** Typed error codes for unmerged commit checks */ @@ -1054,35 +1030,46 @@ export function hasUnmergedCommits( repoRoot: string, ): UnmergedCommitsResult { // Verify branch exists - const branchCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${branch}`], - repoRoot, - ); + const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot); if (!branchCheck.ok) { - return { ok: false, count: 0, code: "BRANCH_NOT_FOUND", error: `Branch "${branch}" does not exist` }; + return { + ok: false, + count: 0, + code: "BRANCH_NOT_FOUND", + error: `Branch "${branch}" does not exist`, + }; } // Verify target branch exists - const targetCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${targetBranch}`], - repoRoot, - ); + const targetCheck = runGit(["rev-parse", "--verify", `refs/heads/${targetBranch}`], repoRoot); if (!targetCheck.ok) { - return { ok: false, count: 0, code: "TARGET_BRANCH_MISSING", error: `Target branch "${targetBranch}" does not exist` }; + return { + ok: false, + count: 0, + code: "TARGET_BRANCH_MISSING", + error: `Target branch "${targetBranch}" does not exist`, + }; } // Count commits on branch not reachable from target - const countResult = runGit( - ["rev-list", "--count", `${targetBranch}..${branch}`], - repoRoot, - ); + const countResult = runGit(["rev-list", "--count", `${targetBranch}..${branch}`], repoRoot); if (!countResult.ok) { - return { ok: false, count: 0, code: "UNMERGED_COUNT_FAILED", error: `Failed to count unmerged commits: ${countResult.stderr}` }; + return { + ok: false, + count: 0, + code: "UNMERGED_COUNT_FAILED", + error: `Failed to count unmerged commits: ${countResult.stderr}`, + }; } const count = parseInt(countResult.stdout.trim(), 10); if (isNaN(count)) { - return { ok: false, count: 0, code: "UNMERGED_COUNT_PARSE_FAILED", error: `Failed to parse commit count: "${countResult.stdout}"` }; + return { + ok: false, + count: 0, + code: "UNMERGED_COUNT_PARSE_FAILED", + error: `Failed to parse commit count: "${countResult.stdout}"`, + }; } return { ok: true, count }; @@ -1197,10 +1184,7 @@ export function preserveBranch( repoRoot: string, ): PreserveBranchResult { // Check if branch exists - const branchCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${branch}`], - repoRoot, - ); + const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot); if (!branchCheck.ok) { return { ok: true, action: "no-branch" }; } @@ -1212,7 +1196,9 @@ export function preserveBranch( // Target branch missing or git error — skip preservation gracefully // Map unmerged error codes to preserve error codes const preserveCode: PreserveBranchErrorCode = - unmergedResult.code === "TARGET_BRANCH_MISSING" ? "TARGET_BRANCH_MISSING" : "UNMERGED_COUNT_FAILED"; + unmergedResult.code === "TARGET_BRANCH_MISSING" + ? "TARGET_BRANCH_MISSING" + : "UNMERGED_COUNT_FAILED"; return { ok: false, action: "error", @@ -1229,10 +1215,7 @@ export function preserveBranch( const savedName = computeSavedBranchName(branch); // Check for collision - const existingCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${savedName}`], - repoRoot, - ); + const existingCheck = runGit(["rev-parse", "--verify", `refs/heads/${savedName}`], repoRoot); const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : ""; const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA); @@ -1249,10 +1232,7 @@ export function preserveBranch( case "create": case "create-suffixed": { // Create saved branch at same SHA - const createResult = runGit( - ["branch", resolution.savedName, branchSHA], - repoRoot, - ); + const createResult = runGit(["branch", resolution.savedName, branchSHA], repoRoot); if (!createResult.ok) { return { ok: false, @@ -1271,11 +1251,15 @@ export function preserveBranch( } default: - return { ok: false, action: "error", code: "UNKNOWN_RESOLUTION", error: `Unknown resolution action` }; + return { + ok: false, + action: "error", + code: "UNKNOWN_RESOLUTION", + error: `Unknown resolution action`, + }; } } - // ── Bulk Worktree Operations ───────────────────────────────────────── /** @@ -1306,7 +1290,12 @@ export function preserveBranch( * only returns worktrees inside the `{opId}-{batchId}/` container * @returns - WorktreeInfo[] sorted by laneNumber (ascending) */ -export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string): WorktreeInfo[] { +export function listWorktrees( + prefix: string, + repoRoot: string, + opId: string, + batchId?: string, +): WorktreeInfo[] { const entries = parseWorktreeList(repoRoot); const results: WorktreeInfo[] = []; @@ -1317,9 +1306,7 @@ export function listWorktrees(prefix: string, repoRoot: string, opId: string, ba // Legacy pattern: {prefix}-{N} (only matched when opId is the default fallback) // This allows cleanup of worktrees from prior batches without operator IDs. - const legacyPattern = opId === "op" - ? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`) - : null; + const legacyPattern = opId === "op" ? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`) : null; // ── New batch-scoped nested pattern ────────────────────────── // Basename: lane-{N} @@ -1617,7 +1604,12 @@ export function removeAllWorktrees( const outcomes: RemoveWorktreeOutcome[] = []; const removed: WorktreeInfo[] = []; const failed: RemoveWorktreeOutcome[] = []; - const preserved: Array<{ branch: string; savedBranch: string; laneNumber: number; unmergedCount?: number }> = []; + const preserved: Array<{ + branch: string; + savedBranch: string; + laneNumber: number; + unmergedCount?: number; + }> = []; for (const wt of worktrees) { try { @@ -1692,7 +1684,9 @@ export function removeAllWorktrees( rmdirSync(basePath); } } - } catch { /* safe default — leave it alone */ } + } catch { + /* safe default — leave it alone */ + } } return { @@ -1756,12 +1750,21 @@ export function execCheck(command: string, cwd?: string, timeoutMs = 10_000): Ex // platform). We attribute SIGTERM to the timeout because `execCheck` is // the one setting the timeout option — there's no other realistic source // of SIGTERM for a short-lived diagnostic command we just spawned. - const e = err as { code?: string | number; status?: number | null; signal?: NodeJS.Signals | null; errno?: number; message?: string; path?: string; stderr?: string | Buffer }; - const stderrText = typeof e?.stderr === "string" - ? e.stderr - : e?.stderr instanceof Buffer - ? e.stderr.toString("utf-8") - : ""; + const e = err as { + code?: string | number; + status?: number | null; + signal?: NodeJS.Signals | null; + errno?: number; + message?: string; + path?: string; + stderr?: string | Buffer; + }; + const stderrText = + typeof e?.stderr === "string" + ? e.stderr + : e?.stderr instanceof Buffer + ? e.stderr.toString("utf-8") + : ""; const commandName = command.split(/\s+/)[0]; if (e?.code === "ENOENT") { return { ok: false, stdout: "", errorKind: "not-found", errorDetail: e.path ?? commandName }; @@ -1770,11 +1773,19 @@ export function execCheck(command: string, cwd?: string, timeoutMs = 10_000): Ex return { ok: false, stdout: "", errorKind: "not-found", errorDetail: commandName }; } // Windows cmd.exe pattern: exit 1 + "is not recognized" in stderr. - if (e?.signal !== "SIGTERM" && /is not recognized as an internal or external command|command not found/i.test(stderrText)) { + if ( + e?.signal !== "SIGTERM" && + /is not recognized as an internal or external command|command not found/i.test(stderrText) + ) { return { ok: false, stdout: "", errorKind: "not-found", errorDetail: commandName }; } if (e?.signal === "SIGTERM") { - return { ok: false, stdout: "", errorKind: "timeout", errorDetail: `exceeded ${timeoutMs}ms timeout` }; + return { + ok: false, + stdout: "", + errorKind: "timeout", + errorDetail: `exceeded ${timeoutMs}ms timeout`, + }; } if (typeof e?.status === "number") { return { ok: false, stdout: "", errorKind: "exit-code", errorDetail: `exit ${e.status}` }; @@ -1782,7 +1793,12 @@ export function execCheck(command: string, cwd?: string, timeoutMs = 10_000): Ex if (e?.signal) { return { ok: false, stdout: "", errorKind: "signal", errorDetail: String(e.signal) }; } - return { ok: false, stdout: "", errorKind: "unknown", errorDetail: e?.message ?? "unknown error" }; + return { + ok: false, + stdout: "", + errorKind: "unknown", + errorDetail: e?.message ?? "unknown error", + }; } } @@ -1853,9 +1869,7 @@ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): Pre checks.push({ name: "git-worktree", status: worktreeResult.ok ? "pass" : "fail", - message: worktreeResult.ok - ? "Worktree support available" - : "Git worktree not available", + message: worktreeResult.ok ? "Worktree support available" : "Git worktree not available", hint: worktreeResult.ok ? undefined : repoRoot @@ -1905,11 +1919,13 @@ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): Pre // in v0.74.0. Recommend the new scope for new installs; the legacy // scope still resolves at runtime via Pi's bundled aliasing if a // transitional install has it. - hint = "Install Pi: npm install -g @earendil-works/pi-coding-agent (legacy: @mariozechner/pi-coding-agent)"; + hint = + "Install Pi: npm install -g @earendil-works/pi-coding-agent (legacy: @mariozechner/pi-coding-agent)"; break; case "timeout": message = `Pi did not respond within ${PI_PREFLIGHT_TIMEOUT_MS / 1000}s (retried once)`; - hint = "Pi appears installed but is responding slowly. Common causes: antivirus scanning the Node binary on first launch, slow disk, a zombie pi process holding a lock, or a stale mise shim. Try running `pi --version` directly to see how long it takes."; + hint = + "Pi appears installed but is responding slowly. Common causes: antivirus scanning the Node binary on first launch, slow disk, a zombie pi process holding a lock, or a stale mise shim. Try running `pi --version` directly to see how long it takes."; break; case "exit-code": message = `Pi exited with error (${piResult.errorDetail ?? "non-zero status"})`; @@ -1917,7 +1933,8 @@ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): Pre break; case "signal": message = `Pi was killed by signal (${piResult.errorDetail ?? "unknown"})`; - hint = "The pi process was killed externally. Check for OOM, antivirus quarantine, or interrupted shell."; + hint = + "The pi process was killed externally. Check for OOM, antivirus quarantine, or interrupted shell."; break; default: message = `Pi check failed (${piResult.errorDetail ?? "unknown error"})`; @@ -1939,10 +1956,7 @@ export function formatPreflightResults(result: PreflightResult): string { const lines: string[] = ["Preflight Check:"]; for (const check of result.checks) { - const icon = - check.status === "pass" ? "✅" : - check.status === "warn" ? "⚠️ " : - "❌"; + const icon = check.status === "pass" ? "✅" : check.status === "warn" ? "⚠️ " : "❌"; const nameCol = check.name.padEnd(18); lines.push(` ${icon} ${nameCol} ${check.message}`); if (check.hint && check.status !== "pass") { @@ -1968,7 +1982,6 @@ export function formatPreflightResults(result: PreflightResult): string { return lines.join("\n"); } - // ── Worktree Reset with Safety ─────────────────────────────────────── /** @@ -2014,9 +2027,14 @@ export function safeResetWorktree( // of failing on the exit code. const cleanResult = runGit(["clean", "-fd"], worktree.path); if (!cleanResult.ok) { - execLog("reset", `lane-${worktree.laneNumber}`, "git clean -fd returned non-zero (may be partial)", { - stderr: cleanResult.stderr.slice(0, 200), - }); + execLog( + "reset", + `lane-${worktree.laneNumber}`, + "git clean -fd returned non-zero (may be partial)", + { + stderr: cleanResult.stderr.slice(0, 200), + }, + ); } // Check if the worktree is clean enough to proceed. @@ -2025,8 +2043,8 @@ export function safeResetWorktree( const statusCheck = runGit(["status", "--porcelain"], worktree.path); if (statusCheck.ok && statusCheck.stdout.length > 0) { // Still dirty after cleaning — check if only untracked files remain - const lines = statusCheck.stdout.split("\n").filter(l => l.trim()); - const onlyUntracked = lines.every(l => l.startsWith("??")); + const lines = statusCheck.stdout.split("\n").filter((l) => l.trim()); + const onlyUntracked = lines.every((l) => l.startsWith("??")); if (!onlyUntracked) { return { success: false, @@ -2034,9 +2052,14 @@ export function safeResetWorktree( }; } // Only untracked files remain (e.g., undeletable "nul") — safe to proceed - execLog("reset", `lane-${worktree.laneNumber}`, "untracked files remain after clean (non-blocking)", { - files: lines.map(l => l.slice(3)).join(", "), - }); + execLog( + "reset", + `lane-${worktree.laneNumber}`, + "untracked files remain after clean (non-blocking)", + { + files: lines.map((l) => l.slice(3)).join(", "), + }, + ); } // Retry reset after cleaning @@ -2058,7 +2081,6 @@ export function safeResetWorktree( } } - // ── Force Cleanup ──────────────────────────────────────────────────── /** @@ -2093,11 +2115,15 @@ export function forceCleanupWorktree( // special handling. Try rmSync first, then fall back to OS-specific // removal for stubborn files. rmSync(worktreePath, { recursive: true, force: true }); - execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, { path: worktreePath }); + execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, { + path: worktreePath, + }); } catch (rmErr: unknown) { // If Node's rmSync fails (e.g., Windows reserved names), try platform-specific const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr); - execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, { error: rmMsg }); + execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, { + error: rmMsg, + }); try { if (process.platform === "win32") { @@ -2109,10 +2135,15 @@ export function forceCleanupWorktree( execLog("cleanup", `lane-${laneNumber}`, `OS-level removal succeeded`, { path: worktreePath }); } catch (osErr: unknown) { const osMsg = osErr instanceof Error ? osErr.message : String(osErr); - execLog("cleanup", `lane-${laneNumber}`, `OS-level removal also failed — manual cleanup needed`, { - path: worktreePath, - error: osMsg, - }); + execLog( + "cleanup", + `lane-${laneNumber}`, + `OS-level removal also failed — manual cleanup needed`, + { + path: worktreePath, + error: osMsg, + }, + ); } } } @@ -2145,12 +2176,13 @@ export function forceCleanupWorktree( if (containerName.includes("-")) { const containerRemoved = removeBatchContainerIfEmpty(containerDir); if (containerRemoved) { - execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, { path: containerDir }); + execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, { + path: containerDir, + }); } } } - // ── Partial Progress Preservation ──────────────────────────────────── /** @@ -2225,10 +2257,7 @@ export function savePartialProgress( repoId?: string, ): SavePartialProgressResult { // Check if lane branch exists - const branchCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${laneBranch}`], - repoRoot, - ); + const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${laneBranch}`], repoRoot); if (!branchCheck.ok) { return { saved: false, commitCount: 0, taskId, error: `Lane branch "${laneBranch}" not found` }; } @@ -2254,10 +2283,7 @@ export function savePartialProgress( const savedName = computePartialProgressBranchName(opId, taskId, batchId, repoId); // Check for collision (idempotent re-runs, retries) - const existingCheck = runGit( - ["rev-parse", "--verify", `refs/heads/${savedName}`], - repoRoot, - ); + const existingCheck = runGit(["rev-parse", "--verify", `refs/heads/${savedName}`], repoRoot); const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : ""; const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA); @@ -2274,10 +2300,7 @@ export function savePartialProgress( case "create": case "create-suffixed": { - const createResult = runGit( - ["branch", resolution.savedName, branchSHA], - repoRoot, - ); + const createResult = runGit(["branch", resolution.savedName, branchSHA], repoRoot); if (!createResult.ok) { return { saved: false, @@ -2386,9 +2409,7 @@ export function preserveFailedLaneProgress( } // Find failed/stalled tasks - const failedTasks = taskOutcomes.filter( - (to) => to.status === "failed" || to.status === "stalled", - ); + const failedTasks = taskOutcomes.filter((to) => to.status === "failed" || to.status === "stalled"); // Track which lane branches we've already processed (a lane may have // multiple tasks; only save once per branch since all commits are shared) @@ -2432,7 +2453,9 @@ export function preserveFailedLaneProgress( // Track the saved branch name for caller visibility preservedBranches.add(result.savedBranch!); - execLog("partial-progress", failedTask.taskId, + execLog( + "partial-progress", + failedTask.taskId, `Task ${failedTask.taskId} failed but has ${result.commitCount} commit(s) of partial progress on branch ${result.savedBranch}`, { laneBranch: laneInfo.branch, @@ -2447,9 +2470,11 @@ export function preserveFailedLaneProgress( // irreversibly lose the partial work. unsafeBranches.add(laneInfo.branch); - execLog("partial-progress", failedTask.taskId, + execLog( + "partial-progress", + failedTask.taskId, `WARNING: Failed to preserve partial progress for task ${failedTask.taskId} ` + - `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`, + `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`, { laneBranch: laneInfo.branch, commitCount: result.commitCount, @@ -2463,7 +2488,6 @@ export function preserveFailedLaneProgress( return { results, preservedBranches, unsafeBranches }; } - /** * TP-147: Preserve partial progress for all skipped tasks before cleanup/reset. * @@ -2505,9 +2529,7 @@ export function preserveSkippedLaneProgress( } // Find skipped tasks - const skippedTasks = taskOutcomes.filter( - (to) => to.status === "skipped", - ); + const skippedTasks = taskOutcomes.filter((to) => to.status === "skipped"); // Track which lane branches we've already processed (a lane may have // multiple tasks; only save once per branch since all commits are shared) @@ -2549,7 +2571,9 @@ export function preserveSkippedLaneProgress( if (result.saved) { preservedBranches.add(result.savedBranch!); - execLog("partial-progress", skippedTask.taskId, + execLog( + "partial-progress", + skippedTask.taskId, `Task ${skippedTask.taskId} was skipped but has ${result.commitCount} commit(s) of partial progress preserved on branch ${result.savedBranch}`, { laneBranch: laneInfo.branch, @@ -2561,9 +2585,11 @@ export function preserveSkippedLaneProgress( } else if (result.commitCount > 0 || result.error) { unsafeBranches.add(laneInfo.branch); - execLog("partial-progress", skippedTask.taskId, + execLog( + "partial-progress", + skippedTask.taskId, `WARNING: Failed to preserve partial progress for skipped task ${skippedTask.taskId} ` + - `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`, + `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`, { laneBranch: laneInfo.branch, commitCount: result.commitCount, @@ -2577,7 +2603,6 @@ export function preserveSkippedLaneProgress( return { results, preservedBranches, unsafeBranches }; } - // ── Stale Branch Cleanup (TP-051) ──────────────────────────────────── /** @@ -2630,7 +2655,7 @@ export function deleteStaleBranches( if (taskBranchResult.ok && taskBranchResult.stdout.trim()) { const branches = taskBranchResult.stdout .split("\n") - .map(b => b.replace(/^\*?\s+/, "").trim()) + .map((b) => b.replace(/^\*?\s+/, "").trim()) .filter(Boolean); for (const branch of branches) { @@ -2648,7 +2673,7 @@ export function deleteStaleBranches( if (savedTaskResult.ok && savedTaskResult.stdout.trim()) { const branches = savedTaskResult.stdout .split("\n") - .map(b => b.replace(/^\*?\s+/, "").trim()) + .map((b) => b.replace(/^\*?\s+/, "").trim()) .filter(Boolean); for (const branch of branches) { @@ -2669,7 +2694,7 @@ export function deleteStaleBranches( if (savedProgressResult.ok && savedProgressResult.stdout.trim()) { const branches = savedProgressResult.stdout .split("\n") - .map(b => b.replace(/^\*?\s+/, "").trim()) + .map((b) => b.replace(/^\*?\s+/, "").trim()) .filter(Boolean); const batchSuffix = `-${batchId}`; @@ -2698,6 +2723,3 @@ export function deleteStaleBranches( return { deletedTaskBranches, deletedSavedBranches, failedDeletes }; } - - - diff --git a/extensions/tests/auto-integration-deterministic.integration.test.ts b/extensions/tests/auto-integration-deterministic.integration.test.ts index badc9747..1743725b 100644 --- a/extensions/tests/auto-integration-deterministic.integration.test.ts +++ b/extensions/tests/auto-integration-deterministic.integration.test.ts @@ -67,7 +67,9 @@ function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "auto-int-det-test-")); } -function makeIntegrationBatchState(overrides?: Partial): OrchBatchRuntimeState { +function makeIntegrationBatchState( + overrides?: Partial, +): OrchBatchRuntimeState { const state = freshOrchBatchState(); state.batchId = "20260322T120000"; state.baseBranch = "main"; @@ -95,8 +97,24 @@ function makeMockPi() { } function makeMockExecutor( - resultOrFn: { success: boolean; integratedLocally: boolean; commitCount: string; message: string; error?: string } | - ((mode: string, context: any) => { success: boolean; integratedLocally: boolean; commitCount: string; message: string; error?: string }), + resultOrFn: + | { + success: boolean; + integratedLocally: boolean; + commitCount: string; + message: string; + error?: string; + } + | (( + mode: string, + context: any, + ) => { + success: boolean; + integratedLocally: boolean; + commitCount: string; + message: string; + error?: string; + }), ): IntegrationExecutor & { calls: Array<{ mode: string; context: any }> } { const calls: Array<{ mode: string; context: any }> = []; const executor = ((mode: string, context: any) => { @@ -134,7 +152,12 @@ function configureMockExecFileSync( } // gh api repos/.../protection -- branch protection check - if (cmd === "gh" && args[0] === "api" && typeof args[1] === "string" && args[1].includes("/protection")) { + if ( + cmd === "gh" && + args[0] === "api" && + typeof args[1] === "string" && + args[1].includes("/protection") + ) { if (protection === "protected") { return "{}"; // 200 OK → protected } else if (protection === "unprotected") { @@ -313,9 +336,7 @@ describe("18.x — Auto mode: executor call order and message assertions", () => expect(integrationMsg!.sendOpts.triggerTurn).toBe(false); // NO confirmation-related messages (no triggerTurn: true) - const confirmMsgs = pi.messages.filter( - (m: any) => m.sendOpts && m.sendOpts.triggerTurn === true, - ); + const confirmMsgs = pi.messages.filter((m: any) => m.sendOpts && m.sendOpts.triggerTurn === true); expect(confirmMsgs).toHaveLength(0); // Supervisor deactivated @@ -332,7 +353,13 @@ describe("18.x — Auto mode: executor call order and message assertions", () => const executor = makeMockExecutor((mode) => { if (mode === "ff") { - return { success: false, integratedLocally: false, commitCount: "0", message: "not linear", error: "branches diverged" }; + return { + success: false, + integratedLocally: false, + commitCount: "0", + message: "not linear", + error: "branches diverged", + }; } return { success: true, integratedLocally: true, commitCount: "3", message: "Merged 3 commits" }; }); @@ -354,9 +381,7 @@ describe("18.x — Auto mode: executor call order and message assertions", () => expect(resultMsg!.opts.content[0].text).toContain("Fell back to merge"); // No confirmation prompts - const confirmMsgs = pi.messages.filter( - (m: any) => m.sendOpts && m.sendOpts.triggerTurn === true, - ); + const confirmMsgs = pi.messages.filter((m: any) => m.sendOpts && m.sendOpts.triggerTurn === true); expect(confirmMsgs).toHaveLength(0); expect(state.active).toBe(false); @@ -395,9 +420,7 @@ describe("18.x — Auto mode: executor call order and message assertions", () => expect(resultMsg!.opts.content[0].text).toContain("/orch-integrate"); // No confirmation prompts - const confirmMsgs = pi.messages.filter( - (m: any) => m.sendOpts && m.sendOpts.triggerTurn === true, - ); + const confirmMsgs = pi.messages.filter((m: any) => m.sendOpts && m.sendOpts.triggerTurn === true); expect(confirmMsgs).toHaveLength(0); expect(state.active).toBe(false); @@ -416,7 +439,8 @@ describe("18.x — Auto mode: executor call order and message assertions", () => // Fallback message with /orch-integrate instruction expect(pi.messages.length).toBeGreaterThanOrEqual(1); const fallbackMsg = pi.messages.find( - (m: any) => m.opts.content[0].text.includes("executor unavailable") || + (m: any) => + m.opts.content[0].text.includes("executor unavailable") || m.opts.content[0].text.includes("/orch-integrate"), ); expect(fallbackMsg).toBeDefined(); @@ -454,9 +478,7 @@ describe("18.x — Auto mode: executor call order and message assertions", () => expect(progressMsg!.opts.content[0].text).toContain("CI"); // No confirmation prompts - const confirmMsgs = pi.messages.filter( - (m: any) => m.sendOpts && m.sendOpts.triggerTurn === true, - ); + const confirmMsgs = pi.messages.filter((m: any) => m.sendOpts && m.sendOpts.triggerTurn === true); expect(confirmMsgs).toHaveLength(0); }); @@ -557,9 +579,7 @@ describe("19.x — Manual-mode guidance and branch-protection-detected default-t deactivateSupervisor(pi as any, state); // Summary message sent - const summaryMsg = pi.messages.find( - (m: any) => m.opts.customType === "supervisor-batch-summary", - ); + const summaryMsg = pi.messages.find((m: any) => m.opts.customType === "supervisor-batch-summary"); expect(summaryMsg).toBeDefined(); expect(summaryMsg!.opts.content[0].text).toContain("📊 **Batch Summary**"); expect(summaryMsg!.opts.content[0].text).toContain("4/5 tasks succeeded"); @@ -569,7 +589,8 @@ describe("19.x — Manual-mode guidance and branch-protection-detected default-t // No integration-related messages (no /orch-integrate execution) const integrationMsgs = pi.messages.filter( - (m: any) => m.opts.customType === "supervisor-integration-result" || + (m: any) => + m.opts.customType === "supervisor-integration-result" || m.opts.customType === "supervisor-integration-progress", ); expect(integrationMsgs).toHaveLength(0); @@ -643,9 +664,7 @@ describe("19.x — Manual-mode guidance and branch-protection-detected default-t expect(executor.calls[0].mode).toBe("pr"); // No confirmation prompt (triggerTurn: true) - const confirmMsgs = pi.messages.filter( - (m: any) => m.sendOpts && m.sendOpts.triggerTurn === true, - ); + const confirmMsgs = pi.messages.filter((m: any) => m.sendOpts && m.sendOpts.triggerTurn === true); expect(confirmMsgs).toHaveLength(0); }); }); diff --git a/extensions/tests/auto-integration.integration.test.ts b/extensions/tests/auto-integration.integration.test.ts index e7acad33..ac04fb96 100644 --- a/extensions/tests/auto-integration.integration.test.ts +++ b/extensions/tests/auto-integration.integration.test.ts @@ -17,7 +17,15 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { expect } from "./expect.ts"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, appendFileSync } from "fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + appendFileSync, +} from "fs"; import { join, dirname } from "path"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; @@ -76,7 +84,9 @@ function makeTmpDir(): string { * Build a batch state suitable for integration testing. * Has orchBranch, baseBranch, and some succeeded tasks. */ -function makeIntegrationBatchState(overrides?: Partial): OrchBatchRuntimeState { +function makeIntegrationBatchState( + overrides?: Partial, +): OrchBatchRuntimeState { const state = freshOrchBatchState(); state.batchId = "20260322T120000"; state.baseBranch = "main"; @@ -109,9 +119,13 @@ function makeMockPi() { /** * Create a mock integration executor. */ -function makeMockExecutor( - result: { success: boolean; integratedLocally: boolean; commitCount: string; message: string; error?: string }, -): IntegrationExecutor { +function makeMockExecutor(result: { + success: boolean; + integratedLocally: boolean; + commitCount: string; + message: string; + error?: string; +}): IntegrationExecutor { const calls: Array<{ mode: string; context: any }> = []; const executor = ((mode: string, context: any) => { calls.push({ mode, context }); @@ -124,7 +138,9 @@ function makeMockExecutor( /** * Create mock CI deps. */ -function makeMockCiDeps(overrides?: Partial): CiDeps & { commandCalls: Array<{ cmd: string; args: string[] }> } { +function makeMockCiDeps( + overrides?: Partial, +): CiDeps & { commandCalls: Array<{ cmd: string; args: string[] }> } { const commandCalls: Array<{ cmd: string; args: string[] }> = []; return { commandCalls, @@ -148,7 +164,8 @@ function makeMockCiDeps(overrides?: Partial): CiDeps & { commandCalls: A */ function createLinearGitRepo(): { dir: string; orchBranch: string; baseBranch: string } { const dir = makeTmpDir(); - const run = (args: string[]) => execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + const run = (args: string[]) => + execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); run(["init", "--initial-branch=main"]); run(["config", "user.email", "test@test.com"]); @@ -177,7 +194,8 @@ function createLinearGitRepo(): { dir: string; orchBranch: string; baseBranch: s */ function createDivergedGitRepo(): { dir: string; orchBranch: string; baseBranch: string } { const dir = makeTmpDir(); - const run = (args: string[]) => execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + const run = (args: string[]) => + execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); run(["init", "--initial-branch=main"]); run(["config", "user.email", "test@test.com"]); @@ -486,9 +504,7 @@ describe("11.x — pollPrCiStatus", () => { const deps = makeMockCiDeps({ runCommand: (cmd, args) => ({ ok: true, - stdout: JSON.stringify([ - { name: "ci", state: "PENDING", conclusion: "" }, - ]), + stdout: JSON.stringify([{ name: "ci", state: "PENDING", conclusion: "" }]), stderr: "", }), }); @@ -652,7 +668,12 @@ describe("12.x — Auto mode: triggerSupervisorIntegration", () => { const executorCalls: Array<{ mode: string; context: any }> = []; const executor: IntegrationExecutor = (mode, context) => { executorCalls.push({ mode, context }); - return { success: true, integratedLocally: true, commitCount: "2", message: "Fast-forwarded 2 commits" }; + return { + success: true, + integratedLocally: true, + commitCount: "2", + message: "Fast-forwarded 2 commits", + }; }; // TP-149: test repo has no remotes → protection skipped → FF plan. @@ -694,7 +715,7 @@ describe("12.x — Auto mode: triggerSupervisorIntegration", () => { triggerSupervisorIntegration(pi as any, state, batchState, "auto", gitRepo.dir, executor); // Should have a success message containing integration outcome - const allText = pi.messages.map(m => m.opts.content[0].text).join("\n"); + const allText = pi.messages.map((m) => m.opts.content[0].text).join("\n"); // formatIntegrationOutcome for success includes "✅" and "Integration complete" expect(allText).toContain("✅"); expect(allText).toContain("Integration complete"); @@ -709,14 +730,25 @@ describe("12.x — Auto mode: triggerSupervisorIntegration", () => { const batchState = makeIntegrationBatchState({ succeededTasks: 0 }); const summaryDeps: SummaryDeps = { opId: "testop", diagnostics: null, mergeResults: [] }; - triggerSupervisorIntegration(pi as any, state, batchState, "auto", tmpDir, undefined, undefined, summaryDeps); + triggerSupervisorIntegration( + pi as any, + state, + batchState, + "auto", + tmpDir, + undefined, + undefined, + summaryDeps, + ); // Should deactivate since no plan (0 succeeded tasks) expect(state.active).toBe(false); // Should send no-integration message - expect(pi.messages.some(m => m.opts.content[0].text.includes("No integration needed"))).toBe(true); + expect(pi.messages.some((m) => m.opts.content[0].text.includes("No integration needed"))).toBe( + true, + ); // Summary should have been presented (presentBatchSummary called via summarizeAndDeactivate) - const hasSummary = pi.messages.some(m => m.opts.customType === "supervisor-batch-summary"); + const hasSummary = pi.messages.some((m) => m.opts.customType === "supervisor-batch-summary"); expect(hasSummary).toBe(true); }); }); @@ -751,7 +783,13 @@ describe("13.x — Integration conflict handling: ff → merge fallback", () => const executor: IntegrationExecutor = (mode, context) => { calls.push({ mode }); if (mode === "ff") { - return { success: false, integratedLocally: false, commitCount: "0", message: "not linear", error: "branches diverged" }; + return { + success: false, + integratedLocally: false, + commitCount: "0", + message: "not linear", + error: "branches diverged", + }; } return { success: true, integratedLocally: true, commitCount: "3", message: "Merged OK" }; }; @@ -768,10 +806,22 @@ describe("13.x — Integration conflict handling: ff → merge fallback", () => // } // } // Simulate this logic: - let result = executor("ff", { orchBranch: "o", baseBranch: "m", batchId: "b", currentBranch: "m", notices: [] }); + let result = executor("ff", { + orchBranch: "o", + baseBranch: "m", + batchId: "b", + currentBranch: "m", + notices: [], + }); expect(result.success).toBe(false); - const fallbackResult = executor("merge", { orchBranch: "o", baseBranch: "m", batchId: "b", currentBranch: "m", notices: [] }); + const fallbackResult = executor("merge", { + orchBranch: "o", + baseBranch: "m", + batchId: "b", + currentBranch: "m", + notices: [], + }); expect(fallbackResult.success).toBe(true); // Verify the calls were made in order: ff first, then merge @@ -791,9 +841,21 @@ describe("13.x — Integration conflict handling: ff → merge fallback", () => }; // Simulate the fallback logic from the source - let result = failingExecutor("ff", { orchBranch: "o", baseBranch: "m", batchId: "b", currentBranch: "m", notices: [] }); + let result = failingExecutor("ff", { + orchBranch: "o", + baseBranch: "m", + batchId: "b", + currentBranch: "m", + notices: [], + }); if (!result.success) { - const fallbackResult = failingExecutor("merge", { orchBranch: "o", baseBranch: "m", batchId: "b", currentBranch: "m", notices: [] }); + const fallbackResult = failingExecutor("merge", { + orchBranch: "o", + baseBranch: "m", + batchId: "b", + currentBranch: "m", + notices: [], + }); if (!fallbackResult.success) { // Result stays as the merge failure result = fallbackResult; @@ -922,7 +984,9 @@ describe("14.x — Supervised mode: triggerSupervisorIntegration", () => { describe("15.x — Manual/supervised/auto config type and source verification", () => { it("15.1: types.ts includes 'supervised' in integration mode type", () => { const source = readSource("types.ts"); - const line = source.split("\n").find(l => l.includes("integration") && l.includes("manual") && l.includes("auto")); + const line = source + .split("\n") + .find((l) => l.includes("integration") && l.includes("manual") && l.includes("auto")); expect(line).toBeDefined(); expect(line).toContain("supervised"); }); @@ -992,9 +1056,24 @@ describe("16.x — readTier0EventsForBatch", () => { }); it("16.2: filters only Tier 0 event types", () => { - writeEventLine(tmpDir, { timestamp: "t1", type: "tier0_recovery_attempt", batchId: "b1", pattern: "MERGE_TIMEOUT", attempt: 1, maxAttempts: 3 }); + writeEventLine(tmpDir, { + timestamp: "t1", + type: "tier0_recovery_attempt", + batchId: "b1", + pattern: "MERGE_TIMEOUT", + attempt: 1, + maxAttempts: 3, + }); writeEventLine(tmpDir, { timestamp: "t2", type: "wave_start", batchId: "b1", waveIndex: 0 }); - writeEventLine(tmpDir, { timestamp: "t3", type: "tier0_recovery_success", batchId: "b1", pattern: "MERGE_TIMEOUT", attempt: 1, maxAttempts: 3, resolution: "retried OK" }); + writeEventLine(tmpDir, { + timestamp: "t3", + type: "tier0_recovery_success", + batchId: "b1", + pattern: "MERGE_TIMEOUT", + attempt: 1, + maxAttempts: 3, + resolution: "retried OK", + }); const events = readTier0EventsForBatch(tmpDir, "b1"); expect(events).toHaveLength(2); @@ -1003,8 +1082,22 @@ describe("16.x — readTier0EventsForBatch", () => { }); it("16.3: filters by batchId", () => { - writeEventLine(tmpDir, { timestamp: "t1", type: "tier0_escalation", batchId: "batch-A", pattern: "WORKER_CRASH", attempt: 1, maxAttempts: 1 }); - writeEventLine(tmpDir, { timestamp: "t2", type: "tier0_escalation", batchId: "batch-B", pattern: "WORKER_CRASH", attempt: 1, maxAttempts: 1 }); + writeEventLine(tmpDir, { + timestamp: "t1", + type: "tier0_escalation", + batchId: "batch-A", + pattern: "WORKER_CRASH", + attempt: 1, + maxAttempts: 1, + }); + writeEventLine(tmpDir, { + timestamp: "t2", + type: "tier0_escalation", + batchId: "batch-B", + pattern: "WORKER_CRASH", + attempt: 1, + maxAttempts: 1, + }); const events = readTier0EventsForBatch(tmpDir, "batch-A"); expect(events).toHaveLength(1); @@ -1012,14 +1105,45 @@ describe("16.x — readTier0EventsForBatch", () => { }); it("16.4: includes all Tier 0 event types", () => { - writeEventLine(tmpDir, { timestamp: "t1", type: "tier0_recovery_attempt", batchId: "b1", pattern: "P", attempt: 1, maxAttempts: 3 }); - writeEventLine(tmpDir, { timestamp: "t2", type: "tier0_recovery_success", batchId: "b1", pattern: "P", attempt: 1, maxAttempts: 3, resolution: "ok" }); - writeEventLine(tmpDir, { timestamp: "t3", type: "tier0_recovery_exhausted", batchId: "b1", pattern: "P", attempt: 3, maxAttempts: 3, error: "gave up" }); - writeEventLine(tmpDir, { timestamp: "t4", type: "tier0_escalation", batchId: "b1", pattern: "P", attempt: 3, maxAttempts: 3, suggestion: "check logs" }); + writeEventLine(tmpDir, { + timestamp: "t1", + type: "tier0_recovery_attempt", + batchId: "b1", + pattern: "P", + attempt: 1, + maxAttempts: 3, + }); + writeEventLine(tmpDir, { + timestamp: "t2", + type: "tier0_recovery_success", + batchId: "b1", + pattern: "P", + attempt: 1, + maxAttempts: 3, + resolution: "ok", + }); + writeEventLine(tmpDir, { + timestamp: "t3", + type: "tier0_recovery_exhausted", + batchId: "b1", + pattern: "P", + attempt: 3, + maxAttempts: 3, + error: "gave up", + }); + writeEventLine(tmpDir, { + timestamp: "t4", + type: "tier0_escalation", + batchId: "b1", + pattern: "P", + attempt: 3, + maxAttempts: 3, + suggestion: "check logs", + }); const events = readTier0EventsForBatch(tmpDir, "b1"); expect(events).toHaveLength(4); - const types = events.map(e => e.type); + const types = events.map((e) => e.type); expect(types).toContain("tier0_recovery_attempt"); expect(types).toContain("tier0_recovery_success"); expect(types).toContain("tier0_recovery_exhausted"); @@ -1030,10 +1154,27 @@ describe("16.x — readTier0EventsForBatch", () => { const dir = join(tmpDir, ".pi", "supervisor"); mkdirSync(dir, { recursive: true }); const path = join(dir, "events.jsonl"); - writeFileSync(path, - JSON.stringify({ timestamp: "t1", type: "tier0_recovery_attempt", batchId: "b1", pattern: "P", attempt: 1, maxAttempts: 3 }) + "\n" + - "not-json\n" + - JSON.stringify({ timestamp: "t3", type: "tier0_escalation", batchId: "b1", pattern: "P", attempt: 1, maxAttempts: 1 }) + "\n", + writeFileSync( + path, + JSON.stringify({ + timestamp: "t1", + type: "tier0_recovery_attempt", + batchId: "b1", + pattern: "P", + attempt: 1, + maxAttempts: 3, + }) + + "\n" + + "not-json\n" + + JSON.stringify({ + timestamp: "t3", + type: "tier0_escalation", + batchId: "b1", + pattern: "P", + attempt: 1, + maxAttempts: 1, + }) + + "\n", "utf-8", ); @@ -1068,23 +1209,28 @@ describe("16.x — collectBatchSummaryData", () => { const batchState = makeIntegrationBatchState(); const diagnostics = { taskExits: { - "T-001": { classification: "clean", cost: 0.50, durationSec: 300 }, + "T-001": { classification: "clean", cost: 0.5, durationSec: 300 }, }, - batchCost: 2.50, + batchCost: 2.5, }; const data = collectBatchSummaryData(batchState, tmpDir, diagnostics); - expect(data.batchCost).toBe(2.50); + expect(data.batchCost).toBe(2.5); expect(data.taskExits["T-001"]).toBeDefined(); - expect(data.taskExits["T-001"].cost).toBe(0.50); + expect(data.taskExits["T-001"].cost).toBe(0.5); }); it("16.8: includes audit trail entries for the batch", () => { const batchState = makeIntegrationBatchState(); appendAuditEntry(tmpDir, { - ts: "t1", action: "merge_retry", classification: "tier0_known", - context: "wave 1 merge timeout", command: "git merge", - result: "success", detail: "ok", batchId: "20260322T120000", + ts: "t1", + action: "merge_retry", + classification: "tier0_known", + context: "wave 1 merge timeout", + command: "git merge", + result: "success", + detail: "ok", + batchId: "20260322T120000", }); const data = collectBatchSummaryData(batchState, tmpDir); @@ -1095,8 +1241,12 @@ describe("16.x — collectBatchSummaryData", () => { it("16.9: includes Tier 0 events (R003)", () => { const batchState = makeIntegrationBatchState(); writeEventLine(tmpDir, { - timestamp: "t1", type: "tier0_recovery_attempt", batchId: "20260322T120000", - pattern: "MERGE_TIMEOUT", attempt: 1, maxAttempts: 3, + timestamp: "t1", + type: "tier0_recovery_attempt", + batchId: "20260322T120000", + pattern: "MERGE_TIMEOUT", + attempt: 1, + maxAttempts: 3, }); const data = collectBatchSummaryData(batchState, tmpDir); @@ -1125,7 +1275,7 @@ describe("16.x — formatBatchSummary", () => { failedTasks: 1, skippedTasks: 0, blockedTasks: 0, - batchCost: 2.50, + batchCost: 2.5, wavePlan: [], waveResults: [], taskExits: {}, @@ -1345,7 +1495,7 @@ describe("16.x — formatBatchSummary", () => { wavePlan: [], waveResults: [], taskExits: { - "T-001": { classification: "clean", cost: 1.00, durationSec: 7200 }, + "T-001": { classification: "clean", cost: 1.0, durationSec: 7200 }, }, mergeResults: [], auditEntries: [], @@ -1370,7 +1520,7 @@ describe("16.x — formatBatchSummary", () => { failedTasks: 0, skippedTasks: 0, blockedTasks: 0, - batchCost: 3.50, + batchCost: 3.5, wavePlan: [], waveResults: [ { @@ -1384,8 +1534,8 @@ describe("16.x — formatBatchSummary", () => { }, ], taskExits: { - "T-1": { classification: "clean", cost: 1.50, durationSec: 200 }, - "T-2": { classification: "clean", cost: 2.00, durationSec: 250 }, + "T-1": { classification: "clean", cost: 1.5, durationSec: 200 }, + "T-2": { classification: "clean", cost: 2.0, durationSec: 250 }, }, mergeResults: [], auditEntries: [], @@ -1485,9 +1635,9 @@ describe("16.x — generateBatchSummary + file output", () => { const batchState = makeIntegrationBatchState(); const diagnostics = { taskExits: { - "T-001": { classification: "clean", cost: 1.50, durationSec: 300 }, + "T-001": { classification: "clean", cost: 1.5, durationSec: 300 }, }, - batchCost: 1.50, + batchCost: 1.5, }; const markdown = generateBatchSummary(batchState, tmpDir, "op1", diagnostics); @@ -1589,7 +1739,7 @@ describe("17.x — Manual mode: operator told to /orch-integrate (R006)", () => // Find the onTerminal callback that handles manual mode // The pattern: phase !== "completed" OR manual mode → presentBatchSummary + deactivateSupervisor - expect(source).toContain("presentBatchSummary(pi, orchBatchState"); + expect(source).toContainNormalized("presentBatchSummary(pi, orchBatchState"); expect(source).toContain("deactivateSupervisor(pi, supervisorState)"); // Verify manual mode does NOT call triggerSupervisorIntegration @@ -1662,7 +1812,8 @@ describe("18.x — Branch protection detected → defaults to PR mode (R006)", ( const tmpDir = makeTmpDir(); try { // Init a local-only git repo (no remote) - const run = (args: string[]) => execFileSync("git", args, { cwd: tmpDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + const run = (args: string[]) => + execFileSync("git", args, { cwd: tmpDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); run(["init", "--initial-branch=main"]); run(["config", "user.email", "test@test.com"]); run(["config", "user.name", "Test"]); @@ -1739,7 +1890,7 @@ describe("18.x — Branch protection detected → defaults to PR mode (R006)", ( expect(executorCalls[0].mode).toBe("ff"); // Messages should mention integration success - const allText = pi.messages.map(m => m.opts.content[0].text).join("\n"); + const allText = pi.messages.map((m) => m.opts.content[0].text).join("\n"); expect(allText).toContain("Integration complete"); } finally { rmSync(repo.dir, { recursive: true, force: true }); diff --git a/extensions/tests/batch-history-persistence.test.ts b/extensions/tests/batch-history-persistence.test.ts index c0841621..90c3b91a 100644 --- a/extensions/tests/batch-history-persistence.test.ts +++ b/extensions/tests/batch-history-persistence.test.ts @@ -4,11 +4,19 @@ import { join } from "path"; import { tmpdir } from "os"; import { expect } from "./expect.ts"; -import { loadBatchHistory, saveBatchHistory, updateBatchHistoryIntegration } from "../taskplane/persistence.ts"; +import { + loadBatchHistory, + saveBatchHistory, + updateBatchHistoryIntegration, +} from "../taskplane/persistence.ts"; import { withPreservedBatchHistory } from "../taskplane/extension.ts"; import type { BatchHistorySummary } from "../taskplane/types.ts"; -function makeSummary(batchId: string, status: BatchHistorySummary["status"], startedAt = 1000): BatchHistorySummary { +function makeSummary( + batchId: string, + status: BatchHistorySummary["status"], + startedAt = 1000, +): BatchHistorySummary { return { batchId, status, @@ -22,23 +30,27 @@ function makeSummary(batchId: string, status: BatchHistorySummary["status"], sta skippedTasks: 0, blockedTasks: 0, tokens: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, costUsd: 0.01 }, - tasks: [{ - taskId: "TP-137", - taskName: "TP-137", - status: status === "failed" ? "failed" : "succeeded", - wave: 1, - lane: 1, - durationMs: 500, - tokens: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, costUsd: 0.01 }, - exitReason: null, - }], - waves: [{ - wave: 1, - tasks: ["TP-137"], - mergeStatus: "succeeded", - durationMs: 500, - tokens: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, costUsd: 0.01 }, - }], + tasks: [ + { + taskId: "TP-137", + taskName: "TP-137", + status: status === "failed" ? "failed" : "succeeded", + wave: 1, + lane: 1, + durationMs: 500, + tokens: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, costUsd: 0.01 }, + exitReason: null, + }, + ], + waves: [ + { + wave: 1, + tasks: ["TP-137"], + mergeStatus: "succeeded", + durationMs: 500, + tokens: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, costUsd: 0.01 }, + }, + ], }; } @@ -84,7 +96,11 @@ describe("batch history persistence", () => { try { const result = withPreservedBatchHistory(root, () => { - writeFileSync(historyPath, JSON.stringify([makeSummary("batch-stale", "failed", 1000)], null, 2), "utf-8"); + writeFileSync( + historyPath, + JSON.stringify([makeSummary("batch-stale", "failed", 1000)], null, 2), + "utf-8", + ); return "ok"; }); @@ -141,8 +157,8 @@ describe("updateBatchHistoryIntegration (TP-179)", () => { const history = loadBatchHistory(root); expect(history).toHaveLength(2); - const entryA = history.find(e => e.batchId === "batch-A"); - const entryB = history.find(e => e.batchId === "batch-B"); + const entryA = history.find((e) => e.batchId === "batch-A"); + const entryB = history.find((e) => e.batchId === "batch-B"); expect(entryA!.integratedAt).toBe(ts); expect(entryB!.integratedAt).toBe(undefined); } finally { diff --git a/extensions/tests/cleanup-artifacts.test.ts b/extensions/tests/cleanup-artifacts.test.ts index 44f73e7d..9adf9ecf 100644 --- a/extensions/tests/cleanup-artifacts.test.ts +++ b/extensions/tests/cleanup-artifacts.test.ts @@ -68,8 +68,8 @@ describe("TP-168: Cleanup constants", () => { describe("TP-168: Age sweep covers all artifact types", () => { const now = Date.now(); - const staleTime = now - (4 * 24 * 60 * 60 * 1000); // 4 days ago (> 3 day threshold) - const freshTime = now - (1 * 24 * 60 * 60 * 1000); // 1 day ago (< 3 day threshold) + const staleTime = now - 4 * 24 * 60 * 60 * 1000; // 4 days ago (> 3 day threshold) + const freshTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago (< 3 day threshold) it("deletes stale telemetry .jsonl files", () => { const root = createTempRoot(); @@ -144,16 +144,8 @@ describe("TP-168: Age sweep covers all artifact types", () => { it("deletes stale lane-state-*.json files", () => { const root = createTempRoot(); - createFileWithMtime( - join(root, ".pi", "lane-state-batch123-lane1.json"), - "{}", - staleTime, - ); - createFileWithMtime( - join(root, ".pi", "lane-state-batch456-lane2.json"), - "{}", - freshTime, - ); + createFileWithMtime(join(root, ".pi", "lane-state-batch123-lane1.json"), "{}", staleTime); + createFileWithMtime(join(root, ".pi", "lane-state-batch456-lane2.json"), "{}", freshTime); const result = sweepStaleArtifacts(root, inactiveDeps(now)); assert.equal(result.staleFilesDeleted, 1); @@ -163,11 +155,7 @@ describe("TP-168: Age sweep covers all artifact types", () => { it("skips sweep when batch is active", () => { const root = createTempRoot(); - createFileWithMtime( - join(root, ".pi", "lane-state-batch123.json"), - "{}", - staleTime, - ); + createFileWithMtime(join(root, ".pi", "lane-state-batch123.json"), "{}", staleTime); const result = sweepStaleArtifacts(root, { isBatchActive: () => true, @@ -184,18 +172,10 @@ describe("TP-168: Age sweep covers all artifact types", () => { mkdirSync(telDir, { recursive: true }); // File just barely within threshold (2 days ago) - const withinTime = now - (2 * 24 * 60 * 60 * 1000); + const withinTime = now - 2 * 24 * 60 * 60 * 1000; createFileWithMtime(join(telDir, "recent.jsonl"), "data", withinTime); - createFileWithMtime( - join(root, ".pi", "worker-conversation-recent.jsonl"), - "[]", - withinTime, - ); - createFileWithMtime( - join(root, ".pi", "lane-state-recent.json"), - "{}", - withinTime, - ); + createFileWithMtime(join(root, ".pi", "worker-conversation-recent.jsonl"), "[]", withinTime); + createFileWithMtime(join(root, ".pi", "lane-state-recent.json"), "{}", withinTime); const result = sweepStaleArtifacts(root, inactiveDeps(now)); assert.equal(result.staleFilesDeleted, 0); @@ -296,7 +276,10 @@ describe("TP-168: Batch-start cleanup of prior batch artifacts", () => { const result = cleanupPriorBatchArtifacts(root, currentBatch); assert.equal(result.itemsDeleted, 1); - assert.ok(existsSync(join(telDir, `worker-${currentBatch}-lane1.jsonl`)), "current batch preserved"); + assert.ok( + existsSync(join(telDir, `worker-${currentBatch}-lane1.jsonl`)), + "current batch preserved", + ); assert.ok(!existsSync(join(telDir, `worker-${oldBatch}-lane1.jsonl`)), "old batch removed"); }); diff --git a/extensions/tests/cleanup-resilience.test.ts b/extensions/tests/cleanup-resilience.test.ts index c92c571f..6586db7f 100644 --- a/extensions/tests/cleanup-resilience.test.ts +++ b/extensions/tests/cleanup-resilience.test.ts @@ -16,7 +16,17 @@ */ import { execSync, spawnSync } from "child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync, unlinkSync, rmdirSync } from "fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + readdirSync, + unlinkSync, + rmdirSync, +} from "fs"; import { join, resolve, basename, dirname } from "path"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; @@ -93,7 +103,11 @@ function initTestRepo(name: string = "test-repo"): string { const repoDir = join(tempBase, name); execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test Repo\n"); @@ -102,7 +116,9 @@ function initTestRepo(name: string = "test-repo"): string { try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - } catch { /* might already be main */ } + } catch { + /* might already be main */ + } execSync("git branch develop", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); return repoDir; @@ -112,22 +128,32 @@ function cleanupTestRepo(repoDir: string): void { const parentDir = resolve(repoDir, ".."); try { const worktrees = execSync("git worktree list --porcelain", { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); for (const line of worktrees.split("\n")) { if (line.startsWith("worktree ") && !line.includes(repoDir)) { const wtPath = line.slice("worktree ".length).trim(); try { execSync(`git worktree remove --force "${wtPath}"`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } } - } catch { /* repo might already be gone */ } + } catch { + /* repo might already be gone */ + } try { rmSync(parentDir, { recursive: true, force: true }); - } catch { /* Windows may need a moment */ } + } catch { + /* Windows may need a moment */ + } } // ══════════════════════════════════════════════════════════════════════ @@ -147,21 +173,50 @@ describe("CR.1 Multi-repo cleanup — repos from earlier waves", () => { const batchId = "multi001"; // Create worktrees in repo A (simulating wave 1 allocation) - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix: prefixA, - }, repoA); - createWorktree({ - laneNumber: 2, batchId, baseBranch: "develop", opId: "test", prefix: prefixA, - }, repoA); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix: prefixA, + }, + repoA, + ); + createWorktree( + { + laneNumber: 2, + batchId, + baseBranch: "develop", + opId: "test", + prefix: prefixA, + }, + repoA, + ); // Create worktrees in repo B (simulating wave 2 allocation) - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix: prefixB, - }, repoB); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix: prefixB, + }, + repoB, + ); // Verify both repos have worktrees - assertEqual(listWorktrees(prefixA, repoA, "test", batchId).length, 2, "repo A should have 2 worktrees"); - assertEqual(listWorktrees(prefixB, repoB, "test", batchId).length, 1, "repo B should have 1 worktree"); + assertEqual( + listWorktrees(prefixA, repoA, "test", batchId).length, + 2, + "repo A should have 2 worktrees", + ); + assertEqual( + listWorktrees(prefixB, repoB, "test", batchId).length, + 1, + "repo B should have 1 worktree", + ); // Simulate terminal cleanup pattern from engine.ts: // Iterate all encountered repo roots and call removeAllWorktrees on each. @@ -175,17 +230,32 @@ describe("CR.1 Multi-repo cleanup — repos from earlier waves", () => { } // Verify BOTH repos are fully cleaned — the critical check - assertEqual(listWorktrees(prefixA, repoA, "test", batchId).length, 0, - "repo A (wave-1-only) should have 0 worktrees after terminal cleanup"); - assertEqual(listWorktrees(prefixB, repoB, "test", batchId).length, 0, - "repo B should have 0 worktrees after terminal cleanup"); + assertEqual( + listWorktrees(prefixA, repoA, "test", batchId).length, + 0, + "repo A (wave-1-only) should have 0 worktrees after terminal cleanup", + ); + assertEqual( + listWorktrees(prefixB, repoB, "test", batchId).length, + 0, + "repo B should have 0 worktrees after terminal cleanup", + ); // Verify lane branches are deleted in both repos - const branchCheckA1 = runGit(["rev-parse", "--verify", "refs/heads/task/test-lane-1-multi001"], repoA); + const branchCheckA1 = runGit( + ["rev-parse", "--verify", "refs/heads/task/test-lane-1-multi001"], + repoA, + ); assert(!branchCheckA1.ok, "repo A lane-1 branch should be deleted"); - const branchCheckA2 = runGit(["rev-parse", "--verify", "refs/heads/task/test-lane-2-multi001"], repoA); + const branchCheckA2 = runGit( + ["rev-parse", "--verify", "refs/heads/task/test-lane-2-multi001"], + repoA, + ); assert(!branchCheckA2.ok, "repo A lane-2 branch should be deleted"); - const branchCheckB1 = runGit(["rev-parse", "--verify", "refs/heads/task/test-lane-1-multi001"], repoB); + const branchCheckB1 = runGit( + ["rev-parse", "--verify", "refs/heads/task/test-lane-1-multi001"], + repoB, + ); assert(!branchCheckB1.ok, "repo B lane-1 branch should be deleted"); cleanupTestRepo(repoA); @@ -214,21 +284,41 @@ describe("CR.1 Multi-repo cleanup — repos from earlier waves", () => { const prefixA = basename(repoA); const prefixB = basename(repoB); - createWorktree({ - laneNumber: 1, batchId: "batchX", baseBranch: "develop", opId: "test", prefix: prefixA, - }, repoA); - createWorktree({ - laneNumber: 1, batchId: "batchY", baseBranch: "develop", opId: "test", prefix: prefixB, - }, repoB); + createWorktree( + { + laneNumber: 1, + batchId: "batchX", + baseBranch: "develop", + opId: "test", + prefix: prefixA, + }, + repoA, + ); + createWorktree( + { + laneNumber: 1, + batchId: "batchY", + baseBranch: "develop", + opId: "test", + prefix: prefixB, + }, + repoB, + ); // Clean only batchX removeAllWorktrees(prefixA, repoA, "test", "develop", "batchX"); // Repo A batch X cleaned, repo B batch Y untouched - assertEqual(listWorktrees(prefixA, repoA, "test", "batchX").length, 0, - "repo A batchX should be cleaned"); - assertEqual(listWorktrees(prefixB, repoB, "test", "batchY").length, 1, - "repo B batchY should be untouched"); + assertEqual( + listWorktrees(prefixA, repoA, "test", "batchX").length, + 0, + "repo A batchX should be cleaned", + ); + assertEqual( + listWorktrees(prefixB, repoB, "test", "batchY").length, + 1, + "repo B batchY should be untouched", + ); cleanupTestRepo(repoA); cleanupTestRepo(repoB); @@ -246,9 +336,16 @@ describe("CR.2 Force cleanup fallback — git worktree remove failure path", () const batchId = "force001"; // Create a worktree - const wt = createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); assert(existsSync(wt.path), "worktree should exist before corruption"); @@ -273,10 +370,14 @@ describe("CR.2 Force cleanup fallback — git worktree remove failure path", () // 3. Worktree should not be registered (after prune) const worktreeList = execSync("git worktree list --porcelain", { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); - assert(!worktreeList.includes(wt.path.replace(/\\/g, "/")), - "worktree should not be in git worktree list after force cleanup"); + assert( + !worktreeList.includes(wt.path.replace(/\\/g, "/")), + "worktree should not be in git worktree list after force cleanup", + ); cleanupTestRepo(repoDir); }); @@ -286,9 +387,16 @@ describe("CR.2 Force cleanup fallback — git worktree remove failure path", () const prefix = basename(repoDir); const batchId = "force002"; - const wt = createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // Clean up normally first removeWorktree(wt, repoDir); @@ -310,13 +418,22 @@ describe("CR.2 Force cleanup fallback — git worktree remove failure path", () const prefix = basename(repoDir); const batchId = "force003"; - const wt = createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // Simulate an orphaned worktree: prune git state but leave directory execSync(`git worktree remove --force "${wt.path}"`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); // Recreate the directory as if it was left behind mkdirSync(wt.path, { recursive: true }); @@ -344,9 +461,16 @@ describe("CR.3 .worktrees base-dir cleanup — subdirectory mode", () => { const batchId = "basedir001"; // Create worktrees in subdirectory mode (default) - const wt = createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // .worktrees dir should exist const worktreeBase = resolve(repoDir, ".worktrees"); @@ -375,9 +499,16 @@ describe("CR.3 .worktrees base-dir cleanup — subdirectory mode", () => { const batchId = "basedir002"; // Create worktrees - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // Add a leftover file to .worktrees to make it non-empty after cleanup const worktreeBase = resolve(repoDir, ".worktrees"); @@ -399,8 +530,7 @@ describe("CR.3 .worktrees base-dir cleanup — subdirectory mode", () => { orchestrator: { worktree_location: "subdirectory" as const }, } as OrchestratorConfig; const basePath = resolveWorktreeBasePath("/tmp/test-repo", subdirConfig); - assertEqual(basePath, resolve("/tmp/test-repo", ".worktrees"), - "subdirectory mode base path"); + assertEqual(basePath, resolve("/tmp/test-repo", ".worktrees"), "subdirectory mode base path"); }); test("resolveWorktreeBasePath returns parent dir for sibling mode", () => { @@ -408,8 +538,7 @@ describe("CR.3 .worktrees base-dir cleanup — subdirectory mode", () => { orchestrator: { worktree_location: "sibling" as const }, } as OrchestratorConfig; const basePath = resolveWorktreeBasePath("/tmp/parent/test-repo", siblingConfig); - assertEqual(basePath, resolve("/tmp/parent/test-repo", ".."), - "sibling mode base path"); + assertEqual(basePath, resolve("/tmp/parent/test-repo", ".."), "sibling mode base path"); }); test("sibling mode: parent directory is never removed even when no worktrees remain", () => { @@ -425,15 +554,13 @@ describe("CR.3 .worktrees base-dir cleanup — subdirectory mode", () => { const basePath = resolveWorktreeBasePath(repoDir, siblingConfig); // basePath should NOT end with ".worktrees" - assert(!basePath.endsWith(".worktrees"), - "sibling mode base path should not end with .worktrees"); + assert(!basePath.endsWith(".worktrees"), "sibling mode base path should not end with .worktrees"); // The engine.ts code gates .worktrees cleanup on basePath.endsWith(".worktrees"). // In sibling mode, this gate prevents removal of the parent directory. // Verify the gate condition: const wouldCleanup = basePath.endsWith(".worktrees"); - assertEqual(wouldCleanup, false, - "sibling mode should NOT trigger .worktrees base-dir cleanup"); + assertEqual(wouldCleanup, false, "sibling mode should NOT trigger .worktrees base-dir cleanup"); // Parent directory must still exist assert(existsSync(parentDir), "parent dir must still exist (sibling mode safety)"); @@ -457,7 +584,11 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern // Create a merge worktree manually (simulating prior merge setup) const tempBranch = `_merge-temp-${opId}-${batchId}`; - execSync(`git branch "${tempBranch}" develop`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git branch "${tempBranch}" develop`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const subdirConfig = { orchestrator: { worktree_location: "subdirectory" as const }, @@ -465,7 +596,9 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern const mergeWorkDir = generateMergeWorktreePath(repoDir, opId, batchId, subdirConfig); mkdirSync(resolve(mergeWorkDir, ".."), { recursive: true }); - const addResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { cwd: repoDir }); + const addResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { + cwd: repoDir, + }); assertEqual(addResult.status, 0, "worktree add should succeed"); assert(existsSync(mergeWorkDir), "merge worktree should exist"); @@ -477,7 +610,9 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern // Apply the same pattern merge.ts uses: force remove + rm + prune // This replicates forceRemoveMergeWorktree's behavior - const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoDir }); + const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { + cwd: repoDir, + }); if (removeResult.status !== 0) { // Fallback: rm -rf + prune rmSync(mergeWorkDir, { recursive: true, force: true }); @@ -490,12 +625,14 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern // Verify the merge worktree is no longer registered in git const wtList = execSync("git worktree list --porcelain", { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); // Check no worktree line references the merge directory const normalizedMergeDir = mergeWorkDir.replace(/\\/g, "/"); - const wtLines = wtList.split("\n").filter(l => l.startsWith("worktree ")); - const hasMergeWorktree = wtLines.some(l => { + const wtLines = wtList.split("\n").filter((l) => l.startsWith("worktree ")); + const hasMergeWorktree = wtLines.some((l) => { const wtPath = l.slice("worktree ".length).trim().replace(/\\/g, "/"); return wtPath === normalizedMergeDir; }); @@ -504,7 +641,9 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern // Clean up temp branch try { execSync(`git branch -D "${tempBranch}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - } catch { /* may already be gone */ } + } catch { + /* may already be gone */ + } cleanupTestRepo(repoDir); }); @@ -519,7 +658,11 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern // Create the merge worktree const tempBranch = `_merge-temp-${opId}-${batchId}`; - execSync(`git branch "${tempBranch}" develop`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git branch "${tempBranch}" develop`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const subdirConfig = { orchestrator: { worktree_location: "subdirectory" as const }, @@ -527,7 +670,9 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern const mergeWorkDir = generateMergeWorktreePath(repoDir, opId, batchId, subdirConfig); mkdirSync(resolve(mergeWorkDir, ".."), { recursive: true }); - const addResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { cwd: repoDir }); + const addResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { + cwd: repoDir, + }); assertEqual(addResult.status, 0, "worktree add should succeed"); // Simulate a "locked" worktree by creating a .git/worktrees/*/locked file @@ -543,7 +688,9 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern } // Apply the merge.ts end-of-wave cleanup pattern - const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoDir }); + const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { + cwd: repoDir, + }); if (removeResult.status !== 0) { // Fallback: rm -rf + prune rmSync(mergeWorkDir, { recursive: true, force: true }); @@ -565,7 +712,9 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern // Clean up temp branch try { execSync(`git branch -D "${tempBranch}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - } catch { /* may already be gone */ } + } catch { + /* may already be gone */ + } cleanupTestRepo(repoDir); }); @@ -573,24 +722,25 @@ describe("CR.4 Merge worktree force cleanup — forceRemoveMergeWorktree pattern test("merge.ts callsites use forceRemoveMergeWorktree at both stale-prep and end-of-wave", () => { // Structural verification that merge.ts calls forceRemoveMergeWorktree // at both required locations (stale-prep and end-of-wave). - const mergeSource = readFileSync( - resolve(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(resolve(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Stale-prep cleanup (before creating new merge worktree) const stalePrepMatch = mergeSource.match( /Clean up stale merge worktree[\s\S]*?forceRemoveMergeWorktree/, ); - assert(stalePrepMatch !== null, - "merge.ts should call forceRemoveMergeWorktree for stale-prep cleanup"); + assert( + stalePrepMatch !== null, + "merge.ts should call forceRemoveMergeWorktree for stale-prep cleanup", + ); // End-of-wave cleanup (after all lane merges complete) const endOfWaveMatch = mergeSource.match( /Clean up merge worktree and temp branch[\s\S]*?forceRemoveMergeWorktree/, ); - assert(endOfWaveMatch !== null, - "merge.ts should call forceRemoveMergeWorktree for end-of-wave cleanup"); + assert( + endOfWaveMatch !== null, + "merge.ts should call forceRemoveMergeWorktree for end-of-wave cleanup", + ); }); }); @@ -614,15 +764,30 @@ describe("CR.5 Engine-level multi-repo cleanup — behavioral verification", () const batchId = "engine001"; // Repo A: wave 1 only (2 lanes) - createWorktree({ laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix: prefixA }, repoA); - createWorktree({ laneNumber: 2, batchId, baseBranch: "develop", opId: "test", prefix: prefixA }, repoA); + createWorktree( + { laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix: prefixA }, + repoA, + ); + createWorktree( + { laneNumber: 2, batchId, baseBranch: "develop", opId: "test", prefix: prefixA }, + repoA, + ); // Repo B: wave 1 + wave 2 (2 lanes total) - createWorktree({ laneNumber: 3, batchId, baseBranch: "develop", opId: "test", prefix: prefixB }, repoB); - createWorktree({ laneNumber: 4, batchId, baseBranch: "develop", opId: "test", prefix: prefixB }, repoB); + createWorktree( + { laneNumber: 3, batchId, baseBranch: "develop", opId: "test", prefix: prefixB }, + repoB, + ); + createWorktree( + { laneNumber: 4, batchId, baseBranch: "develop", opId: "test", prefix: prefixB }, + repoB, + ); // Repo C: wave 2 only (1 lane) - createWorktree({ laneNumber: 5, batchId, baseBranch: "develop", opId: "test", prefix: prefixC }, repoC); + createWorktree( + { laneNumber: 5, batchId, baseBranch: "develop", opId: "test", prefix: prefixC }, + repoC, + ); // Verify all repos have worktrees assertEqual(listWorktrees(prefixA, repoA, "test", batchId).length, 2, "repo A initial"); @@ -638,20 +803,15 @@ describe("CR.5 Engine-level multi-repo cleanup — behavioral verification", () // 2. For each repo: resolve prefix/target and call removeAllWorktrees for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) { - const prefix = perRepoRoot === repoA ? prefixA - : perRepoRoot === repoB ? prefixB - : prefixC; + const prefix = perRepoRoot === repoA ? prefixA : perRepoRoot === repoB ? prefixB : prefixC; removeAllWorktrees(prefix, perRepoRoot, "test", "develop", batchId); } // 3. Verify ALL repos are fully cleaned (no worktrees, no lane branches) for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) { - const prefix = perRepoRoot === repoA ? prefixA - : perRepoRoot === repoB ? prefixB - : prefixC; + const prefix = perRepoRoot === repoA ? prefixA : perRepoRoot === repoB ? prefixB : prefixC; const remaining = listWorktrees(prefix, perRepoRoot, "test", batchId); - assertEqual(remaining.length, 0, - `${perRepoId} should have 0 worktrees after terminal cleanup`); + assertEqual(remaining.length, 0, `${perRepoId} should have 0 worktrees after terminal cleanup`); } // 4. Verify lane branches are deleted in ALL repos @@ -672,27 +832,29 @@ describe("CR.5 Engine-level multi-repo cleanup — behavioral verification", () test("engine.ts terminal cleanup delegates .worktrees cleanup to removeAllWorktrees", () => { // Structural verification: engine.ts should NOT have its own .worktrees // base-dir cleanup loop — removeAllWorktrees owns that responsibility. - const engineSource = readFileSync( - resolve(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(resolve(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // engine.ts should have a comment indicating delegation, not a readdirSync/rmdirSync loop const hasDelegationComment = engineSource.includes( "Empty .worktrees base-dir cleanup (subdirectory mode) is handled", ); - assert(hasDelegationComment, - "engine.ts should have delegation comment for .worktrees cleanup"); + assert(hasDelegationComment, "engine.ts should have delegation comment for .worktrees cleanup"); // engine.ts should NOT import rmdirSync (it was removed as part of dedup) const hasRmdirImport = /import.*rmdirSync.*from\s+"fs"/.test(engineSource); - assertEqual(hasRmdirImport, false, - "engine.ts should not import rmdirSync (cleanup delegated to removeAllWorktrees)"); + assertEqual( + hasRmdirImport, + false, + "engine.ts should not import rmdirSync (cleanup delegated to removeAllWorktrees)", + ); // engine.ts should NOT import resolveWorktreeBasePath (no longer needed) const hasResolveImport = /import.*resolveWorktreeBasePath.*from.*worktree/.test(engineSource); - assertEqual(hasResolveImport, false, - "engine.ts should not import resolveWorktreeBasePath (cleanup delegated)"); + assertEqual( + hasResolveImport, + false, + "engine.ts should not import resolveWorktreeBasePath (cleanup delegated)", + ); }); test("removeAllWorktrees handles .worktrees cleanup in subdirectory mode when config passed", () => { @@ -707,9 +869,16 @@ describe("CR.5 Engine-level multi-repo cleanup — behavioral verification", () } as OrchestratorConfig; // Create a worktree (creates .worktrees dir) - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); const worktreeBase = resolve(repoDir, ".worktrees"); assert(existsSync(worktreeBase), ".worktrees should exist after creating worktree"); @@ -718,8 +887,10 @@ describe("CR.5 Engine-level multi-repo cleanup — behavioral verification", () removeAllWorktrees(prefix, repoDir, "test", "develop", batchId, subdirConfig); // .worktrees should be gone (removeAllWorktrees handles it when config is passed) - assert(!existsSync(worktreeBase), - ".worktrees dir should be removed by removeAllWorktrees when empty and config is passed"); + assert( + !existsSync(worktreeBase), + ".worktrees dir should be removed by removeAllWorktrees when empty and config is passed", + ); cleanupTestRepo(repoDir); }); @@ -731,11 +902,13 @@ describe("CR.5 Engine-level multi-repo cleanup — behavioral verification", () describe("CR.6 Cleanup gate policy — computeCleanupGatePolicy", () => { test("single-repo failure produces correct policy result", () => { - const failures: CleanupGateRepoFailure[] = [{ - repoRoot: "/repos/api", - repoId: "api", - staleWorktrees: ["/repos/api/.worktrees/lane-1", "/repos/api/.worktrees/lane-2"], - }]; + const failures: CleanupGateRepoFailure[] = [ + { + repoRoot: "/repos/api", + repoId: "api", + staleWorktrees: ["/repos/api/.worktrees/lane-1", "/repos/api/.worktrees/lane-2"], + }, + ]; const result = computeCleanupGatePolicy(2, failures); // waveIndex=2 → wave 3 @@ -747,16 +920,34 @@ describe("CR.6 Cleanup gate policy — computeCleanupGatePolicy", () => { // Error message includes wave number, stale count, and repo detail assert(result.errorMessage.includes("wave 3"), "errorMessage should include wave number"); - assert(result.errorMessage.includes("2 stale worktree(s)"), "errorMessage should include stale count"); + assert( + result.errorMessage.includes("2 stale worktree(s)"), + "errorMessage should include stale count", + ); assert(result.errorMessage.includes("1 repo(s)"), "errorMessage should include repo count"); assert(result.errorMessage.includes("api"), "errorMessage should include repo ID"); - assert(result.errorMessage.includes("/orch-resume"), "errorMessage should include recovery command"); + assert( + result.errorMessage.includes("/orch-resume"), + "errorMessage should include recovery command", + ); // Notification includes manual recovery commands - assert(result.notifyMessage.includes("git worktree remove"), "notifyMessage should include manual cleanup command"); - assert(result.notifyMessage.includes("/orch-resume"), "notifyMessage should include resume command"); - assert(result.notifyMessage.includes("lane-1"), "notifyMessage should include stale worktree paths"); - assert(result.notifyMessage.includes("lane-2"), "notifyMessage should include stale worktree paths"); + assert( + result.notifyMessage.includes("git worktree remove"), + "notifyMessage should include manual cleanup command", + ); + assert( + result.notifyMessage.includes("/orch-resume"), + "notifyMessage should include resume command", + ); + assert( + result.notifyMessage.includes("lane-1"), + "notifyMessage should include stale worktree paths", + ); + assert( + result.notifyMessage.includes("lane-2"), + "notifyMessage should include stale worktree paths", + ); // Log details assertEqual(result.logDetails.waveNumber, 3, "logDetails.waveNumber"); @@ -791,11 +982,13 @@ describe("CR.6 Cleanup gate policy — computeCleanupGatePolicy", () => { }); test("default repoId renders as (default) in messages", () => { - const failures: CleanupGateRepoFailure[] = [{ - repoRoot: "/repos/main", - repoId: undefined, - staleWorktrees: ["/repos/main/.worktrees/lane-1"], - }]; + const failures: CleanupGateRepoFailure[] = [ + { + repoRoot: "/repos/main", + repoId: undefined, + staleWorktrees: ["/repos/main/.worktrees/lane-1"], + }, + ]; const result = computeCleanupGatePolicy(0, failures); @@ -817,12 +1010,26 @@ describe("CR.6 Cleanup gate — behavioral: stale worktrees block wave advance", const batchId = "gate001"; // Create worktrees in both repos - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix: prefixA, - }, repoA); - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix: prefixB, - }, repoB); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix: prefixA, + }, + repoA, + ); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix: prefixB, + }, + repoB, + ); // Clean only repo A (simulating successful reset) removeAllWorktrees(prefixA, repoA, "test", "develop", batchId); @@ -840,7 +1047,7 @@ describe("CR.6 Cleanup gate — behavioral: stale worktrees block wave advance", cleanupGateFailures.push({ repoRoot: perRepoRoot, repoId: perRepoId, - staleWorktrees: remaining.map(wt => wt.path), + staleWorktrees: remaining.map((wt) => wt.path), }); } } @@ -865,12 +1072,26 @@ describe("CR.6 Cleanup gate — behavioral: stale worktrees block wave advance", const batchId = "gate002"; // Create and then clean worktrees - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repo); - createWorktree({ - laneNumber: 2, batchId, baseBranch: "develop", opId: "test", prefix, - }, repo); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repo, + ); + createWorktree( + { + laneNumber: 2, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repo, + ); removeAllWorktrees(prefix, repo, "test", "develop", batchId); @@ -905,12 +1126,26 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat const batchId = "gater001"; // Create worktrees (simulating wave 1 allocation) - const wt1 = createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); - const wt2 = createWorktree({ - laneNumber: 2, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + const wt1 = createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); + const wt2 = createWorktree( + { + laneNumber: 2, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // Successfully reset both worktrees (simulating inter-wave reset) const reset1 = safeResetWorktree(wt1, "develop", repoDir); @@ -932,8 +1167,8 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat // This block is never entered — all resets succeeded for (const [perRepoRoot, { repoId, paths: failedPaths }] of failedRemovalWorktrees) { const rem = listWorktrees(prefix, perRepoRoot, "test", batchId); - const remPaths = new Set(rem.map(wt => wt.path)); - const stale = failedPaths.filter(p => remPaths.has(p)); + const remPaths = new Set(rem.map((wt) => wt.path)); + const stale = failedPaths.filter((p) => remPaths.has(p)); if (stale.length > 0) { cleanupGateFailures.push({ repoRoot: perRepoRoot, repoId, staleWorktrees: stale }); } @@ -941,8 +1176,11 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat } // Gate should NOT fire — no failures to report - assertEqual(cleanupGateFailures.length, 0, - "cleanup gate should NOT fire after successful resets (worktrees are reusable)"); + assertEqual( + cleanupGateFailures.length, + 0, + "cleanup gate should NOT fire after successful resets (worktrees are reusable)", + ); cleanupTestRepo(repoDir); }); @@ -961,18 +1199,32 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat const prefix = basename(repoDir); const batchId = "gater002"; - createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); - createWorktree({ - laneNumber: 2, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); + createWorktree( + { + laneNumber: 2, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // Get the listed worktree info (production code uses this, not createWorktree return) const listed = listWorktrees(prefix, repoDir, "test", batchId); assertEqual(listed.length, 2, "should have 2 worktrees initially"); - const wt1Listed = listed.find(w => w.laneNumber === 1)!; - const wt2Listed = listed.find(w => w.laneNumber === 2)!; + const wt1Listed = listed.find((w) => w.laneNumber === 1)!; + const wt2Listed = listed.find((w) => w.laneNumber === 2)!; // Reset wt1 successfully (simulating normal inter-wave behavior) const reset1 = safeResetWorktree(wt1Listed, "develop", repoDir); @@ -988,8 +1240,8 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat const cleanupGateFailures: CleanupGateRepoFailure[] = []; for (const [perRepoRoot, { repoId, paths: failedPaths }] of failedRemovalWorktrees) { const remaining = listWorktrees(prefix, perRepoRoot, "test", batchId); - const remainingPaths = new Set(remaining.map(wt => wt.path)); - const stale = failedPaths.filter(p => remainingPaths.has(p)); + const remainingPaths = new Set(remaining.map((wt) => wt.path)); + const stale = failedPaths.filter((p) => remainingPaths.has(p)); if (stale.length > 0) { cleanupGateFailures.push({ repoRoot: perRepoRoot, repoId, staleWorktrees: stale }); } @@ -998,8 +1250,11 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat // Gate should fire, but only for wt2 assertEqual(cleanupGateFailures.length, 1, "should detect 1 repo with stale worktrees"); assertEqual(cleanupGateFailures[0].staleWorktrees.length, 1, "only 1 stale worktree"); - assertEqual(cleanupGateFailures[0].staleWorktrees[0], wt2Listed.path, - "stale worktree should be wt2 (the one that failed removal)"); + assertEqual( + cleanupGateFailures[0].staleWorktrees[0], + wt2Listed.path, + "stale worktree should be wt2 (the one that failed removal)", + ); cleanupTestRepo(repoDir); }); @@ -1011,9 +1266,16 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat const prefix = basename(repoDir); const batchId = "gater003"; - const wt = createWorktree({ - laneNumber: 1, batchId, baseBranch: "develop", opId: "test", prefix, - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId, + baseBranch: "develop", + opId: "test", + prefix, + }, + repoDir, + ); // Force-cleanup the worktree (simulating reset fail → remove fail → force cleanup) forceCleanupWorktree(wt, repoDir, batchId); @@ -1026,31 +1288,39 @@ describe("CR.7 Cleanup gate regression — successful reset does NOT trigger gat const cleanupGateFailures: CleanupGateRepoFailure[] = []; for (const [perRepoRoot, { repoId, paths: failedPaths }] of failedRemovalWorktrees) { const remaining = listWorktrees(prefix, perRepoRoot, "test", batchId); - const remainingPaths = new Set(remaining.map(wt => wt.path)); - const stale = failedPaths.filter(p => remainingPaths.has(p)); + const remainingPaths = new Set(remaining.map((wt) => wt.path)); + const stale = failedPaths.filter((p) => remainingPaths.has(p)); if (stale.length > 0) { cleanupGateFailures.push({ repoRoot: perRepoRoot, repoId, staleWorktrees: stale }); } } // Gate should NOT fire — forceCleanup successfully removed the worktree - assertEqual(cleanupGateFailures.length, 0, - "cleanup gate should not fire when forceCleanup actually succeeded"); + assertEqual( + cleanupGateFailures.length, + 0, + "cleanup gate should not fire when forceCleanup actually succeeded", + ); cleanupTestRepo(repoDir); }); test("persistTrigger uses underscore format (cleanup_post_merge_failed)", () => { // Verify the canonical classification token uses underscore per spec - const failures: CleanupGateRepoFailure[] = [{ - repoRoot: "/repos/main", - repoId: undefined, - staleWorktrees: ["/repos/main/.worktrees/lane-1"], - }]; + const failures: CleanupGateRepoFailure[] = [ + { + repoRoot: "/repos/main", + repoId: undefined, + staleWorktrees: ["/repos/main/.worktrees/lane-1"], + }, + ]; const result = computeCleanupGatePolicy(0, failures); - assertEqual(result.persistTrigger, "cleanup_post_merge_failed", - "persistTrigger should use underscore format matching spec classification"); + assertEqual( + result.persistTrigger, + "cleanup_post_merge_failed", + "persistTrigger should use underscore format matching spec classification", + ); }); }); diff --git a/extensions/tests/cli-doctor-version-capture.test.ts b/extensions/tests/cli-doctor-version-capture.test.ts index 0a8f69e6..b4d686d8 100644 --- a/extensions/tests/cli-doctor-version-capture.test.ts +++ b/extensions/tests/cli-doctor-version-capture.test.ts @@ -34,18 +34,12 @@ const NODE = process.execPath; describe("TP-189-C — getVersion() behavioral capture (success cases)", () => { it("returns trimmed stdout when the command writes its version to stdout", () => { - const result = getVersion( - `"${NODE}" -e "process.stdout.write('1.2.3')"`, - "", - ); + const result = getVersion(`"${NODE}" -e "process.stdout.write('1.2.3')"`, ""); assert.strictEqual(result, "1.2.3"); }); it("falls back to stderr when stdout is empty (the pi --version case)", () => { - const result = getVersion( - `"${NODE}" -e "process.stderr.write('0.73.0')"`, - "", - ); + const result = getVersion(`"${NODE}" -e "process.stderr.write('0.73.0')"`, ""); assert.strictEqual(result, "0.73.0"); }); @@ -58,10 +52,7 @@ describe("TP-189-C — getVersion() behavioral capture (success cases)", () => { }); it("trims surrounding whitespace from the captured stream", () => { - const result = getVersion( - `"${NODE}" -e "process.stdout.write(' v9.9.9 \\n')"`, - "", - ); + const result = getVersion(`"${NODE}" -e "process.stdout.write(' v9.9.9 \\n')"`, ""); assert.strictEqual(result, "v9.9.9"); }); }); @@ -73,15 +64,8 @@ describe("TP-189-C — getVersion() fail-safe contract (R008 follow-up)", () => // `command not found`-style error prose as a fake version. The // guard `if (result.error || result.status !== 0) return null;` // preserves the prior execSync-throws-on-failure contract. - const result = getVersion( - `"${NODE}" -e "process.stderr.write('boom'); process.exit(1)"`, - "", - ); - assert.strictEqual( - result, - null, - "non-zero exit must return null, not the stderr error text", - ); + const result = getVersion(`"${NODE}" -e "process.stderr.write('boom'); process.exit(1)"`, ""); + assert.strictEqual(result, null, "non-zero exit must return null, not the stderr error text"); }); it("returns null for a guaranteed-nonexistent command", () => { diff --git a/extensions/tests/context-pressure-cache.test.ts b/extensions/tests/context-pressure-cache.test.ts index 23cad2c2..cda13dbb 100644 --- a/extensions/tests/context-pressure-cache.test.ts +++ b/extensions/tests/context-pressure-cache.test.ts @@ -17,10 +17,7 @@ import { mkdirSync, writeFileSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import { - tailSidecarJsonl, - createSidecarTailState, -} from "../taskplane/sidecar-telemetry.ts"; +import { tailSidecarJsonl, createSidecarTailState } from "../taskplane/sidecar-telemetry.ts"; import type { SidecarTailState, SidecarTelemetryDelta } from "../taskplane/sidecar-telemetry.ts"; // ── Helpers ────────────────────────────────────────────────────────── @@ -35,7 +32,9 @@ beforeEach(() => { }); afterEach(() => { - try { rmSync(testRoot, { recursive: true, force: true }); } catch {} + try { + rmSync(testRoot, { recursive: true, force: true }); + } catch {} }); function sidecarPath(): string { @@ -45,7 +44,7 @@ function sidecarPath(): string { /** Write one or more JSONL events to a file. */ function writeSidecarEvents(path: string, events: object[]): void { - const content = events.map(e => JSON.stringify(e)).join("\n") + "\n"; + const content = events.map((e) => JSON.stringify(e)).join("\n") + "\n"; writeFileSync(path, content, "utf-8"); } @@ -67,12 +66,9 @@ function messageEnd(usage: { // ── 1. latestTotalTokens includes cacheRead ────────────────────────── describe("tailSidecarJsonl — cache-inclusive latestTotalTokens", () => { - it("1.1 — cacheRead is added to latestTotalTokens (fallback branch: input+output)", () => { const path = sidecarPath(); - writeSidecarEvents(path, [ - messageEnd({ input: 10_000, output: 5_000, cacheRead: 180_000 }), - ]); + writeSidecarEvents(path, [messageEnd({ input: 10_000, output: 5_000, cacheRead: 180_000 })]); const state = createSidecarTailState(); const delta = tailSidecarJsonl(path, state); @@ -98,9 +94,7 @@ describe("tailSidecarJsonl — cache-inclusive latestTotalTokens", () => { it("1.3 — zero cacheRead does not affect calculation", () => { const path = sidecarPath(); - writeSidecarEvents(path, [ - messageEnd({ input: 50_000, output: 30_000, cacheRead: 0 }), - ]); + writeSidecarEvents(path, [messageEnd({ input: 50_000, output: 30_000, cacheRead: 0 })]); const state = createSidecarTailState(); const delta = tailSidecarJsonl(path, state); @@ -110,9 +104,7 @@ describe("tailSidecarJsonl — cache-inclusive latestTotalTokens", () => { it("1.4 — missing cacheRead does not affect calculation", () => { const path = sidecarPath(); - writeSidecarEvents(path, [ - messageEnd({ input: 50_000, output: 30_000 }), - ]); + writeSidecarEvents(path, [messageEnd({ input: 50_000, output: 30_000 })]); const state = createSidecarTailState(); const delta = tailSidecarJsonl(path, state); @@ -147,9 +139,7 @@ describe("context pressure thresholds — cache-heavy workloads", () => { it("2.1 — cache-heavy workload triggers 85% threshold", () => { const path = sidecarPath(); // 170K total = 85% of 200K context window - writeSidecarEvents(path, [ - messageEnd({ input: 5_000, output: 5_000, cacheRead: 160_000 }), - ]); + writeSidecarEvents(path, [messageEnd({ input: 5_000, output: 5_000, cacheRead: 160_000 })]); const state = createSidecarTailState(); const delta = tailSidecarJsonl(path, state); @@ -163,9 +153,7 @@ describe("context pressure thresholds — cache-heavy workloads", () => { it("2.2 — cache-heavy workload triggers 95% threshold", () => { const path = sidecarPath(); // 190K total = 95% of 200K context window - writeSidecarEvents(path, [ - messageEnd({ input: 5_000, output: 5_000, cacheRead: 180_000 }), - ]); + writeSidecarEvents(path, [messageEnd({ input: 5_000, output: 5_000, cacheRead: 180_000 })]); const state = createSidecarTailState(); const delta = tailSidecarJsonl(path, state); @@ -199,9 +187,7 @@ describe("context pressure thresholds — cache-heavy workloads", () => { it("2.4 — small workload (no cache) stays under threshold", () => { const path = sidecarPath(); - writeSidecarEvents(path, [ - messageEnd({ input: 20_000, output: 10_000 }), - ]); + writeSidecarEvents(path, [messageEnd({ input: 20_000, output: 10_000 })]); const state = createSidecarTailState(); const delta = tailSidecarJsonl(path, state); diff --git a/extensions/tests/context-window-autodetect.test.ts b/extensions/tests/context-window-autodetect.test.ts index 1dbc2854..b1bf198f 100644 --- a/extensions/tests/context-window-autodetect.test.ts +++ b/extensions/tests/context-window-autodetect.test.ts @@ -11,23 +11,11 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { expect } from "./expect.ts"; -import { - resolveContextWindow, - FALLBACK_CONTEXT_WINDOW, -} from "../taskplane/context-window.ts"; +import { resolveContextWindow, FALLBACK_CONTEXT_WINDOW } from "../taskplane/context-window.ts"; import { loadConfig as taskRunnerLoadConfig } from "../taskplane/config-loader.ts"; -import { - loadProjectConfig, - toTaskConfig, -} from "../taskplane/config-loader.ts"; -import { - DEFAULT_TASK_RUNNER_SECTION, -} from "../taskplane/config-schema.ts"; -import { - mkdirSync, - writeFileSync, - rmSync, -} from "fs"; +import { loadProjectConfig, toTaskConfig } from "../taskplane/config-loader.ts"; +import { DEFAULT_TASK_RUNNER_SECTION } from "../taskplane/config-schema.ts"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -70,11 +58,13 @@ function writeJsonConfig(root: string, obj: any): void { } /** Create a minimal TaskConfig with overridable context values. */ -function makeConfig(overrides?: Partial<{ - worker_context_window: number; - warn_percent: number; - kill_percent: number; -}>): any { +function makeConfig( + overrides?: Partial<{ + worker_context_window: number; + warn_percent: number; + kill_percent: number; + }>, +): any { return { project: { name: "Test", description: "" }, paths: { tasks: "tasks" }, @@ -103,11 +93,7 @@ function makeConfig(overrides?: Partial<{ } /** Create a mock ExtensionContext with optional model info. */ -function makeCtx(model?: { - contextWindow?: number; - provider?: string; - id?: string; -}): any { +function makeCtx(model?: { contextWindow?: number; provider?: string; id?: string }): any { if (!model) { return { model: undefined }; } @@ -237,11 +223,7 @@ describe("warn_percent and kill_percent defaults", () => { it("2.6: explicit YAML overrides for warn/kill are still respected", () => { const dir = makeTestDir("explicit-warn-kill"); - writeTaskRunnerYaml(dir, [ - "context:", - " warn_percent: 60", - " kill_percent: 80", - ].join("\n")); + writeTaskRunnerYaml(dir, ["context:", " warn_percent: 60", " kill_percent: 80"].join("\n")); const config = loadProjectConfig(dir); expect(config.taskRunner.context.warnPercent).toBe(60); @@ -303,10 +285,7 @@ describe("workerContextWindow default signals auto-detect", () => { it("3.5: explicit worker_context_window in YAML config is preserved", () => { const dir = makeTestDir("cw-explicit-yaml"); - writeTaskRunnerYaml(dir, [ - "context:", - " worker_context_window: 400000", - ].join("\n")); + writeTaskRunnerYaml(dir, ["context:", " worker_context_window: 400000"].join("\n")); const config = loadProjectConfig(dir); expect(config.taskRunner.context.workerContextWindow).toBe(400_000); diff --git a/extensions/tests/context-window-resolution.test.ts b/extensions/tests/context-window-resolution.test.ts index 3f9b4111..7bd4c85b 100644 --- a/extensions/tests/context-window-resolution.test.ts +++ b/extensions/tests/context-window-resolution.test.ts @@ -11,15 +11,10 @@ import { describe, it } from "node:test"; import { expect } from "./expect.ts"; -import { - resolveContextWindow, - FALLBACK_CONTEXT_WINDOW, -} from "../taskplane/context-window.ts"; +import { resolveContextWindow, FALLBACK_CONTEXT_WINDOW } from "../taskplane/context-window.ts"; import { loadConfig } from "../taskplane/config-loader.ts"; -import { - DEFAULT_TASK_RUNNER_SECTION, -} from "../taskplane/config-schema.ts"; +import { DEFAULT_TASK_RUNNER_SECTION } from "../taskplane/config-schema.ts"; // ── Helpers ────────────────────────────────────────────────────────── diff --git a/extensions/tests/conversation-event-fidelity.test.ts b/extensions/tests/conversation-event-fidelity.test.ts index 0eadc6c7..ae44f79d 100644 --- a/extensions/tests/conversation-event-fidelity.test.ts +++ b/extensions/tests/conversation-event-fidelity.test.ts @@ -18,7 +18,10 @@ import { EventEmitter } from "events"; const __dirname = dirname(fileURLToPath(import.meta.url)); const agentHostSrc = readFileSync(join(__dirname, "..", "taskplane", "agent-host.ts"), "utf-8"); -const dashboardAppSrc = readFileSync(join(__dirname, "..", "..", "dashboard", "public", "app.js"), "utf-8"); +const dashboardAppSrc = readFileSync( + join(__dirname, "..", "..", "dashboard", "public", "app.js"), + "utf-8", +); type RuntimeAgentEvent = import("../taskplane/types.ts").RuntimeAgentEvent; @@ -39,7 +42,7 @@ let lastSpawnedProc: FakeChildProc | null = null; let onStdinWrite: ((chunk: string) => void) | null = null; const realChildProcess = await import("node:child_process"); -const mockSpawnSync = mock.fn(() => ({ stdout: "", stderr: "", status: 0 } as any)); +const mockSpawnSync = mock.fn(() => ({ stdout: "", stderr: "", status: 0 }) as any); const mockSpawn = mock.fn((_cmd: string, _args?: readonly string[], _opts?: any) => { const proc = new EventEmitter() as FakeChildProc; proc.stdout = new PassThrough(); @@ -82,7 +85,14 @@ beforeEach(() => { lastSpawnedProc = null; onStdinWrite = null; fakeAppDataRoot = mkdtempSync(join(tmpdir(), "tp111-agent-host-")); - const fakeCliDir = join(fakeAppDataRoot, "npm", "node_modules", "@mariozechner", "pi-coding-agent", "dist"); + const fakeCliDir = join( + fakeAppDataRoot, + "npm", + "node_modules", + "@mariozechner", + "pi-coding-agent", + "dist", + ); mkdirSync(fakeCliDir, { recursive: true }); writeFileSync(join(fakeCliDir, "cli.js"), "// fake cli for tests\n", "utf-8"); process.env.APPDATA = fakeAppDataRoot; @@ -91,7 +101,11 @@ beforeEach(() => { afterEach(() => { process.env.APPDATA = originalAppData; if (fakeAppDataRoot) { - try { rmSync(fakeAppDataRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(fakeAppDataRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } } lastSpawnedProc = null; onStdinWrite = null; @@ -230,29 +244,34 @@ describe("5.x: Runtime behavioral emission (TP-111)", () => { if (chunk.includes('"type":"prompt"')) timeline.push("prompt_write"); }; - const { promise } = spawnAgent({ - agentId: "orch-test-lane-1-worker", - role: "worker", - batchId: "batch-tp111", - laneNumber: 1, - taskId: "TP-111", - repoId: "default", - cwd: process.cwd(), - prompt: "P".repeat(2200), - mailboxDir: null, - stateRoot: null, - }, (evt) => { - events.push(evt); - timeline.push(`event:${evt.type}`); - }); + const { promise } = spawnAgent( + { + agentId: "orch-test-lane-1-worker", + role: "worker", + batchId: "batch-tp111", + laneNumber: 1, + taskId: "TP-111", + repoId: "default", + cwd: process.cwd(), + prompt: "P".repeat(2200), + mailboxDir: null, + stateRoot: null, + }, + (evt) => { + events.push(evt); + timeline.push(`event:${evt.type}`); + }, + ); expect(mockSpawn).toHaveBeenCalledTimes(1); expect(lastSpawnedProc).toBeDefined(); - lastSpawnedProc!.stdout.write(JSON.stringify({ - type: "message_end", - message: { role: "assistant", content: "A".repeat(2600) }, - }) + "\n"); + lastSpawnedProc!.stdout.write( + JSON.stringify({ + type: "message_end", + message: { role: "assistant", content: "A".repeat(2600) }, + }) + "\n", + ); lastSpawnedProc!.stdout.write(JSON.stringify({ type: "agent_end" }) + "\n"); lastSpawnedProc!.emit("close", 0, null); @@ -261,8 +280,8 @@ describe("5.x: Runtime behavioral emission (TP-111)", () => { expect(timeline.indexOf("prompt_write")).toBeGreaterThan(-1); expect(timeline.indexOf("event:prompt_sent")).toBeGreaterThan(timeline.indexOf("prompt_write")); - const promptEvt = events.find(e => e.type === "prompt_sent"); - const assistantEvt = events.find(e => e.type === "assistant_message"); + const promptEvt = events.find((e) => e.type === "prompt_sent"); + const assistantEvt = events.find((e) => e.type === "assistant_message"); expect(promptEvt).toBeDefined(); expect(assistantEvt).toBeDefined(); @@ -279,38 +298,45 @@ describe("5.x: Runtime behavioral emission (TP-111)", () => { const huge = "X".repeat(5000); const longPath = `/tmp/${"p".repeat(400)}.txt`; - const { promise } = spawnAgent({ - agentId: "orch-test-lane-2-worker", - role: "worker", - batchId: "batch-tp111", - laneNumber: 2, - taskId: "TP-111", - repoId: "default", - cwd: process.cwd(), - prompt: "run", - mailboxDir: null, - stateRoot: null, - }, evt => events.push(evt)); + const { promise } = spawnAgent( + { + agentId: "orch-test-lane-2-worker", + role: "worker", + batchId: "batch-tp111", + laneNumber: 2, + taskId: "TP-111", + repoId: "default", + cwd: process.cwd(), + prompt: "run", + mailboxDir: null, + stateRoot: null, + }, + (evt) => events.push(evt), + ); expect(lastSpawnedProc).toBeDefined(); - lastSpawnedProc!.stdout.write(JSON.stringify({ - type: "tool_execution_start", - toolName: "write", - args: { content: huge, path: longPath }, - }) + "\n"); - lastSpawnedProc!.stdout.write(JSON.stringify({ - type: "tool_execution_end", - toolName: "write", - result: huge, - }) + "\n"); + lastSpawnedProc!.stdout.write( + JSON.stringify({ + type: "tool_execution_start", + toolName: "write", + args: { content: huge, path: longPath }, + }) + "\n", + ); + lastSpawnedProc!.stdout.write( + JSON.stringify({ + type: "tool_execution_end", + toolName: "write", + result: huge, + }) + "\n", + ); lastSpawnedProc!.stdout.write(JSON.stringify({ type: "agent_end" }) + "\n"); lastSpawnedProc!.emit("close", 0, null); await promise; - const toolCall = events.find(e => e.type === "tool_call"); - const toolResult = events.find(e => e.type === "tool_result"); + const toolCall = events.find((e) => e.type === "tool_call"); + const toolResult = events.find((e) => e.type === "tool_result"); expect(toolCall).toBeDefined(); expect(toolResult).toBeDefined(); @@ -326,34 +352,39 @@ describe("5.x: Runtime behavioral emission (TP-111)", () => { it("5.3: malformed assistant content arrays do not crash and still emit text blocks", async () => { const events: RuntimeAgentEvent[] = []; - const { promise } = spawnAgent({ - agentId: "orch-test-lane-3-worker", - role: "worker", - batchId: "batch-tp111", - laneNumber: 3, - taskId: "TP-111", - repoId: "default", - cwd: process.cwd(), - prompt: "run", - mailboxDir: null, - stateRoot: null, - }, evt => events.push(evt)); + const { promise } = spawnAgent( + { + agentId: "orch-test-lane-3-worker", + role: "worker", + batchId: "batch-tp111", + laneNumber: 3, + taskId: "TP-111", + repoId: "default", + cwd: process.cwd(), + prompt: "run", + mailboxDir: null, + stateRoot: null, + }, + (evt) => events.push(evt), + ); expect(lastSpawnedProc).toBeDefined(); - lastSpawnedProc!.stdout.write(JSON.stringify({ - type: "message_end", - message: { - role: "assistant", - content: [null, { type: "text", text: "OK" }, undefined, 42, { type: "text" }], - }, - }) + "\n"); + lastSpawnedProc!.stdout.write( + JSON.stringify({ + type: "message_end", + message: { + role: "assistant", + content: [null, { type: "text", text: "OK" }, undefined, 42, { type: "text" }], + }, + }) + "\n", + ); lastSpawnedProc!.stdout.write(JSON.stringify({ type: "agent_end" }) + "\n"); lastSpawnedProc!.emit("close", 0, null); await promise; - const assistantEvt = events.find(e => e.type === "assistant_message"); + const assistantEvt = events.find((e) => e.type === "assistant_message"); expect(assistantEvt).toBeDefined(); expect((assistantEvt!.payload as any).text).toBe("OK"); }); diff --git a/extensions/tests/dashboard-history-load.test.ts b/extensions/tests/dashboard-history-load.test.ts index b527069e..4dcf86e1 100644 --- a/extensions/tests/dashboard-history-load.test.ts +++ b/extensions/tests/dashboard-history-load.test.ts @@ -24,26 +24,31 @@ describe("dashboard loadHistory", () => { "utf-8", ).replace(/\r\n/g, "\n"); - const fnSource = extractFunction( - source, - "function loadHistory()", - "/** GET /api/history", - ); + const fnSource = extractFunction(source, "function loadHistory()", "/** GET /api/history"); const root = mkdtempSync(join(tmpdir(), "tp-137-dashboard-")); const historyPath = join(root, ".pi", "batch-history.json"); mkdirSync(join(root, ".pi"), { recursive: true }); - writeFileSync(historyPath, JSON.stringify([ - { batchId: "batch-new", startedAt: 2000 }, - { batchId: "batch-old", startedAt: 1000 }, - ], null, 2)); + writeFileSync( + historyPath, + JSON.stringify( + [ + { batchId: "batch-new", startedAt: 2000 }, + { batchId: "batch-old", startedAt: 1000 }, + ], + null, + 2, + ), + ); try { const context = { fs, BATCH_HISTORY_PATH: historyPath, }; - const loadHistory = vm.runInNewContext(`${fnSource}; loadHistory;`, context) as () => Array<{ batchId: string }>; + const loadHistory = vm.runInNewContext(`${fnSource}; loadHistory;`, context) as () => Array<{ + batchId: string; + }>; const history = loadHistory(); expect(history).toHaveLength(2); expect(history[0].batchId).toBe("batch-new"); diff --git a/extensions/tests/diagnostic-reports.test.ts b/extensions/tests/diagnostic-reports.test.ts index 9c5dd749..c5e724f4 100644 --- a/extensions/tests/diagnostic-reports.test.ts +++ b/extensions/tests/diagnostic-reports.test.ts @@ -34,12 +34,8 @@ mock.module("fs", { // Dynamic imports so the module-under-test picks up the mocked 'fs'. // These MUST be after mock.module() to intercept the module's 'fs' import. -const { - buildDiagnosticEvents, - eventsToJsonl, - buildMarkdownReport, - emitDiagnosticReports, -} = await import("../taskplane/diagnostic-reports.ts"); +const { buildDiagnosticEvents, eventsToJsonl, buildMarkdownReport, emitDiagnosticReports } = + await import("../taskplane/diagnostic-reports.ts"); type DiagnosticReportInput = import("../taskplane/diagnostic-reports.ts").DiagnosticReportInput; type DiagnosticEvent = import("../taskplane/diagnostic-reports.ts").DiagnosticEvent; @@ -50,7 +46,10 @@ type OrchestratorConfig = import("../taskplane/types.ts").OrchestratorConfig; // ── Helpers ────────────────────────────────────────────────────────── /** Build a minimal PersistedTaskRecord with overrides. */ -function makeTask(taskId: string, overrides: Partial = {}): PersistedTaskRecord { +function makeTask( + taskId: string, + overrides: Partial = {}, +): PersistedTaskRecord { return { taskId, laneNumber: 1, @@ -80,7 +79,7 @@ function makeInput(overrides: Partial = {}): DiagnosticRe phase: "completed", mode: "repo", startedAt: 1710000000000, - endedAt: 1710000300000, // 300 seconds + endedAt: 1710000300000, // 300 seconds tasks: [], diagnostics: defaultBatchDiagnostics(), succeededTasks: 0, @@ -104,14 +103,10 @@ describe("buildDiagnosticEvents", () => { it("sorts events deterministically by taskId", () => { const input = makeInput({ - tasks: [ - makeTask("ZZ-003"), - makeTask("AA-001"), - makeTask("MM-002"), - ], + tasks: [makeTask("ZZ-003"), makeTask("AA-001"), makeTask("MM-002")], }); const events = buildDiagnosticEvents(input); - expect(events.map(e => e.taskId)).toEqual(["AA-001", "MM-002", "ZZ-003"]); + expect(events.map((e) => e.taskId)).toEqual(["AA-001", "MM-002", "ZZ-003"]); }); it("uses taskExits as primary data source (precedence over exitDiagnostic)", () => { @@ -125,17 +120,17 @@ describe("buildDiagnosticEvents", () => { taskExits: { "TP-001": { classification: "completed", - cost: 0.50, + cost: 0.5, durationSec: 120, retries: 0, }, }, - batchCost: 0.50, + batchCost: 0.5, }, }); const events = buildDiagnosticEvents(input); expect(events[0].classification).toBe("completed"); - expect(events[0].cost).toBe(0.50); + expect(events[0].cost).toBe(0.5); expect(events[0].durationSec).toBe(120); }); @@ -150,7 +145,7 @@ describe("buildDiagnosticEvents", () => { }); const events = buildDiagnosticEvents(input); expect(events[0].classification).toBe("api_error"); - expect(events[0].cost).toBe(0); // no cost in exitDiagnostic + expect(events[0].cost).toBe(0); // no cost in exitDiagnostic }); it("falls back to 'unknown' when both taskExits and exitDiagnostic missing", () => { @@ -167,7 +162,7 @@ describe("buildDiagnosticEvents", () => { tasks: [ makeTask("TP-001", { startedAt: 1710000000000, - endedAt: 1710000090000, // 90 seconds + endedAt: 1710000090000, // 90 seconds }), ], }); @@ -244,12 +239,12 @@ describe("buildDiagnosticEvents", () => { taskExits: { "TP-001": { classification: "completed", - cost: 0.10, + cost: 0.1, durationSec: 30, retries: 3, }, }, - batchCost: 0.10, + batchCost: 0.1, }, }); const events = buildDiagnosticEvents(input); @@ -362,7 +357,7 @@ describe("buildMarkdownReport", () => { ], diagnostics: { taskExits: { - "TP-001": { classification: "completed", cost: 0.10, durationSec: 60, retries: 0 }, + "TP-001": { classification: "completed", cost: 0.1, durationSec: 60, retries: 0 }, "TP-002": { classification: "crash", cost: 0.05, durationSec: 30, retries: 1 }, }, batchCost: 0.15, @@ -394,9 +389,9 @@ describe("buildMarkdownReport", () => { ], diagnostics: { taskExits: { - "TP-001": { classification: "completed", cost: 0.10, durationSec: 60 }, + "TP-001": { classification: "completed", cost: 0.1, durationSec: 60 }, "TP-002": { classification: "crash", cost: 0.05, durationSec: 30 }, - "TP-003": { classification: "completed", cost: 0.20, durationSec: 90 }, + "TP-003": { classification: "completed", cost: 0.2, durationSec: 90 }, }, batchCost: 0.35, }, @@ -440,7 +435,7 @@ describe("buildMarkdownReport", () => { it("formats duration correctly", () => { const input = makeInput({ startedAt: 1710000000000, - endedAt: 1710003661000, // 3661 seconds = 1h 1m 1s + endedAt: 1710003661000, // 3661 seconds = 1h 1m 1s }); const events = buildDiagnosticEvents(input); const report = buildMarkdownReport(input, events); @@ -524,7 +519,7 @@ describe("emitDiagnosticReports — robustness", () => { failedTasks: 1, diagnostics: { taskExits: { - "TP-001": { classification: "completed", cost: 0.10, durationSec: 60, retries: 0 }, + "TP-001": { classification: "completed", cost: 0.1, durationSec: 60, retries: 0 }, "TP-002": { classification: "crash", cost: 0.05, durationSec: 30, retries: 1 }, }, batchCost: 0.15, @@ -537,8 +532,8 @@ describe("emitDiagnosticReports — robustness", () => { expect(mockWriteFileSync).toHaveBeenCalledTimes(2); // Check JSONL file - const jsonlCall = mockWriteFileSync.mock.calls.find( - (call: any) => String(call.arguments[0]).endsWith("-events.jsonl"), + const jsonlCall = mockWriteFileSync.mock.calls.find((call: any) => + String(call.arguments[0]).endsWith("-events.jsonl"), ); expect(jsonlCall).toBeDefined(); const jsonlPath = String(jsonlCall!.arguments[0]); @@ -560,8 +555,8 @@ describe("emitDiagnosticReports — robustness", () => { } // Check markdown file - const mdCall = mockWriteFileSync.mock.calls.find( - (call: any) => String(call.arguments[0]).endsWith("-report.md"), + const mdCall = mockWriteFileSync.mock.calls.find((call: any) => + String(call.arguments[0]).endsWith("-report.md"), ); expect(mdCall).toBeDefined(); const mdPath = String(mdCall!.arguments[0]); diff --git a/extensions/tests/discovery-routing.test.ts b/extensions/tests/discovery-routing.test.ts index e02f7a5c..f469cd04 100644 --- a/extensions/tests/discovery-routing.test.ts +++ b/extensions/tests/discovery-routing.test.ts @@ -35,10 +35,21 @@ import { tmpdir } from "os"; const __dirname = dirname(fileURLToPath(import.meta.url)); -import { formatDiscoveryResults, parsePromptForOrchestrator, resolveTaskRouting, runDiscovery } from "../taskplane/discovery.ts"; +import { + formatDiscoveryResults, + parsePromptForOrchestrator, + resolveTaskRouting, + runDiscovery, +} from "../taskplane/discovery.ts"; import { loadTaskRunnerConfig } from "../taskplane/config.ts"; import { FATAL_DISCOVERY_CODES } from "../taskplane/types.ts"; -import type { DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig, WorkspaceRepoConfig } from "../taskplane/types.ts"; +import type { + DiscoveryResult, + ParsedTask, + TaskArea, + WorkspaceConfig, + WorkspaceRepoConfig, +} from "../taskplane/types.ts"; // ── Test Fixtures ──────────────────────────────────────────────────── @@ -651,7 +662,6 @@ Repo: api }); }); - // ── Routing Precedence Tests (Step 1) ──────────────────────────────── /** @@ -694,7 +704,9 @@ function makeDiscoveryResult(tasks: ParsedTask[]): DiscoveryResult { /** * Helper to build a minimal ParsedTask. */ -function makeTask(overrides: Partial & { taskId: string; areaName: string }): ParsedTask { +function makeTask( + overrides: Partial & { taskId: string; areaName: string }, +): ParsedTask { return { taskName: overrides.taskName ?? "Test Task", reviewLevel: overrides.reviewLevel ?? 2, @@ -1157,7 +1169,6 @@ describe("14.x: Multiple tasks with mixed routing sources", () => { }); }); - // ══════════════════════════════════════════════════════════════════════ // Step 2: Annotate Discovery Outputs — Integration Tests // ══════════════════════════════════════════════════════════════════════ @@ -1243,10 +1254,7 @@ describe("15.x: Discovery output annotation with resolved repo", () => { describe("16.x: Routing errors appear as fatal errors in formatted output", () => { it("16.1: TASK_REPO_UNKNOWN appears in error section", () => { const task = makeTask({ taskId: "TP-100", areaName: "default", promptRepoId: "ghost" }); - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); const taskAreas: Record = { default: { path: "/workspace/tasks", prefix: "TP", context: "" }, }; @@ -1564,10 +1572,7 @@ Repo: nonexistent const taskAreas: Record = { default: { path: areaDir, prefix: "TP", context: "" }, }; - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); const result = runDiscovery("all", taskAreas, areaDir, { workspaceConfig, @@ -1706,7 +1711,6 @@ Repo: nonexistent }); }); - // ── 15.x: Config: area repo_id parsing ─────────────────────────────── describe("15.x: loadTaskRunnerConfig parses repo_id", () => { @@ -1824,7 +1828,6 @@ describe("15.x: loadTaskRunnerConfig parses repo_id", () => { }); }); - // ── 16.x: formatDiscoveryResults repo annotation ───────────────────── describe("16.x: formatDiscoveryResults repo annotation", () => { @@ -1887,7 +1890,6 @@ describe("16.x: formatDiscoveryResults repo annotation", () => { }); }); - // ── 17.x: Actionable routing error guidance ────────────────────────── describe("17.x: Actionable routing error guidance", () => { @@ -1977,7 +1979,6 @@ describe("17.x: Actionable routing error guidance", () => { }); }); - // ══════════════════════════════════════════════════════════════════════ // Strict Routing Policy Tests (TP-011 Step 0 + Step 1) // ══════════════════════════════════════════════════════════════════════ @@ -2041,10 +2042,7 @@ describe("19.x: Strict mode — rejects tasks without explicit execution target" }); it("19.3: strict mode rejects multiple tasks without promptRepoId", () => { - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const taskAreas: Record = { @@ -2063,10 +2061,7 @@ describe("19.x: Strict mode — rejects tasks without explicit execution target" }); it("19.4: strict mode still blocks even if area-level repoId is available", () => { - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const taskAreas: Record = { @@ -2129,10 +2124,7 @@ describe("20.x: Strict mode — accepts tasks with explicit execution target", ( }); it("20.2: strict mode still validates that promptRepoId is known", () => { - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const taskAreas: Record = { @@ -2208,10 +2200,7 @@ describe("21.x: Permissive mode (strict=false) — existing behavior unchanged", }); it("21.2: strict=undefined (not set) behaves as permissive", () => { - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); // strict field not set at all (undefined) delete (workspaceConfig.routing as any).strict; @@ -2262,8 +2251,7 @@ describe("22.x: TASK_ROUTING_STRICT is classified as fatal", () => { const discovery = makeDiscoveryResult([task]); discovery.errors.push({ code: "TASK_ROUTING_STRICT", - message: - 'Task TP-100 has no explicit execution target, but strict routing is enabled.', + message: "Task TP-100 has no explicit execution target, but strict routing is enabled.", taskId: "TP-100", taskPath: "/workspace/tasks/TP-100/PROMPT.md", }); @@ -2277,10 +2265,7 @@ describe("22.x: TASK_ROUTING_STRICT is classified as fatal", () => { }); it("22.3: strict error includes taskId and taskPath", () => { - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const taskAreas: Record = { @@ -2345,10 +2330,7 @@ describe("24.x: runDiscovery pipeline — strict routing end-to-end", () => { const taskAreas: Record = { default: { path: areaDir, prefix: "TP", context: "", repoId: "api" }, }; - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const result = runDiscovery("all", taskAreas, areaDir, { @@ -2395,10 +2377,7 @@ Repo: api const taskAreas: Record = { default: { path: areaDir, prefix: "TP", context: "" }, }; - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const result = runDiscovery("all", taskAreas, areaDir, { @@ -2444,10 +2423,7 @@ Repo: api const taskAreas: Record = { default: { path: areaDir, prefix: "TP", context: "", repoId: "api" }, }; - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); // strict is NOT set (permissive default) const result = runDiscovery("all", taskAreas, areaDir, { @@ -2492,7 +2468,6 @@ Repo: api }); }); - // ══════════════════════════════════════════════════════════════════════ // Step 1: Command-Surface Remediation Hints (TP-011) // ══════════════════════════════════════════════════════════════════════ @@ -2501,66 +2476,47 @@ Repo: api describe("25.x: Command surface TASK_ROUTING_STRICT remediation hints", () => { it("25.1: extension.ts checks for TASK_ROUTING_STRICT in fatal error block", () => { - const extensionSrc = readFileSync( - join(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", - ); + const extensionSrc = readFileSync(join(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); expect(extensionSrc).toContain('"TASK_ROUTING_STRICT"'); // Verify it's part of the fatal-error hint block (not just a comment) - expect(extensionSrc).toContain('hasStrictErrors'); - expect(extensionSrc).toContain('Strict routing is enabled'); + expect(extensionSrc).toContain("hasStrictErrors"); + expect(extensionSrc).toContain("Strict routing is enabled"); }); it("25.2: engine.ts checks for TASK_ROUTING_STRICT in fatal error block", () => { - const engineSrc = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSrc = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); expect(engineSrc).toContain('"TASK_ROUTING_STRICT"'); - expect(engineSrc).toContain('hasStrictErrors'); - expect(engineSrc).toContain('Strict routing is enabled'); + expect(engineSrc).toContain("hasStrictErrors"); + expect(engineSrc).toContain("Strict routing is enabled"); }); it("25.3: extension.ts TASK_ROUTING_STRICT hint includes remediation guidance", () => { - const extensionSrc = readFileSync( - join(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", - ); + const extensionSrc = readFileSync(join(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); // The hint should tell users how to fix and how to disable expect(extensionSrc).toContain("Execution Target"); expect(extensionSrc).toContain("routing.strict: false"); }); it("25.4: engine.ts TASK_ROUTING_STRICT hint includes remediation guidance", () => { - const engineSrc = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSrc = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); expect(engineSrc).toContain("Execution Target"); expect(engineSrc).toContain("routing.strict: false"); }); it("25.5: extension.ts has separate handling for routing and strict errors", () => { - const extensionSrc = readFileSync( - join(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", - ); + const extensionSrc = readFileSync(join(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); // Both TASK_REPO_UNRESOLVED/UNKNOWN and TASK_ROUTING_STRICT should be handled expect(extensionSrc).toContain("hasRoutingErrors"); expect(extensionSrc).toContain("hasStrictErrors"); }); it("25.6: engine.ts has separate handling for routing and strict errors", () => { - const engineSrc = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSrc = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); expect(engineSrc).toContain("hasRoutingErrors"); expect(engineSrc).toContain("hasStrictErrors"); }); }); - // ══════════════════════════════════════════════════════════════════════ // Step 2: Governance Scenarios (TP-011) // ══════════════════════════════════════════════════════════════════════ @@ -2608,11 +2564,14 @@ Repo: api const result = runDiscovery("all", taskAreas, areaDir); // No routing errors at all - expect(result.errors.filter((e) => - e.code === "TASK_ROUTING_STRICT" || - e.code === "TASK_REPO_UNKNOWN" || - e.code === "TASK_REPO_UNRESOLVED" - )).toHaveLength(0); + expect( + result.errors.filter( + (e) => + e.code === "TASK_ROUTING_STRICT" || + e.code === "TASK_REPO_UNKNOWN" || + e.code === "TASK_REPO_UNRESOLVED", + ), + ).toHaveLength(0); // Task discovered but not routed expect(result.pending.size).toBe(1); @@ -2709,10 +2668,7 @@ Repo: ghost-service const taskAreas: Record = { default: { path: areaDir, prefix: "TP", context: "" }, // no repoId on area }; - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = false; // explicitly permissive const result = runDiscovery("all", taskAreas, areaDir, { @@ -2786,10 +2742,7 @@ Repo: api const taskAreas: Record = { default: { path: areaDir, prefix: "TP", context: "", repoId: "api" }, }; - const workspaceConfig = makeWorkspaceConfig( - { api: { path: "/repos/api" } }, - "api", - ); + const workspaceConfig = makeWorkspaceConfig({ api: { path: "/repos/api" } }, "api"); workspaceConfig.routing.strict = true; const result = runDiscovery("all", taskAreas, areaDir, { @@ -2883,9 +2836,7 @@ describe("28.x: explicit segment DAG metadata", () => { expect(result.task).not.toBeNull(); expect(result.task!.explicitSegmentDag).toEqual({ repoIds: ["api", "web-client"], - edges: [ - { fromRepoId: "api", toRepoId: "web-client" }, - ], + edges: [{ fromRepoId: "api", toRepoId: "web-client" }], }); }); @@ -3014,9 +2965,7 @@ Edges: }); it("28.7: SEGMENT_DAG_INVALID is treated as fatal in formatted discovery output", () => { - const discovery = makeDiscoveryResult([ - makeTask({ taskId: "TP-540", areaName: "default" }), - ]); + const discovery = makeDiscoveryResult([makeTask({ taskId: "TP-540", areaName: "default" })]); discovery.errors.push({ code: "SEGMENT_DAG_INVALID", message: "Task TP-540 has cyclic ## Segment DAG metadata: api -> web -> api.", diff --git a/extensions/tests/discovery-segment-steps.test.ts b/extensions/tests/discovery-segment-steps.test.ts index d97331d9..1d6bd5c8 100644 --- a/extensions/tests/discovery-segment-steps.test.ts +++ b/extensions/tests/discovery-segment-steps.test.ts @@ -77,7 +77,6 @@ afterEach(() => { rmSync(testRoot, { recursive: true, force: true }); }); - // ── 29.x: Basic segment markers → correct StepSegmentMapping ──────── describe("29.x: PROMPT.md with segment markers → correct StepSegmentMapping", () => { @@ -85,7 +84,9 @@ describe("29.x: PROMPT.md with segment markers → correct StepSegmentMapping", const dir = makeTestDir("multi-seg"); const taskDir = join(dir, "TP-200-multi-seg"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-200 - Multi Segment Task + const promptPath = writePrompt( + taskDir, + `# Task: TP-200 - Multi Segment Task **Size:** M @@ -119,7 +120,8 @@ Repo: shared-libs ## Completion Criteria - [ ] Everything works -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); expect(result.task).not.toBe(null); @@ -147,7 +149,6 @@ Repo: shared-libs }); }); - // ── 30.x: No segment markers (fallback) ───────────────────────────── describe("30.x: PROMPT.md without segment markers → single segment per step with primary repoId", () => { @@ -155,7 +156,9 @@ describe("30.x: PROMPT.md without segment markers → single segment per step wi const dir = makeTestDir("no-markers"); const taskDir = join(dir, "TP-201-no-markers"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-201 - Simple Task + const promptPath = writePrompt( + taskDir, + `# Task: TP-201 - Simple Task **Size:** M @@ -179,7 +182,8 @@ Repo: api-service ## Completion Criteria - [ ] Done -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); // No explicit segment markers → stepSegmentMap should be undefined @@ -191,7 +195,9 @@ Repo: api-service const dir = makeTestDir("no-repo-id"); const taskDir = join(dir, "TP-202-no-repo"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-202 - No Repo Task + const promptPath = writePrompt( + taskDir, + `# Task: TP-202 - No Repo Task **Size:** M @@ -203,7 +209,8 @@ Repo: api-service ### Step 0: Preflight - [ ] Check stuff -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); // No explicit segment markers → undefined @@ -211,7 +218,6 @@ Repo: api-service }); }); - // ── 31.x: Mixed steps ─────────────────────────────────────────────── describe("31.x: Mixed steps (some with markers, some without) → correct mapping", () => { @@ -219,7 +225,9 @@ describe("31.x: Mixed steps (some with markers, some without) → correct mappin const dir = makeTestDir("mixed"); const taskDir = join(dir, "TP-203-mixed"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-203 - Mixed Task + const promptPath = writePrompt( + taskDir, + `# Task: TP-203 - Mixed Task **Size:** M @@ -246,7 +254,8 @@ Repo: api ### Step 2: Documentation - [ ] Update docs -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); const map = result.task!.stepSegmentMap!; @@ -269,7 +278,6 @@ Repo: api }); }); - // ── 32.x: Duplicate repoId in same step → error ───────────────────── describe("32.x: Duplicate repoId in same step → discovery error", () => { @@ -277,7 +285,9 @@ describe("32.x: Duplicate repoId in same step → discovery error", () => { const dir = makeTestDir("dup-repo"); const taskDir = join(dir, "TP-204-dup"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-204 - Dup Repo Task + const promptPath = writePrompt( + taskDir, + `# Task: TP-204 - Dup Repo Task **Size:** M @@ -298,7 +308,8 @@ Repo: api #### Segment: shared-libs - [ ] Check shared-libs again -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); // Duplicate within a step is a hard error expect(result.error).not.toBe(null); @@ -310,7 +321,9 @@ Repo: api const dir = makeTestDir("dup-pre-seg"); const taskDir = join(dir, "TP-205-dup-pre"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-205 - Dup Pre-Segment + const promptPath = writePrompt( + taskDir, + `# Task: TP-205 - Dup Pre-Segment **Size:** M @@ -329,7 +342,8 @@ Repo: api #### Segment: api - [ ] Explicit api checkbox -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); // Pre-segment with fallback "api" + explicit segment "api" = duplicate expect(result.error).not.toBe(null); @@ -341,7 +355,9 @@ Repo: api const areaDir = join(dir, "tasks"); const taskDir = join(areaDir, "TP-206-dup-repo-mode"); mkdirSync(taskDir, { recursive: true }); - writePrompt(taskDir, `# Task: TP-206 - Dup Repo Mode + writePrompt( + taskDir, + `# Task: TP-206 - Dup Repo Mode **Size:** M @@ -356,7 +372,8 @@ Repo: api #### Segment: default - [ ] Explicit default checkbox -`); +`, + ); const taskAreas: Record = { tasks: { path: areaDir, prefix: "TP" }, @@ -365,13 +382,12 @@ Repo: api // Run discovery in repo mode (no workspace config) const discovery = runDiscovery("all", taskAreas, dir); // Should have duplicate error after placeholder normalization - const dupErrors = discovery.errors.filter(e => e.code === "SEGMENT_STEP_DUPLICATE_REPO"); + const dupErrors = discovery.errors.filter((e) => e.code === "SEGMENT_STEP_DUPLICATE_REPO"); expect(dupErrors.length).toBeGreaterThanOrEqual(1); expect(dupErrors[0].message).toContain("default"); }); }); - // ── 33.x: Empty segment (no checkboxes) → warning ─────────────────── describe("33.x: Empty segment → discovery warning", () => { @@ -379,7 +395,9 @@ describe("33.x: Empty segment → discovery warning", () => { const dir = makeTestDir("empty-seg"); const taskDir = join(dir, "TP-207-empty"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-207 - Empty Segment + const promptPath = writePrompt( + taskDir, + `# Task: TP-207 - Empty Segment **Size:** M @@ -399,13 +417,14 @@ Repo: api #### Segment: web-client - [ ] Do something -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); expect(result.task).not.toBe(null); // Empty segment should produce a warning expect(result.warnings).not.toBe(undefined); - const emptyWarnings = result.warnings!.filter(w => w.code === "SEGMENT_STEP_EMPTY"); + const emptyWarnings = result.warnings!.filter((w) => w.code === "SEGMENT_STEP_EMPTY"); expect(emptyWarnings.length).toBe(1); expect(emptyWarnings[0].message).toContain("shared-libs"); // The mapping should still have the empty segment @@ -416,7 +435,6 @@ Repo: api }); }); - // ── 34.x: Unknown repoId in workspace mode → warning ──────────────── describe("34.x: Unknown repoId → discovery warning with suggestion", () => { @@ -425,7 +443,9 @@ describe("34.x: Unknown repoId → discovery warning with suggestion", () => { const areaDir = join(dir, "tasks"); const taskDir = join(areaDir, "TP-208-unknown-repo"); mkdirSync(taskDir, { recursive: true }); - writePrompt(taskDir, `# Task: TP-208 - Unknown Repo Task + writePrompt( + taskDir, + `# Task: TP-208 - Unknown Repo Task **Size:** M @@ -446,7 +466,8 @@ Repo: api #### Segment: web-clien - [ ] Do web work -`); +`, + ); const taskAreas: Record = { tasks: { path: areaDir, prefix: "TP" }, @@ -457,7 +478,7 @@ Repo: api }); const discovery = runDiscovery("all", taskAreas, dir, { workspaceConfig }); - const unknownErrors = discovery.errors.filter(e => e.code === "SEGMENT_STEP_REPO_INVALID"); + const unknownErrors = discovery.errors.filter((e) => e.code === "SEGMENT_STEP_REPO_INVALID"); expect(unknownErrors.length).toBeGreaterThanOrEqual(1); expect(unknownErrors[0].message).toContain("web-clien"); expect(unknownErrors[0].message).toContain("Known repos:"); @@ -471,7 +492,9 @@ Repo: api const areaDir = join(dir, "tasks"); const taskDir = join(areaDir, "TP-209-nonfatal"); mkdirSync(taskDir, { recursive: true }); - writePrompt(taskDir, `# Task: TP-209 - Non-Fatal Warning + writePrompt( + taskDir, + `# Task: TP-209 - Non-Fatal Warning **Size:** M @@ -492,7 +515,8 @@ Repo: api #### Segment: unknown-repo - [ ] More work -`); +`, + ); const taskAreas: Record = { tasks: { path: areaDir, prefix: "TP" }, @@ -505,7 +529,7 @@ Repo: api // Task should still be pending (not failed) expect(discovery.pending.has("TP-209")).toBe(true); // Warning present - const warnings = discovery.errors.filter(e => e.code === "SEGMENT_STEP_REPO_INVALID"); + const warnings = discovery.errors.filter((e) => e.code === "SEGMENT_STEP_REPO_INVALID"); expect(warnings.length).toBeGreaterThanOrEqual(1); // SEGMENT_STEP_REPO_INVALID is NOT in FATAL_DISCOVERY_CODES const fatalCodes = new Set(FATAL_DISCOVERY_CODES); @@ -513,7 +537,6 @@ Repo: api }); }); - // ── 35.x: Repo mode placeholder resolution ────────────────────────── describe("35.x: Repo mode placeholder resolution", () => { @@ -522,7 +545,9 @@ describe("35.x: Repo mode placeholder resolution", () => { const areaDir = join(dir, "tasks"); const taskDir = join(areaDir, "TP-210-repo-mode"); mkdirSync(taskDir, { recursive: true }); - writePrompt(taskDir, `# Task: TP-210 - Repo Mode Task + writePrompt( + taskDir, + `# Task: TP-210 - Repo Mode Task **Size:** M @@ -537,7 +562,8 @@ describe("35.x: Repo mode placeholder resolution", () => { ### Step 1: Implement - [ ] Do work -`); +`, + ); const taskAreas: Record = { tasks: { path: areaDir, prefix: "TP" }, @@ -552,7 +578,6 @@ describe("35.x: Repo mode placeholder resolution", () => { }); }); - // ── 36.x: Post-## Steps content isolation ──────────────────────────── describe("36.x: Post-## Steps content not leaked into last step", () => { @@ -560,7 +585,9 @@ describe("36.x: Post-## Steps content not leaked into last step", () => { const dir = makeTestDir("post-steps"); const taskDir = join(dir, "TP-211-post-steps"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-211 - Post Steps Leak Test + const promptPath = writePrompt( + taskDir, + `# Task: TP-211 - Post Steps Leak Test **Size:** M @@ -581,7 +608,8 @@ Repo: api - [ ] All steps complete - [ ] Tests passing -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); // No explicit segment markers → stepSegmentMap undefined @@ -590,7 +618,6 @@ Repo: api }); }); - // ── 37.x: Pre-segment checkboxes ──────────────────────────────────── describe("37.x: Pre-segment checkboxes mapped to fallback repo", () => { @@ -598,7 +625,9 @@ describe("37.x: Pre-segment checkboxes mapped to fallback repo", () => { const dir = makeTestDir("pre-segment"); const taskDir = join(dir, "TP-212-pre-seg"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-212 - Pre-Segment + const promptPath = writePrompt( + taskDir, + `# Task: TP-212 - Pre-Segment **Size:** M @@ -617,7 +646,8 @@ Repo: api #### Segment: web-client - [ ] Web-client specific checkbox -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); const map = result.task!.stepSegmentMap!; @@ -632,7 +662,6 @@ Repo: api }); }); - // ── 38.x: Invalid repo ID format ──────────────────────────────────── describe("38.x: Invalid repo ID format → warning, checkboxes preserved", () => { @@ -640,7 +669,9 @@ describe("38.x: Invalid repo ID format → warning, checkboxes preserved", () => const dir = makeTestDir("invalid-repo"); const taskDir = join(dir, "TP-213-invalid"); mkdirSync(taskDir, { recursive: true }); - const promptPath = writePrompt(taskDir, `# Task: TP-213 - Invalid Repo + const promptPath = writePrompt( + taskDir, + `# Task: TP-213 - Invalid Repo **Size:** M @@ -662,13 +693,14 @@ Repo: api #### Segment: web-client - [ ] Valid work -`); +`, + ); const result = parsePromptForOrchestrator(promptPath, taskDir, "test-area"); expect(result.error).toBe(null); expect(result.task).not.toBe(null); // Warning produced for invalid format expect(result.warnings).not.toBe(undefined); - const invalidWarnings = result.warnings!.filter(w => w.code === "SEGMENT_STEP_REPO_INVALID"); + const invalidWarnings = result.warnings!.filter((w) => w.code === "SEGMENT_STEP_REPO_INVALID"); expect(invalidWarnings.length).toBe(1); expect(invalidWarnings[0].message).toContain("api_service"); // Checkboxes NOT dropped diff --git a/extensions/tests/engine-runtime-v2-routing.test.ts b/extensions/tests/engine-runtime-v2-routing.test.ts index 3df51761..d81cf486 100644 --- a/extensions/tests/engine-runtime-v2-routing.test.ts +++ b/extensions/tests/engine-runtime-v2-routing.test.ts @@ -18,19 +18,11 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const engineSrc = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); const executionSrc = readFileSync(join(__dirname, "..", "taskplane", "execution.ts"), "utf-8"); -const { - selectRuntimeBackend, -} = await import("../taskplane/engine.ts"); -const { - mapLaneTaskStatusToTerminalSnapshotStatus, - mapLaneSnapshotStatusToWorkerStatus, -} = await import("../taskplane/lane-runner.ts"); -const { - resolveTaskMonitorState, -} = await import("../taskplane/execution.ts"); -const { - writeLaneSnapshot, -} = await import("../taskplane/process-registry.ts"); +const { selectRuntimeBackend } = await import("../taskplane/engine.ts"); +const { mapLaneTaskStatusToTerminalSnapshotStatus, mapLaneSnapshotStatusToWorkerStatus } = + await import("../taskplane/lane-runner.ts"); +const { resolveTaskMonitorState } = await import("../taskplane/execution.ts"); +const { writeLaneSnapshot } = await import("../taskplane/process-registry.ts"); // ── 1. Backend selection logic in engine ───────────────────────────── @@ -76,8 +68,8 @@ describe("2.x: executeWave backend parameter", () => { }); it("2.3: executeWave routes lanes directly to executeLaneV2", () => { - expect(executionSrc).toContain("const lanePromises = lanes.map(lane =>"); - expect(executionSrc).toContain("executeLaneV2(lane, config"); + expect(executionSrc).toContainNormalized("const lanePromises = lanes.map((lane) =>"); + expect(executionSrc).toContainNormalized("executeLaneV2(lane, config"); }); it("2.4: executeWave forces Runtime V2 even when backend is omitted", () => { @@ -154,11 +146,16 @@ describe("5.x: Lane-runner terminal snapshot emission", () => { }); it("5.3: all makeResult calls pass config, statusPath, reviewerStatePath, and telemetry", () => { + // TP-193: Whitespace-normalize source so cosmetic formatter wrapping + // (multi-line argument lists) doesn't break literal-string match. + const normSrc = laneRunnerSrc.replace(/\s+/g, " "); // Every return makeResult(...) should end with config, statusPath, reviewerStatePath[, lastTelemetry[, snapshotSegmentCtx]] - const calls = laneRunnerSrc.match(/return makeResult\(/g); + const calls = normSrc.match(/return makeResult\(/g); // Worker-result calls pass lastTelemetry; skipped calls don't (no agent ran). // TP-174: Calls may additionally pass snapshotSegmentCtx after lastTelemetry. - const callsWithTelemetry = laneRunnerSrc.match(/config, statusPath, reviewerStatePath, lastTelemetry[^)]*\)/g); + const callsWithTelemetry = normSrc.match( + /config, statusPath, reviewerStatePath, lastTelemetry[^)]*\)/g, + ); expect(calls).not.toBe(null); // At least 3 calls pass telemetry (failed, max-iter-failed, succeeded) expect(callsWithTelemetry).not.toBe(null); @@ -166,9 +163,14 @@ describe("5.x: Lane-runner terminal snapshot emission", () => { }); it("5.4: lastTelemetry is scoped across loop and post-loop completion checks", () => { - const declIdx = laneRunnerSrc.indexOf("let lastTelemetry: Partial = {};"); - const loopIdx = laneRunnerSrc.indexOf("for (let iter = 0; iter < config.maxIterations; iter++)"); - const postLoopUseIdx = laneRunnerSrc.lastIndexOf("config, statusPath, reviewerStatePath, lastTelemetry"); + // TP-193: Whitespace-normalize source so cosmetic formatter wrapping + // doesn't break literal-string indexOf lookups for multi-arg call sites. + const normSrc = laneRunnerSrc.replace(/\s+/g, " "); + const declIdx = normSrc.indexOf("let lastTelemetry: Partial = {};"); + const loopIdx = normSrc.indexOf("for (let iter = 0; iter < config.maxIterations; iter++)"); + const postLoopUseIdx = normSrc.lastIndexOf( + "config, statusPath, reviewerStatePath, lastTelemetry", + ); expect(declIdx).toBeGreaterThan(-1); expect(loopIdx).toBeGreaterThan(-1); expect(postLoopUseIdx).toBeGreaterThan(-1); @@ -206,12 +208,24 @@ describe("7.x: Behavioral backend and snapshot mapping", () => { expect(selectRuntimeBackend("all", [["TP-001"]], null).backend).toBe("v2"); expect(selectRuntimeBackend("all", [["TP-001"], ["TP-002"]], null).backend).toBe("v2"); // Workspace mode also V2 (TP-109: packet-home authority threaded) - const ws = { mode: "workspace", repos: new Map(), routing: {}, configPath: "x", workspaceRoot: "x" } as any; + const ws = { + mode: "workspace", + repos: new Map(), + routing: {}, + configPath: "x", + workspaceRoot: "x", + } as any; expect(selectRuntimeBackend("all", [["TP-001"]], ws).backend).toBe("v2"); }); it("7.2: selectRuntimeBackend returns v2 in workspace mode (TP-109)", () => { - const ws = { mode: "workspace", repos: new Map(), routing: {}, configPath: "x", workspaceRoot: "x" } as any; + const ws = { + mode: "workspace", + repos: new Map(), + routing: {}, + configPath: "x", + workspaceRoot: "x", + } as any; expect(selectRuntimeBackend("tasks/TP-001/PROMPT.md", [["TP-001"]], ws).backend).toBe("v2"); }); @@ -291,9 +305,14 @@ describe("9.x: Merge host V2 migration (TP-108)", () => { }); it("9.5: mergeWave routes spawn to V2 when backend is v2", () => { - // Both retry and first-attempt paths must have V2 routing + // Both retry and first-attempt paths must have V2 routing. + // TP-193 fold: bumped slice window 16000 → 24000. The original 16000 was + // tight against the post-format-pass `merge.ts` length; the second + // `spawnMergeAgentV2(` call landed at ~offset 15999 in the merged state, + // putting its opening paren just outside the window. 24000 leaves + // comfortable headroom for future formatter-induced growth. const fnIdx = mergeSrc.indexOf("export async function mergeWave("); - const block = mergeSrc.slice(fnIdx, fnIdx + 16000); + const block = mergeSrc.slice(fnIdx, fnIdx + 24000); const v2SpawnCount = (block.match(/spawnMergeAgentV2\(/g) || []).length; expect(v2SpawnCount).toBeGreaterThanOrEqual(2); // first attempt + retry }); @@ -404,14 +423,17 @@ describe("11.x: Merge V2 liveness + abort correctness", () => { }); it("11.6: merge path has no TMUX health-monitor registration", () => { + // TP-193 fold: bumped slice window 16000 → 24000 (same rationale as 9.5 + // above — the post-format-pass mergeWave function is ~16k chars and a + // 16000 slice is tight against future growth). const fnIdx = mergeSrc.indexOf("export async function mergeWave("); - const block = mergeSrc.slice(fnIdx, fnIdx + 16000); + const block = mergeSrc.slice(fnIdx, fnIdx + 24000); expect(block).not.toContain("addSession"); }); it("11.7: abort discovery uses Runtime V2 state sources (no tmux list-sessions)", () => { expect(abortSrc).toContain("discoverAbortSessionNames("); - expect(abortSrc).not.toContain('execSync(\'tmux list-sessions'); + expect(abortSrc).not.toContain("execSync('tmux list-sessions"); }); it("11.8: /orch-abort helper delegates to executeAbort without tmux kill-session", () => { @@ -432,7 +454,7 @@ describe("12.x: Resume TDZ safety", () => { const declIdx = resumeSrc.indexOf("const resumeBackend: RuntimeBackend"); expect(declIdx).toBeGreaterThan(-1); // Check ALL uses — not just mergeWaveByRepo but also section 3 liveness - const allUses = [...resumeSrc.matchAll(/resumeBackend/g)].map(m => m.index!); + const allUses = [...resumeSrc.matchAll(/resumeBackend/g)].map((m) => m.index!); for (const useIdx of allUses) { if (useIdx === declIdx) continue; // skip the declaration itself expect(declIdx).toBeLessThan(useIdx); @@ -580,7 +602,7 @@ describe("14.x: Monitor de-TMUX for V2 (TP-112)", () => { const block = execSrc.slice(fnIdx, nextSectionIdx > fnIdx ? nextSectionIdx : fnIdx + 1200); expect(block).toContain("process.kill"); expect(block).toContain("SIGTERM"); - expect(block).not.toContain("spawn(\"tmux\""); + expect(block).not.toContain('spawn("tmux"'); }); it("14.7: executeWave passes batchId and resolved state root to monitorLanes", () => { @@ -594,11 +616,15 @@ describe("14.x: Monitor de-TMUX for V2 (TP-112)", () => { }); it("14.8: final cleanup kills lingering Runtime V2 agents without TMUX fallbacks", () => { - const cleanupIdx = engineSrc.indexOf("Kill lingering Runtime V2 agents BEFORE removing worktrees."); + const cleanupIdx = engineSrc.indexOf( + "Kill lingering Runtime V2 agents BEFORE removing worktrees.", + ); expect(cleanupIdx).toBeGreaterThan(-1); const cleanupBlock = engineSrc.slice(cleanupIdx, cleanupIdx + 1600); expect(cleanupBlock).toContain("readRegistrySnapshot(stateRoot, batchState.batchId)"); - expect(cleanupBlock).toContain("lingeringLaneSessions.add(manifest.agentId.replace(/-(worker|reviewer)$/"); + expect(cleanupBlock).toContain( + "lingeringLaneSessions.add(manifest.agentId.replace(/-(worker|reviewer)$/", + ); expect(cleanupBlock).toContain("killV2LaneAgents(sessionName"); expect(cleanupBlock).toContain("killAllMergeAgentsV2()"); expect(cleanupBlock).not.toContain("tmuxHasSession"); diff --git a/extensions/tests/engine-segment-frontier.test.ts b/extensions/tests/engine-segment-frontier.test.ts index 624bb892..ea92492e 100644 --- a/extensions/tests/engine-segment-frontier.test.ts +++ b/extensions/tests/engine-segment-frontier.test.ts @@ -13,7 +13,13 @@ import { upsertPendingExpandedSegmentRecords, } from "../taskplane/engine.ts"; import { buildExecutionUnit, ensureTaskFilesCommitted } from "../taskplane/execution.ts"; -import type { AllocatedLane, AllocatedTask, ParsedTask, SegmentExpansionRequest, TaskSegmentPlan } from "../taskplane/types.ts"; +import type { + AllocatedLane, + AllocatedTask, + ParsedTask, + SegmentExpansionRequest, + TaskSegmentPlan, +} from "../taskplane/types.ts"; function makeTask(taskId: string, repoId?: string): ParsedTask { return { @@ -31,7 +37,9 @@ function makeTask(taskId: string, repoId?: string): ParsedTask { }; } -function makeExpansionRequest(overrides: Partial = {}): SegmentExpansionRequest { +function makeExpansionRequest( + overrides: Partial = {}, +): SegmentExpansionRequest { return { requestId: "exp-001", taskId: "TP-100", @@ -47,9 +55,7 @@ function makeExpansionRequest(overrides: Partial = {}): describe("TP-133 segment frontier helpers", () => { it("repo-singleton tasks keep one execution round", () => { - const pending = new Map([ - ["TP-001", makeTask("TP-001", "api")], - ]); + const pending = new Map([["TP-001", makeTask("TP-001", "api")]]); const frontier = buildSegmentFrontierWaves([["TP-001"]], pending); expect(frontier.waves).toEqual([["TP-001"]]); @@ -62,9 +68,7 @@ describe("TP-133 segment frontier helpers", () => { }); it("repo mode does not synthesize resolvedRepoId during frontier expansion", () => { - const pending = new Map([ - ["TP-002", makeTask("TP-002")], - ]); + const pending = new Map([["TP-002", makeTask("TP-002")]]); buildSegmentFrontierWaves([["TP-002"]], pending); expect(pending.get("TP-002")!.resolvedRepoId).toBeUndefined(); @@ -77,9 +81,7 @@ describe("TP-133 segment frontier helpers", () => { }); it("multi-segment task is decomposed into sequential rounds", () => { - const pending = new Map([ - ["TP-010", makeTask("TP-010", "api")], - ]); + const pending = new Map([["TP-010", makeTask("TP-010", "api")]]); const plan: TaskSegmentPlan = { taskId: "TP-010", @@ -90,16 +92,22 @@ describe("TP-133 segment frontier helpers", () => { { segmentId: "TP-010::docs", taskId: "TP-010", repoId: "docs", order: 2 }, ], edges: [ - { fromSegmentId: "TP-010::api", toSegmentId: "TP-010::web", provenance: "explicit", reason: "explicit" }, - { fromSegmentId: "TP-010::web", toSegmentId: "TP-010::docs", provenance: "explicit", reason: "explicit" }, + { + fromSegmentId: "TP-010::api", + toSegmentId: "TP-010::web", + provenance: "explicit", + reason: "explicit", + }, + { + fromSegmentId: "TP-010::web", + toSegmentId: "TP-010::docs", + provenance: "explicit", + reason: "explicit", + }, ], }; - const frontier = buildSegmentFrontierWaves( - [["TP-010"]], - pending, - new Map([["TP-010", plan]]), - ); + const frontier = buildSegmentFrontierWaves([["TP-010"]], pending, new Map([["TP-010", plan]])); expect(frontier.waves).toEqual([["TP-010"], ["TP-010"], ["TP-010"]]); // TP-166: Task-level wave count should be 1 (one original wave), not 3 @@ -123,8 +131,18 @@ describe("TP-133 segment frontier helpers", () => { { segmentId: "TP-020::docs", taskId: "TP-020", repoId: "docs", order: 2 }, ], edges: [ - { fromSegmentId: "TP-020::api", toSegmentId: "TP-020::docs", provenance: "explicit", reason: "explicit" }, - { fromSegmentId: "TP-020::web", toSegmentId: "TP-020::docs", provenance: "explicit", reason: "explicit" }, + { + fromSegmentId: "TP-020::api", + toSegmentId: "TP-020::docs", + provenance: "explicit", + reason: "explicit", + }, + { + fromSegmentId: "TP-020::web", + toSegmentId: "TP-020::docs", + provenance: "explicit", + reason: "explicit", + }, ], }; @@ -177,7 +195,7 @@ describe("segment expansion boundary validation smoke", () => { "agent-1", { filePath: "/tmp/segment-expansion-exp-001.json", request }, { terminalStatus: "pending" } as any, - { repos: new Map([ ["api", {}] ]) } as any, + { repos: new Map([["api", {}]]) } as any, new Set(), ); expect(result.ok).toBe(false); @@ -196,7 +214,7 @@ describe("segment expansion boundary validation smoke", () => { "agent-1", { filePath: "/tmp/segment-expansion-exp-001.json", request }, { terminalStatus: "pending" } as any, - { repos: new Map([ ["api", {}] ]) } as any, + { repos: new Map([["api", {}]]) } as any, knownRequestIds, ); expect(first).toEqual({ ok: true }); @@ -209,7 +227,7 @@ describe("segment expansion boundary validation smoke", () => { "agent-1", { filePath: "/tmp/segment-expansion-exp-001-dupe.json", request }, { terminalStatus: "pending" } as any, - { repos: new Map([ ["api", {}] ]) } as any, + { repos: new Map([["api", {}]]) } as any, knownRequestIds, ); expect(duplicate.ok).toBe(false); @@ -293,7 +311,9 @@ describe("segment expansion graph mutation", () => { const mutation = applySegmentExpansionMutation(segmentState, request, "TP-007::api-service"); expect(mutation.insertedSegmentIds).toEqual(["TP-007::web-client"]); - expect(segmentState.dependsOnBySegmentId.get("TP-007::web-client")).toEqual(["TP-007::api-service"]); + expect(segmentState.dependsOnBySegmentId.get("TP-007::web-client")).toEqual([ + "TP-007::api-service", + ]); expect(segmentState.dependsOnBySegmentId.get("TP-007::docs")).toEqual(["TP-007::web-client"]); expect(segmentState.orderedSegments.map((segment: any) => segment.segmentId)).toEqual([ "TP-007::api-service", @@ -346,7 +366,9 @@ describe("segment expansion graph mutation", () => { ); expect(changed).toBe(true); - const webRecord = batchState.segments.find((record: any) => record.segmentId === "TP-007::web-client"); + const webRecord = batchState.segments.find( + (record: any) => record.segmentId === "TP-007::web-client", + ); expect(webRecord).toBeTruthy(); expect(webRecord.taskId).toBe("TP-007"); expect(webRecord.repoId).toBe("web-client"); @@ -389,7 +411,10 @@ describe("segment expansion graph mutation", () => { const result = applySegmentExpansionMutation(segmentState, request, "TP-301::docs"); expect(result.insertedSegmentIds).toEqual(["TP-301::ops", "TP-301::infra"]); - expect(segmentState.dependsOnBySegmentId.get("TP-301::ops")?.sort()).toEqual(["TP-301::docs", "TP-301::web"]); + expect(segmentState.dependsOnBySegmentId.get("TP-301::ops")?.sort()).toEqual([ + "TP-301::docs", + "TP-301::web", + ]); expect(segmentState.dependsOnBySegmentId.get("TP-301::infra")).toEqual(["TP-301::ops"]); expect(segmentState.orderedSegments.map((segment: any) => segment.segmentId).slice(-2)).toEqual([ "TP-301::ops", @@ -461,7 +486,9 @@ describe("segment expansion graph mutation", () => { "TP-008::api-service", "TP-008::shared-libs::2", ]); - expect(segmentState.dependsOnBySegmentId.get("TP-008::shared-libs::2")).toEqual(["TP-008::api-service"]); + expect(segmentState.dependsOnBySegmentId.get("TP-008::shared-libs::2")).toEqual([ + "TP-008::api-service", + ]); }); it("TP-008 repeat-repo insertion rewires downstream dependents through shared-libs::2", () => { @@ -495,8 +522,12 @@ describe("segment expansion graph mutation", () => { const mutation = applySegmentExpansionMutation(segmentState, request, "TP-008::api-service"); expect(mutation.insertedSegmentIds).toEqual(["TP-008::shared-libs::2"]); - expect(segmentState.dependsOnBySegmentId.get("TP-008::shared-libs::2")).toEqual(["TP-008::api-service"]); - expect(segmentState.dependsOnBySegmentId.get("TP-008::web-client")).toEqual(["TP-008::shared-libs::2"]); + expect(segmentState.dependsOnBySegmentId.get("TP-008::shared-libs::2")).toEqual([ + "TP-008::api-service", + ]); + expect(segmentState.dependsOnBySegmentId.get("TP-008::web-client")).toEqual([ + "TP-008::shared-libs::2", + ]); }); it("TP-008 repeat-repo persistence uses orch-branch provisioning metadata for shared-libs::2", () => { @@ -539,7 +570,9 @@ describe("segment expansion graph mutation", () => { ); expect(changed).toBe(true); - const secondPassRecord = batchState.segments.find((record: any) => record.segmentId === "TP-008::shared-libs::2"); + const secondPassRecord = batchState.segments.find( + (record: any) => record.segmentId === "TP-008::shared-libs::2", + ); expect(secondPassRecord).toBeTruthy(); expect(secondPassRecord.repoId).toBe("shared-libs"); expect(secondPassRecord.branch).toBe("orch/tp-008"); @@ -550,17 +583,10 @@ describe("segment expansion graph mutation", () => { }); it("continuation round insertion keeps expanded tasks executable before the next planned task wave", () => { - const runtimeRounds = [ - ["TP-400"], - ["TP-500"], - ]; + const runtimeRounds = [["TP-400"], ["TP-500"]]; const inserted = scheduleContinuationSegmentRound(runtimeRounds, 0, ["TP-400"]); expect(inserted).toEqual(["TP-400"]); - expect(runtimeRounds).toEqual([ - ["TP-400"], - ["TP-400"], - ["TP-500"], - ]); + expect(runtimeRounds).toEqual([["TP-400"], ["TP-400"], ["TP-500"]]); }); it("resyncs persisted pending dependencies across sequential approved requests on one boundary", () => { @@ -628,7 +654,16 @@ describe("segment expansion graph mutation", () => { it("approval path persists mutation state before renaming request file to .processed", () => { const src = readFileSync(new URL("../taskplane/engine.ts", import.meta.url), "utf-8"); - expect(src).toMatch(/persistRuntimeState\("segment-expansion-approved"[\s\S]*markSegmentExpansionRequestFile\(pendingRequest\.filePath, "processed"\)/); + // TP-193: Whitespace-normalize so the formatter's vertical re-wrapping + // of multi-arg calls (and trailing commas) doesn't break the regex. + const normSrc = src + .replace(/\s+/g, " ") + .replace(/([(\[{])\s+/g, "$1") + .replace(/\s+([)\]},])/g, "$1") + .replace(/,([)\]}])/g, "$1"); + expect(normSrc).toMatch( + /persistRuntimeState\("segment-expansion-approved"[\s\S]*?markSegmentExpansionRequestFile\(pendingRequest\.filePath, "processed"\)/, + ); }); it("pending segment persistence carries expansion provenance and orch-branch provisioning metadata", () => { @@ -772,10 +807,7 @@ describe("TP-169 buildExecutionUnit taskFolder guard", () => { describe("TP-169 workspace orch branch: ensureTaskFilesCommitted is exported", () => { it("ensureTaskFilesCommitted accepts orchBranch parameter", () => { // Structural test: ensureTaskFilesCommitted signature includes orchBranch - const execSrc = readFileSync( - new URL("../taskplane/execution.ts", import.meta.url), - "utf-8", - ); + const execSrc = readFileSync(new URL("../taskplane/execution.ts", import.meta.url), "utf-8"); const fnIdx = execSrc.indexOf("function ensureTaskFilesCommitted"); const sig = execSrc.slice(fnIdx, fnIdx + 300); expect(sig).toContain("orchBranch"); diff --git a/extensions/tests/engine-worker-thread.test.ts b/extensions/tests/engine-worker-thread.test.ts index e9707f81..d0dfbfdd 100644 --- a/extensions/tests/engine-worker-thread.test.ts +++ b/extensions/tests/engine-worker-thread.test.ts @@ -92,9 +92,7 @@ describe("1.x — Workspace config serialization", () => { it("1.5: roundtrip preserves workspace config", () => { const original: WorkspaceConfig = { mode: "workspace", - repos: new Map([ - ["backend", { path: "/repo/backend", defaultBranch: "main" } as any], - ]), + repos: new Map([["backend", { path: "/repo/backend", defaultBranch: "main" } as any]]), routing: { tasksRoot: "/tasks", defaultRepo: "backend", strict: true } as any, configPath: "/ws/config.json", }; @@ -232,7 +230,7 @@ describe("3.x — Engine worker entry point structure", () => { it("3.8: engine-worker.ts guards execution with fork sentinel check", () => { const src = readSource("engine-worker.ts"); - expect(src).toContain('TASKPLANE_ENGINE_FORK'); + expect(src).toContain("TASKPLANE_ENGINE_FORK"); expect(src).toContain("process.send"); }); @@ -240,14 +238,18 @@ describe("3.x — Engine worker entry point structure", () => { const src = readSource("engine-worker.ts"); expect(src).toContain('process.once("uncaughtException"'); expect(src).toContain('reportFatalAndExit("uncaughtException"'); - expect(src).toContain('WorkerErrorSource = "enginePromise" | "uncaughtException" | "unhandledRejection"'); + expect(src).toContain( + 'WorkerErrorSource = "enginePromise" | "uncaughtException" | "unhandledRejection"', + ); }); it("3.10: engine-worker.ts registers unhandledRejection process-level handler", () => { const src = readSource("engine-worker.ts"); expect(src).toContain('process.once("unhandledRejection"'); expect(src).toContain('reportFatalAndExit("unhandledRejection"'); - expect(src).toContain('WorkerErrorSource = "enginePromise" | "uncaughtException" | "unhandledRejection"'); + expect(src).toContain( + 'WorkerErrorSource = "enginePromise" | "uncaughtException" | "unhandledRejection"', + ); }); }); @@ -258,7 +260,7 @@ describe("3.x — Engine worker entry point structure", () => { describe("4.x — Extension worker thread integration", () => { it("4.1: extension.ts imports fork from child_process", () => { const src = readSource("extension.ts"); - expect(src).toContain('import { fork'); + expect(src).toContain("import { fork"); expect(src).toContain('"child_process"'); }); diff --git a/extensions/tests/exec-check-error-classification.test.ts b/extensions/tests/exec-check-error-classification.test.ts index 94fb5ccf..9ad2f8ce 100644 --- a/extensions/tests/exec-check-error-classification.test.ts +++ b/extensions/tests/exec-check-error-classification.test.ts @@ -53,11 +53,7 @@ describe("execCheck — error classification", () => { it("classifies a timeout as 'timeout' with the configured duration", () => { // Spawn node with a long sleep, but cap the execCheck timeout at 250ms. - const result = execCheck( - `node -e "setTimeout(() => process.exit(0), 5000)"`, - undefined, - 250, - ); + const result = execCheck(`node -e "setTimeout(() => process.exit(0), 5000)"`, undefined, 250); assert.strictEqual(result.ok, false); assert.strictEqual( result.errorKind, @@ -87,11 +83,7 @@ describe("execCheck — error classification", () => { it("does NOT misclassify a timeout as 'not-found' (regression for #TP-185)", () => { // This is the exact failure mode that produced misleading "Pi not found" // errors in production: a slow-but-installed binary on a cold start. - const result = execCheck( - `node -e "setTimeout(() => process.exit(0), 5000)"`, - undefined, - 200, - ); + const result = execCheck(`node -e "setTimeout(() => process.exit(0), 5000)"`, undefined, 200); assert.strictEqual(result.ok, false); assert.notStrictEqual( result.errorKind, diff --git a/extensions/tests/execution-path-resolution.test.ts b/extensions/tests/execution-path-resolution.test.ts index 391b073f..a55a895a 100644 --- a/extensions/tests/execution-path-resolution.test.ts +++ b/extensions/tests/execution-path-resolution.test.ts @@ -22,10 +22,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; import { join, resolve } from "path"; import { tmpdir } from "os"; -import { - resolveCanonicalTaskPaths, - resolveTaskDonePath, -} from "../task-orchestrator.ts"; +import { resolveCanonicalTaskPaths, resolveTaskDonePath } from "../task-orchestrator.ts"; const isTestRunner = !!(process.env.NODE_TEST_CONTEXT || process.env.VITEST); @@ -133,24 +130,42 @@ function runMonorepoTests(): void { { console.log(" ▸ 1.1 no files exist — returns worktree-translated primary paths"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(expectedFolder), - "1.1 taskFolderResolved is in worktree"); - assertEqual(norm(result.donePath), norm(join(expectedFolder, ".DONE")), - "1.1 donePath is in worktree"); - assertEqual(norm(result.statusPath), norm(join(expectedFolder, "STATUS.md")), - "1.1 statusPath is in worktree"); + assertEqual( + norm(result.taskFolderResolved), + norm(expectedFolder), + "1.1 taskFolderResolved is in worktree", + ); + assertEqual( + norm(result.donePath), + norm(join(expectedFolder, ".DONE")), + "1.1 donePath is in worktree", + ); + assertEqual( + norm(result.statusPath), + norm(join(expectedFolder, "STATUS.md")), + "1.1 statusPath is in worktree", + ); } { console.log(" ▸ 1.2 STATUS.md exists in worktree — returns primary paths"); writeFileSync(join(expectedFolder, "STATUS.md"), "# Status\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(expectedFolder), - "1.2 taskFolderResolved is in worktree"); - assertEqual(norm(result.donePath), norm(join(expectedFolder, ".DONE")), - "1.2 donePath points to worktree .DONE"); - assertEqual(norm(result.statusPath), norm(join(expectedFolder, "STATUS.md")), - "1.2 statusPath points to worktree STATUS.md"); + assertEqual( + norm(result.taskFolderResolved), + norm(expectedFolder), + "1.2 taskFolderResolved is in worktree", + ); + assertEqual( + norm(result.donePath), + norm(join(expectedFolder, ".DONE")), + "1.2 donePath points to worktree .DONE", + ); + assertEqual( + norm(result.statusPath), + norm(join(expectedFolder, "STATUS.md")), + "1.2 statusPath points to worktree STATUS.md", + ); rmSync(join(expectedFolder, "STATUS.md")); } @@ -158,10 +173,12 @@ function runMonorepoTests(): void { console.log(" ▸ 1.3 .DONE exists in worktree — returns primary paths"); writeFileSync(join(expectedFolder, ".DONE"), "done\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(expectedFolder), - "1.3 taskFolderResolved is in worktree"); - assert(norm(result.donePath).endsWith(".DONE"), - "1.3 donePath ends with .DONE"); + assertEqual( + norm(result.taskFolderResolved), + norm(expectedFolder), + "1.3 taskFolderResolved is in worktree", + ); + assert(norm(result.donePath).endsWith(".DONE"), "1.3 donePath ends with .DONE"); rmSync(join(expectedFolder, ".DONE")); } @@ -173,8 +190,11 @@ function runMonorepoTests(): void { mkdirSync(deepWorktreeFolder, { recursive: true }); const result = resolveCanonicalTaskPaths(deepTaskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(deepWorktreeFolder), - "1.4 deeply nested task folder resolves correctly in worktree"); + assertEqual( + norm(result.taskFolderResolved), + norm(deepWorktreeFolder), + "1.4 deeply nested task folder resolves correctly in worktree", + ); } } @@ -204,34 +224,50 @@ function runExternalTests(): void { { console.log(" ▸ 2.1 no files exist — returns absolute task folder path (not worktree)"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "2.1 taskFolderResolved is absolute (external)"); - assertEqual(norm(result.donePath), norm(join(taskFolder, ".DONE")), - "2.1 donePath is absolute (external)"); - assertEqual(norm(result.statusPath), norm(join(taskFolder, "STATUS.md")), - "2.1 statusPath is absolute (external)"); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "2.1 taskFolderResolved is absolute (external)", + ); + assertEqual( + norm(result.donePath), + norm(join(taskFolder, ".DONE")), + "2.1 donePath is absolute (external)", + ); + assertEqual( + norm(result.statusPath), + norm(join(taskFolder, "STATUS.md")), + "2.1 statusPath is absolute (external)", + ); } { console.log(" ▸ 2.2 taskFolderResolved must NOT be under worktree"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assert(!norm(result.taskFolderResolved).startsWith(norm(worktreePath) + "/"), - "2.2 external task folder is NOT translated to worktree path"); + assert( + !norm(result.taskFolderResolved).startsWith(norm(worktreePath) + "/"), + "2.2 external task folder is NOT translated to worktree path", + ); } { console.log(" ▸ 2.3 taskFolderResolved must NOT be under repoRoot"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assert(!norm(result.taskFolderResolved).startsWith(norm(repoRoot) + "/"), - "2.3 external task folder is NOT under repoRoot"); + assert( + !norm(result.taskFolderResolved).startsWith(norm(repoRoot) + "/"), + "2.3 external task folder is NOT under repoRoot", + ); } { console.log(" ▸ 2.4 STATUS.md exists in external task folder — returns primary paths"); writeFileSync(join(taskFolder, "STATUS.md"), "# Status\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "2.4 taskFolderResolved is absolute (external, with STATUS.md)"); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "2.4 taskFolderResolved is absolute (external, with STATUS.md)", + ); rmSync(join(taskFolder, "STATUS.md")); } @@ -239,10 +275,16 @@ function runExternalTests(): void { console.log(" ▸ 2.5 .DONE exists in external task folder — returns primary paths"); writeFileSync(join(taskFolder, ".DONE"), "done\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "2.5 taskFolderResolved is absolute (external, with .DONE)"); - assertEqual(norm(result.donePath), norm(join(taskFolder, ".DONE")), - "2.5 donePath points to external .DONE"); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "2.5 taskFolderResolved is absolute (external, with .DONE)", + ); + assertEqual( + norm(result.donePath), + norm(join(taskFolder, ".DONE")), + "2.5 donePath points to external .DONE", + ); rmSync(join(taskFolder, ".DONE")); } @@ -252,8 +294,11 @@ function runExternalTests(): void { mkdirSync(altWorktree, { recursive: true }); const result1 = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); const result2 = resolveCanonicalTaskPaths(taskFolder, altWorktree, repoRoot); - assertEqual(norm(result1.taskFolderResolved), norm(result2.taskFolderResolved), - "2.6 external resolution is worktree-independent"); + assertEqual( + norm(result1.taskFolderResolved), + norm(result2.taskFolderResolved), + "2.6 external resolution is worktree-independent", + ); } } @@ -281,12 +326,21 @@ function runArchiveFallbackTests(): void { writeFileSync(join(archiveInWorktree, ".DONE"), "done\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(archiveInWorktree), - "3.1 monorepo archive: taskFolderResolved points to archive"); - assertEqual(norm(result.donePath), norm(join(archiveInWorktree, ".DONE")), - "3.1 monorepo archive: donePath points to archive .DONE"); - assertEqual(norm(result.statusPath), norm(join(archiveInWorktree, "STATUS.md")), - "3.1 monorepo archive: statusPath points to archive STATUS.md"); + assertEqual( + norm(result.taskFolderResolved), + norm(archiveInWorktree), + "3.1 monorepo archive: taskFolderResolved points to archive", + ); + assertEqual( + norm(result.donePath), + norm(join(archiveInWorktree, ".DONE")), + "3.1 monorepo archive: donePath points to archive .DONE", + ); + assertEqual( + norm(result.statusPath), + norm(join(archiveInWorktree, "STATUS.md")), + "3.1 monorepo archive: statusPath points to archive STATUS.md", + ); } // 3.2 External archive fallback @@ -306,10 +360,16 @@ function runArchiveFallbackTests(): void { writeFileSync(join(archiveExternal, "STATUS.md"), "# Archived\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(archiveExternal), - "3.2 external archive: taskFolderResolved points to archive"); - assertEqual(norm(result.statusPath), norm(join(archiveExternal, "STATUS.md")), - "3.2 external archive: statusPath points to archive STATUS.md"); + assertEqual( + norm(result.taskFolderResolved), + norm(archiveExternal), + "3.2 external archive: taskFolderResolved points to archive", + ); + assertEqual( + norm(result.statusPath), + norm(join(archiveExternal, "STATUS.md")), + "3.2 external archive: statusPath points to archive STATUS.md", + ); } // 3.3 No archive exists — returns primary paths @@ -324,10 +384,15 @@ function runArchiveFallbackTests(): void { mkdirSync(taskFolder, { recursive: true }); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assert(!norm(result.taskFolderResolved).includes("archive"), - "3.3 taskFolderResolved does not include 'archive' when no archive exists"); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "3.3 taskFolderResolved is the original external path"); + assert( + !norm(result.taskFolderResolved).includes("archive"), + "3.3 taskFolderResolved does not include 'archive' when no archive exists", + ); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "3.3 taskFolderResolved is the original external path", + ); } // 3.4 Primary exists AND archive exists — primary takes precedence @@ -349,8 +414,11 @@ function runArchiveFallbackTests(): void { writeFileSync(join(archiveFolder, ".DONE"), "done\n"); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "3.4 primary takes precedence over archive"); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "3.4 primary takes precedence over archive", + ); } } @@ -376,8 +444,11 @@ function runDelegationTests(): void { const canonical = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); const donePath = resolveTaskDonePath(taskFolder, worktreePath, repoRoot); - assertEqual(norm(donePath), norm(canonical.donePath), - "4.1 resolveTaskDonePath == resolveCanonicalTaskPaths.donePath (monorepo)"); + assertEqual( + norm(donePath), + norm(canonical.donePath), + "4.1 resolveTaskDonePath == resolveCanonicalTaskPaths.donePath (monorepo)", + ); } { @@ -387,8 +458,11 @@ function runDelegationTests(): void { const canonical = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); const donePath = resolveTaskDonePath(taskFolder, worktreePath, repoRoot); - assertEqual(norm(donePath), norm(canonical.donePath), - "4.2 resolveTaskDonePath == resolveCanonicalTaskPaths.donePath (external)"); + assertEqual( + norm(donePath), + norm(canonical.donePath), + "4.2 resolveTaskDonePath == resolveCanonicalTaskPaths.donePath (external)", + ); } } @@ -410,8 +484,11 @@ function runEdgeCaseTests(): void { const result = resolveCanonicalTaskPaths(repoRoot, worktreePath, repoRoot); // repoRoot does NOT start with repoRoot + "/" so it's case 2 (external) - assertEqual(norm(result.taskFolderResolved), norm(repoRoot), - "5.1 task folder == repo root: treated as external (exact match, no trailing slash)"); + assertEqual( + norm(result.taskFolderResolved), + norm(repoRoot), + "5.1 task folder == repo root: treated as external (exact match, no trailing slash)", + ); } { @@ -424,10 +501,15 @@ function runEdgeCaseTests(): void { mkdirSync(taskFolder, { recursive: true }); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "5.2 sibling of repo root is external"); - assert(!norm(result.taskFolderResolved).startsWith(norm(worktreePath) + "/"), - "5.2 sibling task folder not mapped to worktree"); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "5.2 sibling of repo root is external", + ); + assert( + !norm(result.taskFolderResolved).startsWith(norm(worktreePath) + "/"), + "5.2 sibling task folder not mapped to worktree", + ); } { @@ -442,8 +524,11 @@ function runEdgeCaseTests(): void { mkdirSync(taskFolder, { recursive: true }); const result = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot); - assertEqual(norm(result.taskFolderResolved), norm(taskFolder), - "5.3 prefix overlap: not confused by repoRoot being prefix of different path"); + assertEqual( + norm(result.taskFolderResolved), + norm(taskFolder), + "5.3 prefix overlap: not confused by repoRoot being prefix of different path", + ); } { @@ -463,8 +548,11 @@ function runEdgeCaseTests(): void { const backslashWt = worktreePath.replace(/\//g, "\\"); const result = resolveCanonicalTaskPaths(backslashTask, backslashWt, backslashRepo); - assertEqual(norm(result.taskFolderResolved), norm(wtMirror), - "5.4 backslash paths: monorepo resolution works with backslash input"); + assertEqual( + norm(result.taskFolderResolved), + norm(wtMirror), + "5.4 backslash paths: monorepo resolution works with backslash input", + ); } { @@ -480,12 +568,20 @@ function runEdgeCaseTests(): void { const r1 = resolveCanonicalTaskPaths(taskFolder, wt1, repoRoot); const r2 = resolveCanonicalTaskPaths(taskFolder, wt2, repoRoot); - assert(norm(r1.taskFolderResolved) !== norm(r2.taskFolderResolved), - "5.5 different worktrees produce different resolved paths (monorepo)"); - assertEqual(norm(r1.taskFolderResolved), norm(join(wt1, "tasks", "TP-LANE")), - "5.5 lane 1 maps to wt1"); - assertEqual(norm(r2.taskFolderResolved), norm(join(wt2, "tasks", "TP-LANE")), - "5.5 lane 2 maps to wt2"); + assert( + norm(r1.taskFolderResolved) !== norm(r2.taskFolderResolved), + "5.5 different worktrees produce different resolved paths (monorepo)", + ); + assertEqual( + norm(r1.taskFolderResolved), + norm(join(wt1, "tasks", "TP-LANE")), + "5.5 lane 1 maps to wt1", + ); + assertEqual( + norm(r2.taskFolderResolved), + norm(join(wt2, "tasks", "TP-LANE")), + "5.5 lane 2 maps to wt2", + ); } { @@ -502,10 +598,16 @@ function runEdgeCaseTests(): void { const r1 = resolveCanonicalTaskPaths(taskFolder, wt1, repoRoot); const r2 = resolveCanonicalTaskPaths(taskFolder, wt2, repoRoot); - assertEqual(norm(r1.taskFolderResolved), norm(r2.taskFolderResolved), - "5.6 external: same canonical path regardless of worktree"); - assertEqual(norm(r1.donePath), norm(r2.donePath), - "5.6 external: same donePath regardless of worktree"); + assertEqual( + norm(r1.taskFolderResolved), + norm(r2.taskFolderResolved), + "5.6 external: same canonical path regardless of worktree", + ); + assertEqual( + norm(r1.donePath), + norm(r2.donePath), + "5.6 external: same donePath regardless of worktree", + ); } } diff --git a/extensions/tests/exit-classification.test.ts b/extensions/tests/exit-classification.test.ts index 8d710cec..5cfe4bb1 100644 --- a/extensions/tests/exit-classification.test.ts +++ b/extensions/tests/exit-classification.test.ts @@ -67,9 +67,7 @@ describe("classifyExit — all 9 classification paths", () => { name: "model_access_error — retries with rate_limit_exceeded pattern", input: makeInput({ exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "rate_limit_exceeded", delayMs: 5000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "rate_limit_exceeded", delayMs: 5000, succeeded: false }], }), }), expected: "model_access_error", @@ -102,9 +100,7 @@ describe("classifyExit — all 9 classification paths", () => { input: makeInput({ exitSummary: makeSummary({ exitCode: 1, - retries: [ - { attempt: 1, error: "rate_limit", delayMs: 1000, succeeded: true }, - ], + retries: [{ attempt: 1, error: "rate_limit", delayMs: 1000, succeeded: true }], }), }), // last retry succeeded → skip api_error, move to process_crash (exitCode=1) @@ -401,32 +397,40 @@ describe("classifyExit — edge cases", () => { }); it("exitCode === null (killed by signal) → skips process_crash", () => { - const result = classifyExit(makeInput({ - exitSummary: makeSummary({ exitCode: null, exitSignal: "SIGTERM" }), - })); + const result = classifyExit( + makeInput({ + exitSummary: makeSummary({ exitCode: null, exitSignal: "SIGTERM" }), + }), + ); // exitCode is null (not a number), so process_crash check doesn't fire expect(result).toBe("unknown"); }); it("exitCode === 0 → not process_crash (clean exit)", () => { - const result = classifyExit(makeInput({ - exitSummary: makeSummary({ exitCode: 0 }), - })); + const result = classifyExit( + makeInput({ + exitSummary: makeSummary({ exitCode: 0 }), + }), + ); expect(result).toBe("unknown"); }); it("empty retries array → not api_error", () => { - const result = classifyExit(makeInput({ - exitSummary: makeSummary({ retries: [], exitCode: 0 }), - })); + const result = classifyExit( + makeInput({ + exitSummary: makeSummary({ retries: [], exitCode: 0 }), + }), + ); expect(result).toBe("unknown"); }); it("compactions > 0 but contextPct exactly 89 → not context_overflow", () => { - const result = classifyExit(makeInput({ - exitSummary: makeSummary({ compactions: 1, exitCode: 0 }), - contextPct: 89, - })); + const result = classifyExit( + makeInput({ + exitSummary: makeSummary({ compactions: 1, exitCode: 0 }), + contextPct: 89, + }), + ); // 89 < 90 threshold → not context_overflow, exitCode=0 → not crash → unknown expect(result).toBe("unknown"); }); @@ -434,28 +438,34 @@ describe("classifyExit — edge cases", () => { it("contextKilled → context_overflow (even without compactions or summary)", () => { // Task-runner explicitly killed the session due to context limit, // but wrapper crashed before writing exit summary - const result = classifyExit(makeInput({ - exitSummary: null, - contextKilled: true, - })); + const result = classifyExit( + makeInput({ + exitSummary: null, + contextKilled: true, + }), + ); // contextKilled (3b) beats session_vanished (6) expect(result).toBe("context_overflow"); }); it("contextKilled → context_overflow (summary exists but compactions=0)", () => { // Wrapper didn't record compactions but task-runner detected context limit - const result = classifyExit(makeInput({ - exitSummary: makeSummary({ compactions: 0, exitCode: 0 }), - contextKilled: true, - contextPct: 50, - })); + const result = classifyExit( + makeInput({ + exitSummary: makeSummary({ compactions: 0, exitCode: 0 }), + contextKilled: true, + contextPct: 50, + }), + ); expect(result).toBe("context_overflow"); }); it("contextKilled=false (default) → no change to existing behavior", () => { - const result = classifyExit(makeInput({ - exitSummary: makeSummary({ exitCode: 0 }), - })); + const result = classifyExit( + makeInput({ + exitSummary: makeSummary({ exitCode: 0 }), + }), + ); // contextKilled defaults to false via ?? in classifyExit expect(result).toBe("unknown"); }); @@ -484,9 +494,17 @@ describe("EXIT_CLASSIFICATIONS constant", () => { it("includes all expected values", () => { const expected: ExitClassification[] = [ - "completed", "api_error", "model_access_error", "context_overflow", - "wall_clock_timeout", "process_crash", "session_vanished", - "stall_timeout", "user_killed", "spawn_failure", "unknown", + "completed", + "api_error", + "model_access_error", + "context_overflow", + "wall_clock_timeout", + "process_crash", + "session_vanished", + "stall_timeout", + "user_killed", + "spawn_failure", + "unknown", ]; for (const val of expected) { expect(EXIT_CLASSIFICATIONS).toContain(val); diff --git a/extensions/tests/exit-interception.test.ts b/extensions/tests/exit-interception.test.ts index 914812b1..434e34a3 100644 --- a/extensions/tests/exit-interception.test.ts +++ b/extensions/tests/exit-interception.test.ts @@ -20,13 +20,18 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const agentHostSrc = readFileSync(join(__dirname, "..", "taskplane", "agent-host.ts"), "utf-8"); const laneRunnerSrc = readFileSync(join(__dirname, "..", "taskplane", "lane-runner.ts"), "utf-8"); const typesSrc = readFileSync(join(__dirname, "..", "taskplane", "types.ts"), "utf-8"); -const supervisorPrimerSrc = readFileSync(join(__dirname, "..", "taskplane", "supervisor-primer.md"), "utf-8"); +const supervisorPrimerSrc = readFileSync( + join(__dirname, "..", "taskplane", "supervisor-primer.md"), + "utf-8", +); // ── 1. Agent-host exit interception contract ──────────────────────── describe("1.x: Agent-host exit interception (TP-172)", () => { it("1.1: AgentHostOptions has onPrematureExit callback", () => { - expect(agentHostSrc).toContain("onPrematureExit?: (assistantMessage: string) => Promise"); + expect(agentHostSrc).toContain( + "onPrematureExit?: (assistantMessage: string) => Promise", + ); }); it("1.2: AgentHostOptions has maxExitInterceptions option", () => { @@ -73,8 +78,8 @@ describe("1.x: Agent-host exit interception (TP-172)", () => { expect(agentHostSrc).toContain("interceptionCount:"); expect(agentHostSrc).toContain("assistantMessage:"); expect(agentHostSrc).toContain("supervisorConsulted:"); - expect(agentHostSrc).toContain("action: \"reprompt\""); - expect(agentHostSrc).toContain("action: \"close\""); + expect(agentHostSrc).toContain('action: "reprompt"'); + expect(agentHostSrc).toContain('action: "close"'); }); it("1.9: callback invocation is wrapped for synchronous throw safety", () => { @@ -162,7 +167,9 @@ describe("2.x: Lane-runner supervisor escalation (TP-172)", () => { it("2.11: close directives cause session to close normally", () => { const closeIdx = laneRunnerSrc.indexOf("CLOSE_DIRECTIVES"); - const closeBlock = laneRunnerSrc.slice(closeIdx, closeIdx + 800); + // Use a generous window so cosmetic re-wrapping by the formatter doesn't + // push `return null` outside the slice. + const closeBlock = laneRunnerSrc.slice(closeIdx, closeIdx + 1500); expect(closeBlock).toContain('"skip"'); expect(closeBlock).toContain('"let it fail"'); expect(closeBlock).toContain('"close"'); diff --git a/extensions/tests/expect.ts b/extensions/tests/expect.ts index e8917428..b346f919 100644 --- a/extensions/tests/expect.ts +++ b/extensions/tests/expect.ts @@ -11,6 +11,14 @@ interface ExpectMethods { toBe(expected: unknown): void; toEqual(expected: unknown): void; toContain(needle: unknown): void; + /** + * Like `toContain`, but whitespace-insensitive when matching strings. + * Both haystack and needle have any run of whitespace collapsed to a + * single space before checking. Intended for source-grep tests so they + * survive cosmetic formatter changes (line wrapping, indentation, + * inserted parentheses around arrow params, etc.). + */ + toContainNormalized(needle: string): void; toHaveLength(n: number): void; toBeDefined(): void; toBeUndefined(): void; @@ -47,14 +55,35 @@ export function expect(actual: unknown): ExpectMethods { `Expected string to contain "${needle}", but got: "${actual}"`, ); } else if (Array.isArray(actual)) { - assert.ok( - actual.includes(needle), - `Expected array to contain ${JSON.stringify(needle)}`, - ); + assert.ok(actual.includes(needle), `Expected array to contain ${JSON.stringify(needle)}`); } else { assert.fail(`toContain: actual is neither string nor array`); } }, + toContainNormalized(needle: string) { + assert.ok( + typeof actual === "string", + `toContainNormalized: actual must be a string, got ${typeof actual}`, + ); + // Collapse runs of whitespace, strip whitespace adjacent to brackets + // and commas, and drop trailing commas before close-brackets so + // source-grep needles like `foo(a, b, c)` match formatter output + // `foo(\n\ta,\n\tb,\n\tc,\n)` after vertical re-wrapping with + // trailingCommas: "all". + const normalize = (s: string) => + s + .replace(/\s+/g, " ") + .replace(/([(\[{])\s+/g, "$1") + .replace(/\s+([)\]},])/g, "$1") + .replace(/,([)\]}])/g, "$1") + .trim(); + const hayN = normalize(actual as string); + const needleN = normalize(needle); + assert.ok( + hayN.includes(needleN), + `Expected (whitespace-normalized) string to contain "${needleN}"`, + ); + }, toHaveLength(n: number) { assert.strictEqual((actual as any).length, n); }, @@ -74,28 +103,16 @@ export function expect(actual: unknown): ExpectMethods { assert.ok(!actual, `Expected falsy value, got: ${actual}`); }, toBeGreaterThan(n: number) { - assert.ok( - (actual as number) > n, - `Expected ${actual} > ${n}`, - ); + assert.ok((actual as number) > n, `Expected ${actual} > ${n}`); }, toBeGreaterThanOrEqual(n: number) { - assert.ok( - (actual as number) >= n, - `Expected ${actual} >= ${n}`, - ); + assert.ok((actual as number) >= n, `Expected ${actual} >= ${n}`); }, toBeLessThan(n: number) { - assert.ok( - (actual as number) < n, - `Expected ${actual} < ${n}`, - ); + assert.ok((actual as number) < n, `Expected ${actual} < ${n}`); }, toBeLessThanOrEqual(n: number) { - assert.ok( - (actual as number) <= n, - `Expected ${actual} <= ${n}`, - ); + assert.ok((actual as number) <= n, `Expected ${actual} <= ${n}`); }, toBeCloseTo(expected: number, numDigits: number = 2) { const precision = 10 ** -numDigits / 2; @@ -139,10 +156,7 @@ export function expect(actual: unknown): ExpectMethods { }, toHaveBeenCalled() { const fn = actual as any; - assert.ok( - fn.mock && fn.mock.calls.length > 0, - `Expected function to have been called`, - ); + assert.ok(fn.mock && fn.mock.calls.length > 0, `Expected function to have been called`); }, toHaveBeenCalledTimes(n: number) { const fn = actual as any; @@ -182,14 +196,30 @@ export function expect(actual: unknown): ExpectMethods { `Expected string NOT to contain "${needle}", but it does`, ); } else if (Array.isArray(actual)) { - assert.ok( - !actual.includes(needle), - `Expected array NOT to contain ${JSON.stringify(needle)}`, - ); + assert.ok(!actual.includes(needle), `Expected array NOT to contain ${JSON.stringify(needle)}`); } else { assert.fail(`not.toContain: actual is neither string nor array`); } }, + toContainNormalized(needle: string) { + assert.ok( + typeof actual === "string", + `not.toContainNormalized: actual must be a string, got ${typeof actual}`, + ); + const normalize = (s: string) => + s + .replace(/\s+/g, " ") + .replace(/([(\[{])\s+/g, "$1") + .replace(/\s+([)\]},])/g, "$1") + .replace(/,([)\]}])/g, "$1") + .trim(); + const hayN = normalize(actual as string); + const needleN = normalize(needle); + assert.ok( + !hayN.includes(needleN), + `Expected (whitespace-normalized) string NOT to contain "${needleN}"`, + ); + }, toHaveLength(n: number) { assert.notStrictEqual((actual as any).length, n); }, @@ -209,28 +239,16 @@ export function expect(actual: unknown): ExpectMethods { assert.ok(actual, `Expected truthy value, got: ${actual}`); }, toBeGreaterThan(n: number) { - assert.ok( - (actual as number) <= n, - `Expected ${actual} to NOT be greater than ${n}`, - ); + assert.ok((actual as number) <= n, `Expected ${actual} to NOT be greater than ${n}`); }, toBeGreaterThanOrEqual(n: number) { - assert.ok( - (actual as number) < n, - `Expected ${actual} to NOT be >= ${n}`, - ); + assert.ok((actual as number) < n, `Expected ${actual} to NOT be >= ${n}`); }, toBeLessThan(n: number) { - assert.ok( - (actual as number) >= n, - `Expected ${actual} to NOT be less than ${n}`, - ); + assert.ok((actual as number) >= n, `Expected ${actual} to NOT be less than ${n}`); }, toBeLessThanOrEqual(n: number) { - assert.ok( - (actual as number) > n, - `Expected ${actual} to NOT be <= ${n}`, - ); + assert.ok((actual as number) > n, `Expected ${actual} to NOT be <= ${n}`); }, toBeCloseTo(expected: number, numDigits: number = 2) { const precision = 10 ** -numDigits / 2; @@ -266,10 +284,7 @@ export function expect(actual: unknown): ExpectMethods { }, toHaveBeenCalled() { const fn = actual as any; - assert.ok( - fn.mock && fn.mock.calls.length === 0, - `Expected function NOT to have been called`, - ); + assert.ok(fn.mock && fn.mock.calls.length === 0, `Expected function NOT to have been called`); }, toHaveBeenCalledTimes(n: number) { const fn = actual as any; diff --git a/extensions/tests/extension-forwarding.test.ts b/extensions/tests/extension-forwarding.test.ts index 8838cc58..785577b7 100644 --- a/extensions/tests/extension-forwarding.test.ts +++ b/extensions/tests/extension-forwarding.test.ts @@ -23,7 +23,10 @@ import { tmpdir } from "os"; // ── Test Helpers ───────────────────────────────────────────────────── function createTempDir(): string { - const dir = join(tmpdir(), `tp180-fwd-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const dir = join( + tmpdir(), + `tp180-fwd-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } diff --git a/extensions/tests/extension-ipc-batchid-scope.test.ts b/extensions/tests/extension-ipc-batchid-scope.test.ts index c12e54d2..d9f9094c 100644 --- a/extensions/tests/extension-ipc-batchid-scope.test.ts +++ b/extensions/tests/extension-ipc-batchid-scope.test.ts @@ -85,7 +85,10 @@ function locateSupervisorClosureRegion(): { start: number; end: number; body: st const firstActivate = source.indexOf(activateMarker, startIdx); assert.ok(firstActivate > startIdx, "Could not locate first 'Activate supervisor agent' anchor"); const secondActivate = source.indexOf(activateMarker, firstActivate + activateMarker.length); - assert.ok(secondActivate > firstActivate, "Could not locate second 'Activate supervisor agent' anchor"); + assert.ok( + secondActivate > firstActivate, + "Could not locate second 'Activate supervisor agent' anchor", + ); return { start: startIdx, end: secondActivate, @@ -106,8 +109,8 @@ describe("extension.ts supervisor IPC closure — batchId scope (regression #559 assert.ok( codeOnly.includes("orchBatchState.batchId"), "Expected at least one reference to `orchBatchState.batchId` inside the supervisor IPC closure. " + - "That's the canonical live-batch identifier in scope. If the only batchId reference is via " + - "`supervisorState.batchId`, the gate effectively never fires (sage post-mortem on #559).", + "That's the canonical live-batch identifier in scope. If the only batchId reference is via " + + "`supervisorState.batchId`, the gate effectively never fires (sage post-mortem on #559).", ); }); @@ -133,10 +136,10 @@ describe("extension.ts supervisor IPC closure — batchId scope (regression #559 occurrences.length, 0, `Found ${occurrences.length} occurrence(s) of \`batchState.batchId\` inside the ` + - `supervisor IPC closure (lines ${region.start}-${region.end}). \`batchState\` is NOT ` + - `bound in this scope — only \`supervisorState\` is. References to \`batchState.batchId\` ` + - `crash the orchestrator parent with ReferenceError on the first IPC frame (issue #559). ` + - `Use \`supervisorState.batchId\` instead.`, + `supervisor IPC closure (lines ${region.start}-${region.end}). \`batchState\` is NOT ` + + `bound in this scope — only \`supervisorState\` is. References to \`batchState.batchId\` ` + + `crash the orchestrator parent with ReferenceError on the first IPC frame (issue #559). ` + + `Use \`supervisorState.batchId\` instead.`, ); }); @@ -151,8 +154,8 @@ describe("extension.ts supervisor IPC closure — batchId scope (regression #559 assert.ok( helperBody.includes("orchBatchState.batchId"), "`ipcBatchIdMatches` must read the current batch ID from `orchBatchState.batchId` " + - "(the let-binding the extension manages itself, populated via state-sync IPC). " + - "Reading from `supervisorState.batchId` would defeat the gate for non-supervised batches.", + "(the let-binding the extension manages itself, populated via state-sync IPC). " + + "Reading from `supervisorState.batchId` would defeat the gate for non-supervised batches.", ); assert.ok( !helperBody.includes("batchState.batchId"), diff --git a/extensions/tests/external-task-path-resolution.test.ts b/extensions/tests/external-task-path-resolution.test.ts index 68f67bcb..6cb9f15e 100644 --- a/extensions/tests/external-task-path-resolution.test.ts +++ b/extensions/tests/external-task-path-resolution.test.ts @@ -28,10 +28,7 @@ import { parseWorktreeStatusMd, } from "../taskplane/execution.ts"; -import { - discoverAbortSessionNames, - selectAbortTargetSessions, -} from "../taskplane/abort.ts"; +import { discoverAbortSessionNames, selectAbortTargetSessions } from "../taskplane/abort.ts"; // ── Test Helpers ────────────────────────────────────────────────────── @@ -318,7 +315,7 @@ describe("parseWorktreeStatusMd", () => { expect(parsed).not.toBeNull(); // ParsedWorktreeStatus has a steps array — verify step parsing expect(parsed!.steps.length).toBeGreaterThanOrEqual(2); - const inProgressStep = parsed!.steps.find(s => s.status === "in-progress"); + const inProgressStep = parsed!.steps.find((s) => s.status === "in-progress"); expect(inProgressStep).toBeDefined(); expect(inProgressStep!.name).toContain("Implement feature"); // Aggregate checkbox counts across steps @@ -337,7 +334,7 @@ describe("parseWorktreeStatusMd", () => { expect(error).toBeNull(); expect(parsed).not.toBeNull(); - const inProgressStep = parsed!.steps.find(s => s.status === "in-progress"); + const inProgressStep = parsed!.steps.find((s) => s.status === "in-progress"); expect(inProgressStep).toBeDefined(); expect(inProgressStep!.name).toContain("Implement feature"); }); @@ -377,16 +374,20 @@ describe("selectAbortTargetSessions", () => { const targets = selectAbortTargetSessions( ["orch-lane-1"], null, // no persisted state - [{ - laneId: "lane-1", - laneNumber: 1, - worktreePath, - laneSessionId: "orch-lane-1", - tasks: [{ - taskId: "TP-060", - task: { taskFolder } as any, - }] as any[], - } as any], + [ + { + laneId: "lane-1", + laneNumber: 1, + worktreePath, + laneSessionId: "orch-lane-1", + tasks: [ + { + taskId: "TP-060", + task: { taskFolder } as any, + }, + ] as any[], + } as any, + ], repoRoot, "orch", ); @@ -397,9 +398,7 @@ describe("selectAbortTargetSessions", () => { expect(target.taskFolderInWorktree).not.toBeNull(); // Must be under worktreePath for repo-contained tasks expect(norm(target.taskFolderInWorktree!).startsWith(norm(worktreePath))).toBe(true); - expect(norm(target.taskFolderInWorktree!)).toBe( - norm(join(worktreePath, "tasks", "TP-060")), - ); + expect(norm(target.taskFolderInWorktree!)).toBe(norm(join(worktreePath, "tasks", "TP-060"))); }); it("resolves external task folder to absolute canonical path (not under worktree)", () => { @@ -409,16 +408,20 @@ describe("selectAbortTargetSessions", () => { const targets = selectAbortTargetSessions( ["orch-lane-1"], null, - [{ - laneId: "lane-1", - laneNumber: 1, - worktreePath, - laneSessionId: "orch-lane-1", - tasks: [{ - taskId: "TP-061-ext", - task: { taskFolder } as any, - }] as any[], - } as any], + [ + { + laneId: "lane-1", + laneNumber: 1, + worktreePath, + laneSessionId: "orch-lane-1", + tasks: [ + { + taskId: "TP-061-ext", + task: { taskFolder } as any, + }, + ] as any[], + } as any, + ], repoRoot, "orch", ); @@ -442,16 +445,20 @@ describe("selectAbortTargetSessions", () => { const targets = selectAbortTargetSessions( ["orch-lane-1"], null, - [{ - laneId: "lane-1", - laneNumber: 1, - worktreePath, - laneSessionId: "orch-lane-1", - tasks: [{ - taskId: "TP-062-ext-archived", - task: { taskFolder } as any, - }] as any[], - } as any], + [ + { + laneId: "lane-1", + laneNumber: 1, + worktreePath, + laneSessionId: "orch-lane-1", + tasks: [ + { + taskId: "TP-062-ext-archived", + task: { taskFolder } as any, + }, + ] as any[], + } as any, + ], repoRoot, "orch", ); @@ -473,16 +480,20 @@ describe("selectAbortTargetSessions", () => { const targets = selectAbortTargetSessions( ["orch-lane-1"], null, - [{ - laneId: "lane-1", - laneNumber: 1, - worktreePath, - laneSessionId: "orch-lane-1", - tasks: [{ - taskId: "TP-063-archived", - task: { taskFolder } as any, - }] as any[], - } as any], + [ + { + laneId: "lane-1", + laneNumber: 1, + worktreePath, + laneSessionId: "orch-lane-1", + tasks: [ + { + taskId: "TP-063-archived", + task: { taskFolder } as any, + }, + ] as any[], + } as any, + ], repoRoot, "orch", ); @@ -496,13 +507,15 @@ describe("selectAbortTargetSessions", () => { const targets = selectAbortTargetSessions( ["orch-lane-1"], null, - [{ - laneId: "lane-1", - laneNumber: 1, - worktreePath, - laneSessionId: "orch-lane-1", - tasks: [] as any[], - } as any], + [ + { + laneId: "lane-1", + laneNumber: 1, + worktreePath, + laneSessionId: "orch-lane-1", + tasks: [] as any[], + } as any, + ], repoRoot, "orch", ); @@ -515,13 +528,15 @@ describe("selectAbortTargetSessions", () => { const taskFolder = join(externalTaskRoot, "TP-064-persisted-ext"); const persistedState = { - tasks: [{ - taskId: "TP-064-persisted-ext", - sessionName: "orch-lane-1", - laneNumber: 1, - taskFolder, - status: "running", - }], + tasks: [ + { + taskId: "TP-064-persisted-ext", + sessionName: "orch-lane-1", + laneNumber: 1, + taskFolder, + status: "running", + }, + ], }; // No runtime lanes — only persisted data @@ -605,17 +620,13 @@ describe("monorepo completion detection regression", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 6. discoverAbortSessionNames — Runtime V2 abort discovery // ═══════════════════════════════════════════════════════════════════════ describe("discoverAbortSessionNames", () => { it("collects unique session names from runtime and persisted state", () => { - const runtimeLanes = [ - { laneSessionId: "orch-lane-1" }, - { laneSessionId: "orch-lane-2" }, - ] as any; + const runtimeLanes = [{ laneSessionId: "orch-lane-1" }, { laneSessionId: "orch-lane-2" }] as any; const persistedState = { lanes: [ @@ -629,36 +640,21 @@ describe("discoverAbortSessionNames", () => { } as any; const names = discoverAbortSessionNames("orch", persistedState, runtimeLanes).sort(); - expect(names).toEqual([ - "orch-lane-1", - "orch-lane-2", - "orch-lane-3", - "orch-merge-1", - ]); + expect(names).toEqual(["orch-lane-1", "orch-lane-2", "orch-lane-3", "orch-merge-1"]); }); it("supports persisted-only abort discovery when runtime lanes are empty", () => { const persistedState = { - lanes: [ - { laneSessionId: "orch-api-lane-1" }, - ], - tasks: [ - { sessionName: "orch-api-merge-1" }, - ], + lanes: [{ laneSessionId: "orch-api-lane-1" }], + tasks: [{ sessionName: "orch-api-merge-1" }], } as any; const names = discoverAbortSessionNames("orch", persistedState, []).sort(); - expect(names).toEqual([ - "orch-api-lane-1", - "orch-api-merge-1", - ]); + expect(names).toEqual(["orch-api-lane-1", "orch-api-merge-1"]); }); it("filters out sessions that do not match the configured prefix", () => { - const runtimeLanes = [ - { laneSessionId: "other-lane-1" }, - { laneSessionId: "orch-lane-1" }, - ] as any; + const runtimeLanes = [{ laneSessionId: "other-lane-1" }, { laneSessionId: "orch-lane-1" }] as any; const persistedState = { lanes: [{ laneSessionId: "other-lane-2" }], tasks: [{ sessionName: "orch-merge-1" }], @@ -669,7 +665,6 @@ describe("discoverAbortSessionNames", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 7. selectAbortTargetSessions — workspace-mode session matching (TP-004) // ═══════════════════════════════════════════════════════════════════════ @@ -683,16 +678,10 @@ describe("selectAbortTargetSessions workspace-mode", () => { "unrelated-session", ]; - const targets = selectAbortTargetSessions( - sessions, - null, - [], - repoRoot, - "orch", - ); + const targets = selectAbortTargetSessions(sessions, null, [], repoRoot, "orch"); expect(targets.length).toBe(3); - expect(targets.map(t => t.sessionName).sort()).toEqual([ + expect(targets.map((t) => t.sessionName).sort()).toEqual([ "orch-api-lane-1", "orch-api-lane-2", "orch-frontend-lane-1", @@ -701,23 +690,17 @@ describe("selectAbortTargetSessions workspace-mode", () => { it("matches both repo-mode and workspace-mode sessions together", () => { const sessions = [ - "orch-lane-1", // repo mode - "orch-api-lane-1", // workspace mode - "orch-merge-1", // repo mode merge - "orch-api-merge-1", // workspace mode merge (hypothetical) - "other-session", // unrelated + "orch-lane-1", // repo mode + "orch-api-lane-1", // workspace mode + "orch-merge-1", // repo mode merge + "orch-api-merge-1", // workspace mode merge (hypothetical) + "other-session", // unrelated ]; - const targets = selectAbortTargetSessions( - sessions, - null, - [], - repoRoot, - "orch", - ); + const targets = selectAbortTargetSessions(sessions, null, [], repoRoot, "orch"); expect(targets.length).toBe(4); - expect(targets.map(t => t.sessionName).sort()).toEqual([ + expect(targets.map((t) => t.sessionName).sort()).toEqual([ "orch-api-lane-1", "orch-api-merge-1", "orch-lane-1", @@ -729,31 +712,29 @@ describe("selectAbortTargetSessions workspace-mode", () => { const sessions = ["orch-api-lane-1"]; const persistedState = { - tasks: [{ - taskId: "TP-080", - sessionName: "orch-api-lane-1", - laneNumber: 1, - taskFolder: join(repoRoot, "tasks", "TP-080"), - status: "running", - }], - lanes: [{ - laneNumber: 1, - laneId: "api/lane-1", - laneSessionId: "orch-api-lane-1", - worktreePath: "/tmp/wt/lane-1", - branch: "orch-lane-1", - taskIds: ["TP-080"], - repoId: "api", - }], + tasks: [ + { + taskId: "TP-080", + sessionName: "orch-api-lane-1", + laneNumber: 1, + taskFolder: join(repoRoot, "tasks", "TP-080"), + status: "running", + }, + ], + lanes: [ + { + laneNumber: 1, + laneId: "api/lane-1", + laneSessionId: "orch-api-lane-1", + worktreePath: "/tmp/wt/lane-1", + branch: "orch-lane-1", + taskIds: ["TP-080"], + repoId: "api", + }, + ], }; - const targets = selectAbortTargetSessions( - sessions, - persistedState as any, - [], - repoRoot, - "orch", - ); + const targets = selectAbortTargetSessions(sessions, persistedState as any, [], repoRoot, "orch"); expect(targets.length).toBe(1); expect(targets[0].laneId).toBe("api/lane-1"); @@ -764,23 +745,19 @@ describe("selectAbortTargetSessions workspace-mode", () => { const sessions = ["orch-lane-1"]; const persistedState = { - tasks: [{ - taskId: "TP-081", - sessionName: "orch-lane-1", - laneNumber: 1, - taskFolder: join(repoRoot, "tasks", "TP-081"), - status: "running", - }], + tasks: [ + { + taskId: "TP-081", + sessionName: "orch-lane-1", + laneNumber: 1, + taskFolder: join(repoRoot, "tasks", "TP-081"), + status: "running", + }, + ], lanes: [], // no lane records }; - const targets = selectAbortTargetSessions( - sessions, - persistedState as any, - [], - repoRoot, - "orch", - ); + const targets = selectAbortTargetSessions(sessions, persistedState as any, [], repoRoot, "orch"); expect(targets.length).toBe(1); // Falls back to `lane-${laneNumber}` when no PersistedLaneRecord @@ -788,12 +765,7 @@ describe("selectAbortTargetSessions workspace-mode", () => { }); it("repo-mode behavior unchanged (regression)", () => { - const sessions = [ - "orch-lane-1", - "orch-lane-2", - "orch-merge-1", - "orch-lane-1-worker", - ]; + const sessions = ["orch-lane-1", "orch-lane-2", "orch-merge-1", "orch-lane-1-worker"]; const persistedState = { tasks: [ @@ -832,21 +804,15 @@ describe("selectAbortTargetSessions workspace-mode", () => { ], }; - const targets = selectAbortTargetSessions( - sessions, - persistedState as any, - [], - repoRoot, - "orch", - ); + const targets = selectAbortTargetSessions(sessions, persistedState as any, [], repoRoot, "orch"); // Should match lane-1, lane-2, merge-1, and lane-1-worker // (worker sessions start with "lane-" so they match) expect(targets.length).toBe(4); - + // Verify repo-mode laneIds are correctly resolved - const lane1 = targets.find(t => t.sessionName === "orch-lane-1"); - const lane2 = targets.find(t => t.sessionName === "orch-lane-2"); + const lane1 = targets.find((t) => t.sessionName === "orch-lane-1"); + const lane2 = targets.find((t) => t.sessionName === "orch-lane-2"); expect(lane1?.laneId).toBe("lane-1"); expect(lane2?.laneId).toBe("lane-2"); expect(lane1?.taskId).toBe("TP-082"); @@ -854,37 +820,17 @@ describe("selectAbortTargetSessions workspace-mode", () => { }); it("does not match sessions with prefix but no lane/merge suffix", () => { - const sessions = [ - "orch-dashboard", - "orch-monitor", - "orch-cleanup", - ]; + const sessions = ["orch-dashboard", "orch-monitor", "orch-cleanup"]; - const targets = selectAbortTargetSessions( - sessions, - null, - [], - repoRoot, - "orch", - ); + const targets = selectAbortTargetSessions(sessions, null, [], repoRoot, "orch"); expect(targets.length).toBe(0); }); it("handles hyphenated prefix in workspace mode", () => { - const sessions = [ - "orch-prod-api-lane-1", - "orch-prod-lane-1", - "orch-prod-merge-1", - ]; + const sessions = ["orch-prod-api-lane-1", "orch-prod-lane-1", "orch-prod-merge-1"]; - const targets = selectAbortTargetSessions( - sessions, - null, - [], - repoRoot, - "orch-prod", - ); + const targets = selectAbortTargetSessions(sessions, null, [], repoRoot, "orch-prod"); expect(targets.length).toBe(3); }); diff --git a/extensions/tests/fixtures/polyrepo-builder.ts b/extensions/tests/fixtures/polyrepo-builder.ts index 1c865b94..4c563e23 100644 --- a/extensions/tests/fixtures/polyrepo-builder.ts +++ b/extensions/tests/fixtures/polyrepo-builder.ts @@ -120,7 +120,7 @@ interface TaskPacket { taskName: string; size: string; areaName: string; - repoId?: string; // prompt-level repo declaration (optional) + repoId?: string; // prompt-level repo declaration (optional) dependencies: string[]; fileScope: string[]; } @@ -150,7 +150,7 @@ const TASK_PACKETS: TaskPacket[] = [ taskName: "UI Shell Layout", size: "M", areaName: "ui-tasks", - repoId: "frontend", // explicit prompt-level repo + repoId: "frontend", // explicit prompt-level repo dependencies: [], fileScope: ["src/components/Shell.tsx"], }, @@ -168,7 +168,7 @@ const TASK_PACKETS: TaskPacket[] = [ size: "L", areaName: "ui-tasks", repoId: "frontend", - dependencies: ["UI-001", "AP-001"], // cross-repo: AP-001 is in api + dependencies: ["UI-001", "AP-001"], // cross-repo: AP-001 is in api fileScope: ["src/views/Dashboard.tsx", "src/views/Dashboard.test.tsx"], }, { @@ -176,7 +176,7 @@ const TASK_PACKETS: TaskPacket[] = [ taskName: "Shared Documentation Update", size: "M", areaName: "shared-tasks", - dependencies: ["AP-002", "UI-002"], // cross-repo: depends on both api and frontend + dependencies: ["AP-002", "UI-002"], // cross-repo: depends on both api and frontend fileScope: ["docs/api.md", "docs/ui.md"], }, ]; @@ -184,17 +184,17 @@ const TASK_PACKETS: TaskPacket[] = [ // -- PROMPT.md Generation ---------------------------------------------- function generatePrompt(packet: TaskPacket): string { - const depsSection = packet.dependencies.length > 0 - ? packet.dependencies.map(d => `- **Requires:** ${d}`).join("\n") - : "**None**"; + const depsSection = + packet.dependencies.length > 0 + ? packet.dependencies.map((d) => `- **Requires:** ${d}`).join("\n") + : "**None**"; - const repoSection = packet.repoId - ? `\n## Execution Target\n\nRepo: ${packet.repoId}\n` - : ""; + const repoSection = packet.repoId ? `\n## Execution Target\n\nRepo: ${packet.repoId}\n` : ""; - const fileScopeSection = packet.fileScope.length > 0 - ? `\n## File Scope\n\n${packet.fileScope.map(f => `- ${f}`).join("\n")}\n` - : ""; + const fileScopeSection = + packet.fileScope.length > 0 + ? `\n## File Scope\n\n${packet.fileScope.map((f) => `- ${f}`).join("\n")}\n` + : ""; return `# Task: ${packet.taskId} - ${packet.taskName} @@ -262,7 +262,10 @@ routing: `; } -function generateTaskRunnerYaml(areaPaths: Record, areaRepoIds: Record): string { +function generateTaskRunnerYaml( + areaPaths: Record, + areaRepoIds: Record, +): string { const entries = Object.entries(areaPaths) .map(([name, path]) => { const prefix = name === "api-tasks" ? "AP" : name === "ui-tasks" ? "UI" : "SH"; @@ -293,7 +296,10 @@ ${entries} * Call fixture.cleanup() when done. */ export function buildPolyrepoFixture(): PolyrepoFixture { - const workspaceRoot = join(tmpdir(), `polyrepo-fixture-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const workspaceRoot = join( + tmpdir(), + `polyrepo-fixture-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); mkdirSync(workspaceRoot, { recursive: true }); // -- Create repo directories and init git -------------------------- @@ -358,7 +364,9 @@ export function buildPolyrepoFixture(): PolyrepoFixture { // matching the canonical path normalization used in production. const workspaceConfig = loadWorkspaceConfig(workspaceRoot); if (!workspaceConfig) { - throw new Error("buildPolyrepoFixture: loadWorkspaceConfig returned null — workspace config missing or broken"); + throw new Error( + "buildPolyrepoFixture: loadWorkspaceConfig returned null — workspace config missing or broken", + ); } // Update repoPaths to match the canonicalized paths from the config loader. @@ -393,12 +401,12 @@ export function buildPolyrepoFixture(): PolyrepoFixture { // -- Expected outputs ---------------------------------------------- const expectedRouting: Record = { - "SH-001": "docs", // area fallback - "AP-001": "api", // area fallback - "UI-001": "frontend", // prompt-level repo - "AP-002": "api", // area fallback - "UI-002": "frontend", // prompt-level repo - "SH-002": "docs", // area fallback + "SH-001": "docs", // area fallback + "AP-001": "api", // area fallback + "UI-001": "frontend", // prompt-level repo + "AP-002": "api", // area fallback + "UI-002": "frontend", // prompt-level repo + "SH-002": "docs", // area fallback }; const expectedDeps: Record = { @@ -411,7 +419,7 @@ export function buildPolyrepoFixture(): PolyrepoFixture { }; const expectedWaves: string[][] = [ - ["AP-001", "SH-001", "UI-001"], // sorted alphabetically + ["AP-001", "SH-001", "UI-001"], // sorted alphabetically ["AP-002", "UI-002"], ["SH-002"], ]; @@ -430,7 +438,9 @@ export function buildPolyrepoFixture(): PolyrepoFixture { cleanup: () => { try { rmSync(workspaceRoot, { recursive: true, force: true }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } }, }; } @@ -482,7 +492,14 @@ export function buildFixtureDiscovery(fixture: PolyrepoFixture): DiscoveryResult /** * The canonical task IDs in the polyrepo fixture. */ -export const FIXTURE_TASK_IDS = ["SH-001", "AP-001", "UI-001", "AP-002", "UI-002", "SH-002"] as const; +export const FIXTURE_TASK_IDS = [ + "SH-001", + "AP-001", + "UI-001", + "AP-002", + "UI-002", + "SH-002", +] as const; /** * The canonical repo IDs in the polyrepo fixture. diff --git a/extensions/tests/force-resume.test.ts b/extensions/tests/force-resume.test.ts index e8538169..351b4ddb 100644 --- a/extensions/tests/force-resume.test.ts +++ b/extensions/tests/force-resume.test.ts @@ -17,8 +17,16 @@ import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { parseResumeArgs } from "../taskplane/extension.ts"; import { checkResumeEligibility, runPreResumeDiagnostics } from "../taskplane/resume.ts"; -import type { PersistedBatchState, OrchBatchPhase, PersistedLaneRecord } from "../taskplane/types.ts"; -import { BATCH_STATE_SCHEMA_VERSION, defaultResilienceState, defaultBatchDiagnostics } from "../taskplane/types.ts"; +import type { + PersistedBatchState, + OrchBatchPhase, + PersistedLaneRecord, +} from "../taskplane/types.ts"; +import { + BATCH_STATE_SCHEMA_VERSION, + defaultResilienceState, + defaultBatchDiagnostics, +} from "../taskplane/types.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -103,7 +111,14 @@ describe("parseResumeArgs", () => { describe("checkResumeEligibility — normal resume (force=false)", () => { const normalEligible: OrchBatchPhase[] = ["paused", "executing", "merging"]; - const normalIneligible: OrchBatchPhase[] = ["stopped", "failed", "completed", "idle", "launching", "planning"]; + const normalIneligible: OrchBatchPhase[] = [ + "stopped", + "failed", + "completed", + "idle", + "launching", + "planning", + ]; for (const phase of normalEligible) { it(`${phase} → eligible without force`, () => { @@ -206,9 +221,13 @@ describe("runPreResumeDiagnostics", () => { it("passes state-coherence check for valid loaded state", () => { const state = makeState("failed"); // Use a non-existent path to avoid git calls actually finding branches - const result = runPreResumeDiagnostics(state, "/tmp/nonexistent-repo-root", "/tmp/nonexistent-state-root"); + const result = runPreResumeDiagnostics( + state, + "/tmp/nonexistent-repo-root", + "/tmp/nonexistent-state-root", + ); // State coherence always passes because state was already loaded - const stateCheck = result.checks.find(c => c.check === "state-coherence"); + const stateCheck = result.checks.find((c) => c.check === "state-coherence"); expect(stateCheck).toBeDefined(); expect(stateCheck!.passed).toBe(true); expect(stateCheck!.detail).toContain(state.batchId); @@ -220,7 +239,7 @@ describe("runPreResumeDiagnostics", () => { // Point to a valid git repo (current project) but with a nonexistent branch const repoRoot = join(__dirname, "..", ".."); const result = runPreResumeDiagnostics(state, repoRoot, repoRoot); - const branchCheck = result.checks.find(c => c.check.startsWith("branch-consistency:")); + const branchCheck = result.checks.find((c) => c.check.startsWith("branch-consistency:")); expect(branchCheck).toBeDefined(); expect(branchCheck!.passed).toBe(false); expect(branchCheck!.detail).toContain("not found"); @@ -240,7 +259,7 @@ describe("runPreResumeDiagnostics", () => { ]; const result = runPreResumeDiagnostics(state, "/tmp/nonexistent", "/tmp/nonexistent"); // No worktree health checks should be emitted for null worktreePath - const wtChecks = result.checks.filter(c => c.check.startsWith("worktree-health:")); + const wtChecks = result.checks.filter((c) => c.check.startsWith("worktree-health:")); expect(wtChecks).toHaveLength(0); }); @@ -257,7 +276,7 @@ describe("runPreResumeDiagnostics", () => { } as unknown as PersistedLaneRecord, ]; const result = runPreResumeDiagnostics(state, "/tmp/nonexistent", "/tmp/nonexistent"); - const wtCheck = result.checks.find(c => c.check === "worktree-health:lane-1"); + const wtCheck = result.checks.find((c) => c.check === "worktree-health:lane-1"); expect(wtCheck).toBeDefined(); expect(wtCheck!.passed).toBe(true); expect(wtCheck!.detail).toContain("absent"); @@ -288,10 +307,7 @@ describe("runPreResumeDiagnostics", () => { // ── 4. Force-resume runtime path — source verification ─────────────── describe("force-resume runtime path in resumeOrchBatch — source verification", () => { - const resumeSource = readFileSync( - join(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); it("gates force-resume on pre-resume diagnostics (blocks when diagnostics fail)", () => { // The force-resume path must call runPreResumeDiagnostics and return early @@ -329,9 +345,13 @@ describe("force-resume runtime path in resumeOrchBatch — source verification", // The isForceResume guard must check for stopped|failed specifically expect(resumeSource).toContain('persistedState.phase === "stopped"'); expect(resumeSource).toContain('persistedState.phase === "failed"'); - // isForceResume should be gated on force AND (stopped|failed) - const isForceResumePattern = /const isForceResume = force && \(persistedState\.phase === "stopped" \|\| persistedState\.phase === "failed"\)/; - expect(resumeSource).toMatch(isForceResumePattern); + // isForceResume should be gated on force AND (stopped|failed). + // TP-193: Whitespace-normalize so the formatter's vertical re-wrapping + // of long boolean expressions doesn't break the regex. + const normSrc = resumeSource.replace(/\s+/g, " "); + const isForceResumePattern = + /const isForceResume = force && \(persistedState\.phase === "stopped" \|\| persistedState\.phase === "failed"\)/; + expect(normSrc).toMatch(isForceResumePattern); }); }); @@ -363,10 +383,15 @@ describe("force-resume runtime path — diagnostics gate", () => { state.lanes = []; // no worktrees to check // With no lanes and a non-repo cwd, only state-coherence runs - const result = runPreResumeDiagnostics(state, "/tmp/nonexistent-repo-root", "/tmp/state-root", null); + const result = runPreResumeDiagnostics( + state, + "/tmp/nonexistent-repo-root", + "/tmp/state-root", + null, + ); // State coherence always passes (state is already loaded) - const stateCheck = result.checks.find(c => c.check === "state-coherence"); + const stateCheck = result.checks.find((c) => c.check === "state-coherence"); expect(stateCheck).toBeDefined(); expect(stateCheck!.passed).toBe(true); }); @@ -379,7 +404,7 @@ describe("force-resume runtime path — diagnostics gate", () => { // Use cwd as repo root (which IS a git repo in the test environment) const result = runPreResumeDiagnostics(state, process.cwd(), process.cwd(), null); - const branchCheck = result.checks.find(c => c.check.startsWith("branch-consistency")); + const branchCheck = result.checks.find((c) => c.check.startsWith("branch-consistency")); expect(branchCheck).toBeDefined(); expect(branchCheck!.passed).toBe(false); expect(branchCheck!.detail).toContain("not found"); diff --git a/extensions/tests/gitignore-pattern-matching.test.ts b/extensions/tests/gitignore-pattern-matching.test.ts index 0a7f64d7..beebada3 100644 --- a/extensions/tests/gitignore-pattern-matching.test.ts +++ b/extensions/tests/gitignore-pattern-matching.test.ts @@ -48,16 +48,14 @@ const TASKPLANE_GITIGNORE_ENTRIES = [ ".worktrees/", ]; -const TASKPLANE_GITIGNORE_NPM_ENTRIES = [ - ".pi/npm/", -]; +const TASKPLANE_GITIGNORE_NPM_ENTRIES = [".pi/npm/"]; const ALL_GITIGNORE_PATTERNS = [...TASKPLANE_GITIGNORE_ENTRIES, ...TASKPLANE_GITIGNORE_NPM_ENTRIES]; // ─── Helper: match a file against all patterns ─────────────────────────── function matchesAnyPattern(file: string, patterns: string[]): boolean { - return patterns.map(p => patternToRegex(p)).some(regex => regex.test(file)); + return patterns.map((p) => patternToRegex(p)).some((regex) => regex.test(file)); } // ─── Tests ─────────────────────────────────────────────────────────────── @@ -194,8 +192,12 @@ describe("full pattern set against realistic tracked files", () => { }); it("4.3 — directory patterns match deeply nested files", () => { - expect(matchesAnyPattern(".worktrees/wt1/deeply/nested/file.txt", ALL_GITIGNORE_PATTERNS)).toBe(true); + expect(matchesAnyPattern(".worktrees/wt1/deeply/nested/file.txt", ALL_GITIGNORE_PATTERNS)).toBe( + true, + ); expect(matchesAnyPattern(".pi/orch-logs/a/b/c/deep.log", ALL_GITIGNORE_PATTERNS)).toBe(true); - expect(matchesAnyPattern(".pi/npm/node_modules/@scope/pkg/lib/index.js", ALL_GITIGNORE_PATTERNS)).toBe(true); + expect( + matchesAnyPattern(".pi/npm/node_modules/@scope/pkg/lib/index.js", ALL_GITIGNORE_PATTERNS), + ).toBe(true); }); }); diff --git a/extensions/tests/gitignore-patterns.test.ts b/extensions/tests/gitignore-patterns.test.ts index b282409e..571563ce 100644 --- a/extensions/tests/gitignore-patterns.test.ts +++ b/extensions/tests/gitignore-patterns.test.ts @@ -197,7 +197,7 @@ describe("matchesAnyGitignorePattern: integration", () => { it("5.7 — ALL_GITIGNORE_PATTERNS includes both runtime and npm entries", () => { expect(ALL_GITIGNORE_PATTERNS.length).toBe( - TASKPLANE_GITIGNORE_ENTRIES.length + TASKPLANE_GITIGNORE_NPM_ENTRIES.length + TASKPLANE_GITIGNORE_ENTRIES.length + TASKPLANE_GITIGNORE_NPM_ENTRIES.length, ); expect(ALL_GITIGNORE_PATTERNS).toContain(".pi/npm/"); expect(ALL_GITIGNORE_PATTERNS).toContain(".worktrees/"); diff --git a/extensions/tests/global-preferences.test.ts b/extensions/tests/global-preferences.test.ts index 38ec18bd..18e47f27 100644 --- a/extensions/tests/global-preferences.test.ts +++ b/extensions/tests/global-preferences.test.ts @@ -16,13 +16,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { expect } from "./expect.ts"; -import { - mkdirSync, - writeFileSync, - readFileSync, - existsSync, - rmSync, -} from "fs"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir, homedir } from "os"; @@ -42,10 +36,7 @@ import { GLOBAL_PREFERENCES_FILENAME, GLOBAL_PREFERENCES_SUBDIR, } from "../taskplane/config-schema.ts"; -import type { - TaskplaneConfig, - GlobalPreferences, -} from "../taskplane/config-schema.ts"; +import type { TaskplaneConfig, GlobalPreferences } from "../taskplane/config-schema.ts"; // ── Fixture Helpers ────────────────────────────────────────────────── @@ -122,7 +113,13 @@ describe("resolveGlobalPreferencesPath", () => { delete process.env.PI_CODING_AGENT_DIR; const result = resolveGlobalPreferencesPath(); - const expected = join(homedir(), ".pi", "agent", GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME); + const expected = join( + homedir(), + ".pi", + "agent", + GLOBAL_PREFERENCES_SUBDIR, + GLOBAL_PREFERENCES_FILENAME, + ); expect(result).toBe(expected); }); @@ -192,12 +189,15 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("unknown-keys"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - operatorId: "alice", - unknownField: "should-be-dropped", - anotherUnknown: 42, - nested: { deep: true }, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + operatorId: "alice", + unknownField: "should-be-dropped", + anotherUnknown: 42, + nested: { deep: true }, + }), + ); const prefs = loadGlobalPreferences(); @@ -211,15 +211,18 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("valid-full"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - operatorId: "bob", - sessionPrefix: "myprefix", - spawnMode: "subprocess", - workerModel: "openai/gpt-4", - reviewerModel: "anthropic/claude-3", - mergeModel: "openai/gpt-4", - dashboardPort: 9090, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + operatorId: "bob", + sessionPrefix: "myprefix", + spawnMode: "subprocess", + workerModel: "openai/gpt-4", + reviewerModel: "anthropic/claude-3", + mergeModel: "openai/gpt-4", + dashboardPort: 9090, + }), + ); const prefs = loadGlobalPreferences(); @@ -236,9 +239,12 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("legacy-prefix-alias"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - tmuxPrefix: "legacy-prefix", - })); + writePrefsFile( + agentDir, + JSON.stringify({ + tmuxPrefix: "legacy-prefix", + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.sessionPrefix).toBe("legacy-prefix"); @@ -248,9 +254,12 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("prefs-spawn-tmux-migrate"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - spawnMode: "tmux", - })); + writePrefsFile( + agentDir, + JSON.stringify({ + spawnMode: "tmux", + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.spawnMode).toBe("subprocess"); @@ -282,10 +291,13 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("bad-spawn"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - operatorId: "valid", - spawnMode: "invalid-mode", - })); + writePrefsFile( + agentDir, + JSON.stringify({ + operatorId: "valid", + spawnMode: "invalid-mode", + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.operatorId).toBe("valid"); @@ -296,9 +308,12 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("bad-port"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - dashboardPort: "not-a-number", - })); + writePrefsFile( + agentDir, + JSON.stringify({ + dashboardPort: "not-a-number", + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.dashboardPort).toBeUndefined(); @@ -310,9 +325,12 @@ describe("loadGlobalPreferences", () => { // JSON.stringify drops Infinity/NaN → null, so test numeric edge case: // NaN can't appear in valid JSON, but Infinity can't either. Test with null: - writePrefsFile(agentDir, JSON.stringify({ - dashboardPort: null, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + dashboardPort: null, + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.dashboardPort).toBeUndefined(); @@ -322,13 +340,16 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("wrong-types"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - operatorId: 123, - sessionPrefix: true, - workerModel: { nested: "obj" }, - reviewerModel: ["array"], - mergeModel: null, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + operatorId: 123, + sessionPrefix: true, + workerModel: { nested: "obj" }, + reviewerModel: ["array"], + mergeModel: null, + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.operatorId).toBeUndefined(); @@ -342,31 +363,34 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("nested-overrides"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - taskRunner: { - worker: { model: "nested-worker", tools: "read,write" }, - context: { maxWorkerIterations: 44 }, - }, - orchestrator: { - orchestrator: { maxLanes: 9 }, - failure: { stallTimeout: 120 }, - }, - workspace: { - routing: { - tasksRoot: "taskplane-tasks", - defaultRepo: "default", - taskPacketRepo: "default", + writePrefsFile( + agentDir, + JSON.stringify({ + taskRunner: { + worker: { model: "nested-worker", tools: "read,write" }, + context: { maxWorkerIterations: 44 }, }, - repos: { - default: { path: "." }, + orchestrator: { + orchestrator: { maxLanes: 9 }, + failure: { stallTimeout: 120 }, }, - }, - dashboardPort: 7070, - initAgentDefaults: { - workerModel: "seed-worker", - workerThinking: "on", - }, - })); + workspace: { + routing: { + tasksRoot: "taskplane-tasks", + defaultRepo: "default", + taskPacketRepo: "default", + }, + repos: { + default: { path: "." }, + }, + }, + dashboardPort: 7070, + initAgentDefaults: { + workerModel: "seed-worker", + workerThinking: "on", + }, + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.taskRunner?.worker?.model).toBe("nested-worker"); @@ -383,14 +407,17 @@ describe("loadGlobalPreferences", () => { const agentDir = makeTestDir("nested-tmux"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - taskRunner: { - worker: { spawnMode: "tmux" }, - }, - orchestrator: { - orchestrator: { spawnMode: "tmux" }, - }, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + taskRunner: { + worker: { spawnMode: "tmux" }, + }, + orchestrator: { + orchestrator: { spawnMode: "tmux" }, + }, + }), + ); const prefs = loadGlobalPreferences(); expect(prefs.taskRunner?.worker?.spawnMode).toBe("subprocess"); @@ -612,24 +639,31 @@ describe("Layer 2 merge integration", () => { process.env.PI_CODING_AGENT_DIR = agentDir; // Write global preferences - writePrefsFile(agentDir, JSON.stringify({ - operatorId: "e2e-user", - workerModel: "e2e-worker-model", - dashboardPort: 8888, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + operatorId: "e2e-user", + workerModel: "e2e-worker-model", + dashboardPort: 8888, + }), + ); // Write JSON project config const projectDir = makeTestDir("e2e-json-project"); - writePiFile(projectDir, "taskplane-config.json", JSON.stringify({ - configVersion: 1, - taskRunner: { - project: { name: "E2EProject" }, - worker: { model: "project-worker-model" }, - }, - orchestrator: { - orchestrator: { operatorId: "project-operator", maxLanes: 7 }, - }, - })); + writePiFile( + projectDir, + "taskplane-config.json", + JSON.stringify({ + configVersion: 1, + taskRunner: { + project: { name: "E2EProject" }, + worker: { model: "project-worker-model" }, + }, + orchestrator: { + orchestrator: { operatorId: "project-operator", maxLanes: 7 }, + }, + }), + ); const config = loadProjectConfig(projectDir); @@ -651,28 +685,37 @@ describe("Layer 2 merge integration", () => { process.env.PI_CODING_AGENT_DIR = agentDir; // Write global preferences - writePrefsFile(agentDir, JSON.stringify({ - reviewerModel: "e2e-reviewer", - sessionPrefix: "e2e-prefix", - spawnMode: "subprocess", - })); + writePrefsFile( + agentDir, + JSON.stringify({ + reviewerModel: "e2e-reviewer", + sessionPrefix: "e2e-prefix", + spawnMode: "subprocess", + }), + ); // Write YAML project config const projectDir = makeTestDir("e2e-yaml-project"); - writeTaskRunnerYaml(projectDir, [ - "project:", - " name: YamlE2EProject", - "reviewer:", - " model: yaml-reviewer-model", - " tools: read,write", - " thinking: on", - ].join("\n")); - writeOrchestratorYaml(projectDir, [ - "orchestrator:", - " max_lanes: 4", - " session_prefix: yaml-prefix", - " spawn_mode: subprocess", - ].join("\n")); + writeTaskRunnerYaml( + projectDir, + [ + "project:", + " name: YamlE2EProject", + "reviewer:", + " model: yaml-reviewer-model", + " tools: read,write", + " thinking: on", + ].join("\n"), + ); + writeOrchestratorYaml( + projectDir, + [ + "orchestrator:", + " max_lanes: 4", + " session_prefix: yaml-prefix", + " spawn_mode: subprocess", + ].join("\n"), + ); const config = loadProjectConfig(projectDir); @@ -696,13 +739,17 @@ describe("Layer 2 merge integration", () => { // Write valid project config const projectDir = makeTestDir("e2e-malformed-project"); - writePiFile(projectDir, "taskplane-config.json", JSON.stringify({ - configVersion: 1, - taskRunner: { - project: { name: "StillWorks" }, - worker: { model: "project-model" }, - }, - })); + writePiFile( + projectDir, + "taskplane-config.json", + JSON.stringify({ + configVersion: 1, + taskRunner: { + project: { name: "StillWorks" }, + worker: { model: "project-model" }, + }, + }), + ); const config = loadProjectConfig(projectDir); @@ -735,22 +782,29 @@ describe("Layer 2 merge integration", () => { const agentDir = makeTestDir("e2e-empty-str"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - operatorId: "", - workerModel: "", - reviewerModel: "non-empty-reviewer", - })); + writePrefsFile( + agentDir, + JSON.stringify({ + operatorId: "", + workerModel: "", + reviewerModel: "non-empty-reviewer", + }), + ); const projectDir = makeTestDir("e2e-empty-str-project"); - writePiFile(projectDir, "taskplane-config.json", JSON.stringify({ - configVersion: 1, - taskRunner: { - worker: { model: "layer1-worker" }, - }, - orchestrator: { - orchestrator: { operatorId: "layer1-operator" }, - }, - })); + writePiFile( + projectDir, + "taskplane-config.json", + JSON.stringify({ + configVersion: 1, + taskRunner: { + worker: { model: "layer1-worker" }, + }, + orchestrator: { + orchestrator: { operatorId: "layer1-operator" }, + }, + }), + ); const config = loadProjectConfig(projectDir); @@ -766,28 +820,35 @@ describe("Layer 2 merge integration", () => { const agentDir = makeTestDir("e2e-nested-agent"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - taskRunner: { - reviewer: { thinking: "off" }, - }, - orchestrator: { - orchestrator: { maxLanes: 11 }, - failure: { stallTimeout: 150 }, - }, - dashboardPort: 4567, - initAgentDefaults: { reviewerModel: "seed-reviewer" }, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + taskRunner: { + reviewer: { thinking: "off" }, + }, + orchestrator: { + orchestrator: { maxLanes: 11 }, + failure: { stallTimeout: 150 }, + }, + dashboardPort: 4567, + initAgentDefaults: { reviewerModel: "seed-reviewer" }, + }), + ); const projectDir = makeTestDir("e2e-nested-project"); - writePiFile(projectDir, "taskplane-config.json", JSON.stringify({ - configVersion: 1, - taskRunner: { - reviewer: { thinking: "on" }, - }, - orchestrator: { - orchestrator: { maxLanes: 2 }, - }, - })); + writePiFile( + projectDir, + "taskplane-config.json", + JSON.stringify({ + configVersion: 1, + taskRunner: { + reviewer: { thinking: "on" }, + }, + orchestrator: { + orchestrator: { maxLanes: 2 }, + }, + }), + ); const config = loadProjectConfig(projectDir); // Project overrides should win when explicitly set @@ -805,25 +866,32 @@ describe("Layer 2 merge integration", () => { const agentDir = makeTestDir("e2e-nested-tmux-agent"); process.env.PI_CODING_AGENT_DIR = agentDir; - writePrefsFile(agentDir, JSON.stringify({ - taskRunner: { - worker: { spawnMode: "tmux" }, - }, - orchestrator: { - orchestrator: { spawnMode: "tmux" }, - }, - })); + writePrefsFile( + agentDir, + JSON.stringify({ + taskRunner: { + worker: { spawnMode: "tmux" }, + }, + orchestrator: { + orchestrator: { spawnMode: "tmux" }, + }, + }), + ); const projectDir = makeTestDir("e2e-nested-tmux-project"); - writePiFile(projectDir, "taskplane-config.json", JSON.stringify({ - configVersion: 1, - taskRunner: { - worker: { spawnMode: "subprocess" }, - }, - orchestrator: { - orchestrator: { spawnMode: "subprocess" }, - }, - })); + writePiFile( + projectDir, + "taskplane-config.json", + JSON.stringify({ + configVersion: 1, + taskRunner: { + worker: { spawnMode: "subprocess" }, + }, + orchestrator: { + orchestrator: { spawnMode: "subprocess" }, + }, + }), + ); const config = loadProjectConfig(projectDir); expect(config.taskRunner.worker.spawnMode).toBe("subprocess"); diff --git a/extensions/tests/init-mode-detection.integration.test.ts b/extensions/tests/init-mode-detection.integration.test.ts index 8ae4716e..231056cc 100644 --- a/extensions/tests/init-mode-detection.integration.test.ts +++ b/extensions/tests/init-mode-detection.integration.test.ts @@ -69,7 +69,9 @@ function isGitRepoRoot(dir: string): boolean { cwd: dir, stdio: ["pipe", "pipe", "pipe"], timeout: 5000, - }).toString().trim(); + }) + .toString() + .trim(); // Normalize paths for comparison (handles Windows path separators // and 8.3 short name mismatches on Windows) const normalizedToplevel = resolve(toplevel); @@ -77,7 +79,9 @@ function isGitRepoRoot(dir: string): boolean { // On Windows, fs.realpathSync.native resolves 8.3 short names to // long names, matching what git returns. Without this, paths like // C:\Users\HENRYL~1\... won't match C:\Users\HenryLach\... - try { normalizedDir = realpathSync.native(normalizedDir); } catch {} + try { + normalizedDir = realpathSync.native(normalizedDir); + } catch {} return normalizedToplevel === normalizedDir; } catch { return false; @@ -146,9 +150,7 @@ function detectInitMode(dir: string): DetectResult { alreadyInitialized: hasLocalConfig, existingConfigPath: hasLocalConfig ? join(dir, ".pi") : null, workspaceConfigRepo, - workspaceConfigPath: workspaceConfigRepo - ? join(dir, workspaceConfigRepo, ".taskplane") - : null, + workspaceConfigPath: workspaceConfigRepo ? join(dir, workspaceConfigRepo, ".taskplane") : null, }; } @@ -165,9 +167,7 @@ function detectInitMode(dir: string): DetectResult { mode: "workspace", subRepos, alreadyInitialized: existingConfigRepo !== null, - existingConfigPath: existingConfigRepo - ? join(dir, existingConfigRepo, ".taskplane") - : null, + existingConfigPath: existingConfigRepo ? join(dir, existingConfigRepo, ".taskplane") : null, }; } @@ -652,7 +652,7 @@ describe("CLI dry-run integration", () => { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000, - } + }, ); expect(output).toContain("Mode:"); @@ -676,7 +676,7 @@ describe("CLI dry-run integration", () => { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000, - } + }, ); } catch (e: any) { exitCode = e.status; @@ -697,7 +697,7 @@ describe("CLI dry-run integration", () => { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000, - } + }, ); expect(output).toContain("taskplane-config.json"); @@ -715,7 +715,7 @@ describe("CLI dry-run integration", () => { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000, - } + }, ); // YAML files should no longer be generated @@ -778,7 +778,9 @@ describe("CLI dry-run integration", () => { // JSON config should exist expect(existsSync(join(repo, ".pi", "taskplane-config.json"))).toBe(true); - const projectConfig = JSON.parse(readFileSync(join(repo, ".pi", "taskplane-config.json"), "utf-8")); + const projectConfig = JSON.parse( + readFileSync(join(repo, ".pi", "taskplane-config.json"), "utf-8"), + ); // Sparse init config: no orchestrator block unless explicitly chosen during init expect(projectConfig.orchestrator).toBeUndefined(); }); @@ -799,7 +801,9 @@ describe("CLI dry-run integration", () => { // JSON config should exist expect(existsSync(join(configRoot, "taskplane-config.json"))).toBe(true); - const projectConfig = JSON.parse(readFileSync(join(configRoot, "taskplane-config.json"), "utf-8")); + const projectConfig = JSON.parse( + readFileSync(join(configRoot, "taskplane-config.json"), "utf-8"), + ); // Sparse init config: no orchestrator block unless explicitly chosen during init expect(projectConfig.orchestrator).toBeUndefined(); }); diff --git a/extensions/tests/init-model-discovery.test.ts b/extensions/tests/init-model-discovery.test.ts index 866d8be4..e11a4a95 100644 --- a/extensions/tests/init-model-discovery.test.ts +++ b/extensions/tests/init-model-discovery.test.ts @@ -1,9 +1,6 @@ import { describe, it } from "node:test"; import { expect } from "./expect.ts"; -import { - parsePiListModelsOutput, - queryAvailableModelsFromPi, -} from "../../bin/taskplane.mjs"; +import { parsePiListModelsOutput, queryAvailableModelsFromPi } from "../../bin/taskplane.mjs"; describe("init model discovery helpers", () => { it("parses pi --list-models output into structured model rows", () => { @@ -59,10 +56,7 @@ describe("init model discovery helpers", () => { it("returns available models when list command succeeds", () => { const result = queryAvailableModelsFromPi({ commandExistsImpl: () => true, - execFileSyncImpl: () => [ - "provider model context", - "openai gpt-5.3-codex 400K", - ].join("\n"), + execFileSyncImpl: () => ["provider model context", "openai gpt-5.3-codex 400K"].join("\n"), }); expect(result.available).toBe(true); @@ -77,10 +71,7 @@ describe("init model discovery helpers", () => { }); it("parses supportsThinking=false when thinking column says no", () => { - const raw = [ - "provider model thinking context", - "openai gpt-5.3-codex no 400K", - ].join("\n"); + const raw = ["provider model thinking context", "openai gpt-5.3-codex no 400K"].join("\n"); const parsed = parsePiListModelsOutput(raw); expect(parsed).toEqual([ diff --git a/extensions/tests/init-model-picker.test.ts b/extensions/tests/init-model-picker.test.ts index 9e9bda8a..8f34effd 100644 --- a/extensions/tests/init-model-picker.test.ts +++ b/extensions/tests/init-model-picker.test.ts @@ -1,9 +1,6 @@ import { describe, it } from "node:test"; import { expect } from "./expect.ts"; -import { - collectInitAgentConfig, - generateProjectConfig, -} from "../../bin/taskplane.mjs"; +import { collectInitAgentConfig, generateProjectConfig } from "../../bin/taskplane.mjs"; const AVAILABLE_MODELS = [ { provider: "anthropic", id: "claude-sonnet-4-6", displayName: "anthropic/claude-sonnet-4-6" }, @@ -102,7 +99,9 @@ describe("init model picker flow", () => { expect(logs.some((line) => line.includes("First-run recommendation"))).toBe(true); const workerThinkingPrompt = prompts.find((entry) => entry.question.includes("Worker thinking")); - const reviewerProviderPrompt = prompts.find((entry) => entry.question.includes("Reviewer provider")); + const reviewerProviderPrompt = prompts.find((entry) => + entry.question.includes("Reviewer provider"), + ); const mergerProviderPrompt = prompts.find((entry) => entry.question.includes("Merger provider")); expect(workerThinkingPrompt?.defaultValue).toBe("6"); expect(reviewerProviderPrompt?.defaultValue).toBe("2"); @@ -111,7 +110,12 @@ describe("init model picker flow", () => { it("shows unsupported-thinking note but still allows selecting a thinking level", async () => { const modelsWithoutThinking = [ - { provider: "openai", id: "gpt-5.3-codex", displayName: "openai/gpt-5.3-codex", supportsThinking: false }, + { + provider: "openai", + id: "gpt-5.3-codex", + displayName: "openai/gpt-5.3-codex", + supportsThinking: false, + }, ]; const logs: string[] = []; const config = await collectInitAgentConfig({ diff --git a/extensions/tests/lane-runner-spawn-wiring.test.ts b/extensions/tests/lane-runner-spawn-wiring.test.ts index 27d0a1d6..97507152 100644 --- a/extensions/tests/lane-runner-spawn-wiring.test.ts +++ b/extensions/tests/lane-runner-spawn-wiring.test.ts @@ -57,14 +57,13 @@ describe("TP-189-A1 — lane-runner.ts worker spawn-site wires buildWorkerToolsA // literal (the worker spawn payload), the `tools:` field must be // set to `buildWorkerToolsAllowlist(config.workerTools)`. Tolerate // trailing comma/whitespace; tolerate optional `as const` casts. - const expected = - /\btools\s*:\s*buildWorkerToolsAllowlist\(\s*config\.workerTools\s*\)/; + const expected = /\btools\s*:\s*buildWorkerToolsAllowlist\(\s*config\.workerTools\s*\)/; assert.match( laneRunnerSrc, expected, "lane-runner.ts must wire `tools: buildWorkerToolsAllowlist(config.workerTools)` " + - "in the worker spawn options. If a refactor moved this site, update both this " + - "test and the surrounding TP-184 NOTE comment.", + "in the worker spawn options. If a refactor moved this site, update both this " + + "test and the surrounding TP-184 NOTE comment.", ); }); @@ -77,9 +76,9 @@ describe("TP-189-A1 — lane-runner.ts worker spawn-site wires buildWorkerToolsA laneRunnerSrc, /\btools\s*:\s*config\.workerTools\b/, "lane-runner.ts must NOT pass config.workerTools directly as the worker `tools:` " + - "option. Use buildWorkerToolsAllowlist(config.workerTools) so engine bridge tools " + - "(review_step, notify_supervisor, escalate_to_supervisor, request_segment_expansion) " + - "are always present. See TP-184 / issue #530.", + "option. Use buildWorkerToolsAllowlist(config.workerTools) so engine bridge tools " + + "(review_step, notify_supervisor, escalate_to_supervisor, request_segment_expansion) " + + "are always present. See TP-184 / issue #530.", ); }); @@ -102,13 +101,12 @@ describe("TP-189-A1 — lane-runner.ts worker spawn-site wires buildWorkerToolsA lastAgentIdIdx > -1, "no `agentId:` field found before the buildWorkerToolsAllowlist call site", ); - const linesBetween = - laneRunnerSrc.slice(lastAgentIdIdx, helperCallIdx).split("\n").length; + const linesBetween = laneRunnerSrc.slice(lastAgentIdIdx, helperCallIdx).split("\n").length; assert.ok( linesBetween < 80, `buildWorkerToolsAllowlist call site is ${linesBetween} lines from the nearest \`agentId:\` field; ` + - `expected < 80 (call should be inside the AgentHostOptions object literal). ` + - `If the spawn site has been refactored, widen this tolerance or update the test.`, + `expected < 80 (call should be inside the AgentHostOptions object literal). ` + + `If the spawn site has been refactored, widen this tolerance or update the test.`, ); }); }); diff --git a/extensions/tests/lane-runner-v2.test.ts b/extensions/tests/lane-runner-v2.test.ts index 9f63e4b6..3e646b85 100644 --- a/extensions/tests/lane-runner-v2.test.ts +++ b/extensions/tests/lane-runner-v2.test.ts @@ -21,7 +21,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const laneRunnerSrc = readFileSync(join(__dirname, "..", "taskplane", "lane-runner.ts"), "utf-8"); const executionSrc = readFileSync(join(__dirname, "..", "taskplane", "execution.ts"), "utf-8"); -const agentBridgeSrc = readFileSync(join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), "utf-8"); +const agentBridgeSrc = readFileSync( + join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), + "utf-8", +); // ── 1. Lane-runner module structure ───────────────────────────────── @@ -282,7 +285,7 @@ describe("6.x: Segment-aware lane execution contracts", () => { it("6.4: lane snapshots include segmentId", () => { expect(laneRunnerSrc).toContain("segmentId: string | null"); expect(laneRunnerSrc).toContain("segmentId,"); - expect(laneRunnerSrc).toContain("emitSnapshot(config, taskId, segmentId"); + expect(laneRunnerSrc).toContainNormalized("emitSnapshot(config, taskId, segmentId"); }); }); @@ -309,7 +312,9 @@ describe("8.x: Multi-segment .DONE timing (TP-145)", () => { // It checks segmentId is non-null, segmentIds has multiple entries, and current is not last expect(laneRunnerSrc).toContain("segmentId != null"); expect(laneRunnerSrc).toContain("unit.task.segmentIds.length > 1"); - expect(laneRunnerSrc).toContain('unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId'); + expect(laneRunnerSrc).toContain( + "unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId", + ); }); it("8.2: non-final segment returns succeeded without creating .DONE", () => { @@ -318,7 +323,7 @@ describe("8.x: Multi-segment .DONE timing (TP-145)", () => { // The return for non-final segment passes doneFileFound=false const nonFinalBlock = laneRunnerSrc.slice( laneRunnerSrc.indexOf("isNonFinalSegment"), - laneRunnerSrc.indexOf("// Create .DONE if not already present") + laneRunnerSrc.indexOf("// Create .DONE if not already present"), ); expect(nonFinalBlock).toContain('"succeeded"'); expect(nonFinalBlock).toContain("false"); @@ -327,11 +332,11 @@ describe("8.x: Multi-segment .DONE timing (TP-145)", () => { it("8.3: final segment and single-segment tasks still create .DONE", () => { // The .DONE creation code is preserved after the non-final guard const afterGuard = laneRunnerSrc.slice( - laneRunnerSrc.indexOf("// Create .DONE if not already present") + laneRunnerSrc.indexOf("// Create .DONE if not already present"), ); expect(afterGuard).toContain("writeFileSync(donePath"); expect(afterGuard).toContain('"✅ Complete"'); - expect(afterGuard).toContain('.DONE created'); + expect(afterGuard).toContain(".DONE created"); }); it("8.4: single-segment task (segmentId null) is unaffected", () => { @@ -339,6 +344,6 @@ describe("8.x: Multi-segment .DONE timing (TP-145)", () => { // This means the .DONE creation block runs normally expect(laneRunnerSrc).toContain("segmentId != null"); // The logical expression evaluates to false when segmentId is null - expect(laneRunnerSrc).toContain("const isNonFinalSegment = segmentId != null"); + expect(laneRunnerSrc).toContainNormalized("const isNonFinalSegment = segmentId != null"); }); }); diff --git a/extensions/tests/mailbox-supervisor-tool.test.ts b/extensions/tests/mailbox-supervisor-tool.test.ts index 77a76003..13a1c22d 100644 --- a/extensions/tests/mailbox-supervisor-tool.test.ts +++ b/extensions/tests/mailbox-supervisor-tool.test.ts @@ -30,7 +30,9 @@ describe("send_agent_message guards", () => { describe("workspace-root cleanup wiring", () => { it("buildIntegrationExecutor uses stateRoot override for cleanupPostIntegrate", () => { - expect(extensionSource).toContain("buildIntegrationExecutor(repoRoot: string, opId?: string, stateRoot?: string)"); + expect(extensionSource).toContainNormalized( + "buildIntegrationExecutor(repoRoot: string, opId?: string, stateRoot?: string)", + ); expect(extensionSource).toContain("cleanupPostIntegrate(stateRoot ?? repoRoot, context.batchId)"); expect(extensionSource).toContain("withPreservedBatchHistory(effectiveStateRoot"); }); diff --git a/extensions/tests/mailbox-v2.test.ts b/extensions/tests/mailbox-v2.test.ts index 8ede3216..69f5a288 100644 --- a/extensions/tests/mailbox-v2.test.ts +++ b/extensions/tests/mailbox-v2.test.ts @@ -40,7 +40,11 @@ beforeEach(() => { }); afterEach(() => { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } _resetRateLimits(); }); @@ -58,7 +62,7 @@ describe("1.x: Agent outbox", () => { }); const outDir = sessionOutboxDir(tmpDir, batchId, agentId); expect(existsSync(outDir)).toBe(true); - const files = readdirSync(outDir).filter(f => f.endsWith(".msg.json")); + const files = readdirSync(outDir).filter((f) => f.endsWith(".msg.json")); expect(files.length).toBe(1); expect(msg.to).toBe("supervisor"); expect(msg.type).toBe("reply"); @@ -66,10 +70,14 @@ describe("1.x: Agent outbox", () => { it("1.2: readOutbox returns all written messages", () => { writeOutboxMessage(tmpDir, batchId, agentId, { from: agentId, type: "reply", content: "first" }); - writeOutboxMessage(tmpDir, batchId, agentId, { from: agentId, type: "escalate", content: "second" }); + writeOutboxMessage(tmpDir, batchId, agentId, { + from: agentId, + type: "escalate", + content: "second", + }); const messages = readOutbox(tmpDir, batchId, agentId); expect(messages.length).toBe(2); - const contents = messages.map(m => m.content).sort(); + const contents = messages.map((m) => m.content).sort(); expect(contents).toContain("first"); expect(contents).toContain("second"); }); @@ -98,7 +106,11 @@ describe("1.x: Agent outbox", () => { const bigContent = "x".repeat(5000); let threw = false; try { - writeOutboxMessage(tmpDir, batchId, agentId, { from: agentId, type: "reply", content: bigContent }); + writeOutboxMessage(tmpDir, batchId, agentId, { + from: agentId, + type: "reply", + content: bigContent, + }); } catch { threw = true; } @@ -114,7 +126,16 @@ describe("1.x: Agent outbox", () => { expect(readOutbox(tmpDir, batchId, agentId).length).toBe(1); expect(ackOutboxMessage(tmpDir, batchId, agentId, msg.id)).toBe(true); expect(readOutbox(tmpDir, batchId, agentId).length).toBe(0); - const processedPath = join(tmpDir, ".pi", "mailbox", batchId, agentId, "outbox", "processed", `${msg.id}.msg.json`); + const processedPath = join( + tmpDir, + ".pi", + "mailbox", + batchId, + agentId, + "outbox", + "processed", + `${msg.id}.msg.json`, + ); expect(existsSync(processedPath)).toBe(true); }); }); @@ -132,7 +153,7 @@ describe("2.x: Broadcast messages", () => { }); const broadcastInbox = join(tmpDir, ".pi", "mailbox", batchId, "_broadcast", "inbox"); expect(existsSync(broadcastInbox)).toBe(true); - const files = readdirSync(broadcastInbox).filter(f => f.endsWith(".msg.json")); + const files = readdirSync(broadcastInbox).filter((f) => f.endsWith(".msg.json")); expect(files.length).toBe(1); expect(msg.to).toBe("_broadcast"); }); @@ -338,22 +359,34 @@ describe("7.x: Agent bridge extension", () => { }); it("7.2: provides notify_supervisor tool", () => { - const src = readFileSync(join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), "utf-8"); + const src = readFileSync( + join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), + "utf-8", + ); expect(src).toContain('"notify_supervisor"'); }); it("7.3: provides escalate_to_supervisor tool", () => { - const src = readFileSync(join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), "utf-8"); + const src = readFileSync( + join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), + "utf-8", + ); expect(src).toContain('"escalate_to_supervisor"'); }); it("7.4: writes to outbox directory via TASKPLANE_OUTBOX_DIR", () => { - const src = readFileSync(join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), "utf-8"); + const src = readFileSync( + join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), + "utf-8", + ); expect(src).toContain("TASKPLANE_OUTBOX_DIR"); }); it("7.5: uses atomic write (tmp + rename)", () => { - const src = readFileSync(join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), "utf-8"); + const src = readFileSync( + join(__dirname, "..", "taskplane", "agent-bridge-extension.ts"), + "utf-8", + ); expect(src).toContain(".msg.json.tmp"); expect(src).toContain("renameSync"); }); @@ -410,7 +443,11 @@ describe("9.x: Outbox history (pending + processed)", () => { }); it("9.2: readOutboxHistory includes processed (acked) messages", () => { - const msg = writeOutboxMessage(tmpDir, bid, aid, { from: aid, type: "reply", content: "will ack" }); + const msg = writeOutboxMessage(tmpDir, bid, aid, { + from: aid, + type: "reply", + content: "will ack", + }); ackOutboxMessage(tmpDir, bid, aid, msg.id); const history = readOutboxHistory(tmpDir, bid, aid); expect(history.length).toBe(1); @@ -420,12 +457,16 @@ describe("9.x: Outbox history (pending + processed)", () => { it("9.3: readOutboxHistory returns both pending and processed sorted by timestamp", () => { writeOutboxMessage(tmpDir, bid, aid, { from: aid, type: "reply", content: "first" }); - const msg2 = writeOutboxMessage(tmpDir, bid, aid, { from: aid, type: "escalate", content: "second" }); + const msg2 = writeOutboxMessage(tmpDir, bid, aid, { + from: aid, + type: "escalate", + content: "second", + }); ackOutboxMessage(tmpDir, bid, aid, msg2.id); const history = readOutboxHistory(tmpDir, bid, aid); expect(history.length).toBe(2); - const acked = history.filter(h => h.acked); - const pending = history.filter(h => !h.acked); + const acked = history.filter((h) => h.acked); + const pending = history.filter((h) => !h.acked); expect(acked.length).toBe(1); expect(pending.length).toBe(1); }); @@ -460,7 +501,11 @@ describe("10.x: discoverMailboxAgentIds", () => { }); it("10.4: includes agent with only processed outbox (no longer active)", () => { - const msg = writeOutboxMessage(tmpDir, bid, "dead-agent", { from: "dead-agent", type: "reply", content: "old" }); + const msg = writeOutboxMessage(tmpDir, bid, "dead-agent", { + from: "dead-agent", + type: "reply", + content: "old", + }); ackOutboxMessage(tmpDir, bid, "dead-agent", msg.id); const ids = discoverMailboxAgentIds(tmpDir, bid); expect(ids).toContain("dead-agent"); diff --git a/extensions/tests/mailbox.test.ts b/extensions/tests/mailbox.test.ts index ba1217c6..82e0046c 100644 --- a/extensions/tests/mailbox.test.ts +++ b/extensions/tests/mailbox.test.ts @@ -10,7 +10,16 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { expect } from "./expect.ts"; import { join, dirname } from "path"; -import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, statSync, utimesSync } from "fs"; +import { + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, + existsSync, + rmSync, + statSync, + utimesSync, +} from "fs"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; @@ -42,13 +51,20 @@ import { // ── Helpers ────────────────────────────────────────────────────────── function makeTmpDir(prefix: string): string { - const dir = join(tmpdir(), `mailbox-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`); + const dir = join( + tmpdir(), + `mailbox-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } function cleanupDir(dir: string): void { - try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* best-effort */ + } } // ── 1. Path Helpers ────────────────────────────────────────────────── @@ -61,17 +77,23 @@ describe("Mailbox path helpers", () => { it("sessionInboxDir returns correct path", () => { const result = sessionInboxDir("/workspace", "20260329T120000", "orch-lane-1-worker"); - expect(result).toBe(join("/workspace", ".pi", MAILBOX_DIR_NAME, "20260329T120000", "orch-lane-1-worker", "inbox")); + expect(result).toBe( + join("/workspace", ".pi", MAILBOX_DIR_NAME, "20260329T120000", "orch-lane-1-worker", "inbox"), + ); }); it("sessionAckDir returns correct path", () => { const result = sessionAckDir("/workspace", "20260329T120000", "orch-lane-1-worker"); - expect(result).toBe(join("/workspace", ".pi", MAILBOX_DIR_NAME, "20260329T120000", "orch-lane-1-worker", "ack")); + expect(result).toBe( + join("/workspace", ".pi", MAILBOX_DIR_NAME, "20260329T120000", "orch-lane-1-worker", "ack"), + ); }); it("broadcastInboxDir returns correct path", () => { const result = broadcastInboxDir("/workspace", "20260329T120000"); - expect(result).toBe(join("/workspace", ".pi", MAILBOX_DIR_NAME, "20260329T120000", "_broadcast", "inbox")); + expect(result).toBe( + join("/workspace", ".pi", MAILBOX_DIR_NAME, "20260329T120000", "_broadcast", "inbox"), + ); }); }); @@ -238,9 +260,33 @@ describe("readInbox", () => { mkdirSync(inboxDir, { recursive: true }); // Write messages with different timestamps - const msg1 = { id: "1000-aaa00", batchId: "batch-1", from: "supervisor", to: "session-1", timestamp: 1000, type: "steer", content: "first" }; - const msg3 = { id: "3000-ccc00", batchId: "batch-1", from: "supervisor", to: "session-1", timestamp: 3000, type: "steer", content: "third" }; - const msg2 = { id: "2000-bbb00", batchId: "batch-1", from: "supervisor", to: "session-1", timestamp: 2000, type: "steer", content: "second" }; + const msg1 = { + id: "1000-aaa00", + batchId: "batch-1", + from: "supervisor", + to: "session-1", + timestamp: 1000, + type: "steer", + content: "first", + }; + const msg3 = { + id: "3000-ccc00", + batchId: "batch-1", + from: "supervisor", + to: "session-1", + timestamp: 3000, + type: "steer", + content: "third", + }; + const msg2 = { + id: "2000-bbb00", + batchId: "batch-1", + from: "supervisor", + to: "session-1", + timestamp: 2000, + type: "steer", + content: "second", + }; // Write in non-sorted order writeFileSync(join(inboxDir, "3000-ccc00.msg.json"), JSON.stringify(msg3)); @@ -258,7 +304,15 @@ describe("readInbox", () => { const inboxDir = join(tmpDir, "inbox"); mkdirSync(inboxDir, { recursive: true }); - const validMsg = { id: "1000-aaa00", batchId: "batch-1", from: "sup", to: "s1", timestamp: 1000, type: "steer", content: "valid" }; + const validMsg = { + id: "1000-aaa00", + batchId: "batch-1", + from: "sup", + to: "s1", + timestamp: 1000, + type: "steer", + content: "valid", + }; writeFileSync(join(inboxDir, "1000-aaa00.msg.json"), JSON.stringify(validMsg)); writeFileSync(join(inboxDir, "1000-aaa00.msg.json.tmp"), JSON.stringify(validMsg)); // temp file writeFileSync(join(inboxDir, "random.txt"), "not a message"); @@ -278,8 +332,24 @@ describe("readInbox", () => { const inboxDir = join(tmpDir, "inbox"); mkdirSync(inboxDir, { recursive: true }); - const wrongBatch = { id: "1000-aaa00", batchId: "wrong-batch", from: "sup", to: "s1", timestamp: 1000, type: "steer", content: "wrong" }; - const rightBatch = { id: "2000-bbb00", batchId: "batch-1", from: "sup", to: "s1", timestamp: 2000, type: "steer", content: "right" }; + const wrongBatch = { + id: "1000-aaa00", + batchId: "wrong-batch", + from: "sup", + to: "s1", + timestamp: 1000, + type: "steer", + content: "wrong", + }; + const rightBatch = { + id: "2000-bbb00", + batchId: "batch-1", + from: "sup", + to: "s1", + timestamp: 2000, + type: "steer", + content: "right", + }; writeFileSync(join(inboxDir, "1000-aaa00.msg.json"), JSON.stringify(wrongBatch)); writeFileSync(join(inboxDir, "2000-bbb00.msg.json"), JSON.stringify(rightBatch)); @@ -296,7 +366,15 @@ describe("readInbox", () => { mkdirSync(inboxDir, { recursive: true }); writeFileSync(join(inboxDir, "bad-json.msg.json"), "not valid json {{{"); - const validMsg = { id: "1000-aaa00", batchId: "batch-1", from: "sup", to: "s1", timestamp: 1000, type: "steer", content: "valid" }; + const validMsg = { + id: "1000-aaa00", + batchId: "batch-1", + from: "sup", + to: "s1", + timestamp: 1000, + type: "steer", + content: "valid", + }; writeFileSync(join(inboxDir, "1000-aaa00.msg.json"), JSON.stringify(validMsg)); const results = readInbox(inboxDir, "batch-1"); @@ -309,15 +387,37 @@ describe("readInbox", () => { mkdirSync(inboxDir, { recursive: true }); // Missing 'type' field - const incomplete = { id: "1000-aaa00", batchId: "batch-1", from: "sup", to: "s1", timestamp: 1000, content: "test" }; + const incomplete = { + id: "1000-aaa00", + batchId: "batch-1", + from: "sup", + to: "s1", + timestamp: 1000, + content: "test", + }; writeFileSync(join(inboxDir, "1000-aaa00.msg.json"), JSON.stringify(incomplete)); // Missing 'id' field - const noId = { batchId: "batch-1", from: "sup", to: "s1", timestamp: 1000, type: "steer", content: "test" }; + const noId = { + batchId: "batch-1", + from: "sup", + to: "s1", + timestamp: 1000, + type: "steer", + content: "test", + }; writeFileSync(join(inboxDir, "no-id.msg.json"), JSON.stringify(noId)); // Non-finite timestamp - const badTs = { id: "2000-bbb00", batchId: "batch-1", from: "sup", to: "s1", timestamp: NaN, type: "steer", content: "test" }; + const badTs = { + id: "2000-bbb00", + batchId: "batch-1", + from: "sup", + to: "s1", + timestamp: NaN, + type: "steer", + content: "test", + }; writeFileSync(join(inboxDir, "2000-bbb00.msg.json"), JSON.stringify(badTs)); const results = readInbox(inboxDir, "batch-1"); @@ -406,11 +506,31 @@ describe("isValidMailboxMessage", () => { it("rejects missing required fields", () => { // Missing id - expect(isValidMailboxMessage({ batchId: "b", from: "f", to: "t", timestamp: 1, type: "steer", content: "c" })).toBe(false); + expect( + isValidMailboxMessage({ + batchId: "b", + from: "f", + to: "t", + timestamp: 1, + type: "steer", + content: "c", + }), + ).toBe(false); // Missing content - expect(isValidMailboxMessage({ id: "i", batchId: "b", from: "f", to: "t", timestamp: 1, type: "steer" })).toBe(false); + expect( + isValidMailboxMessage({ + id: "i", + batchId: "b", + from: "f", + to: "t", + timestamp: 1, + type: "steer", + }), + ).toBe(false); // Missing type - expect(isValidMailboxMessage({ id: "i", batchId: "b", from: "f", to: "t", timestamp: 1, content: "c" })).toBe(false); + expect( + isValidMailboxMessage({ id: "i", batchId: "b", from: "f", to: "t", timestamp: 1, content: "c" }), + ).toBe(false); }); it("rejects invalid type value", () => { @@ -656,7 +776,9 @@ function sanitizeSteeringContent(content: string): string { function appendTableRow(statusPath: string, sectionName: string, row: string): void { let content = readFileSync(statusPath, "utf-8").replace(/\r\n/g, "\n"); const lines = content.split("\n"); - let insertIdx = -1, inSection = false, lastTableRow = -1; + let insertIdx = -1, + inSection = false, + lastTableRow = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].match(new RegExp(`^##\\s+${sectionName}`))) { inSection = true; @@ -688,7 +810,7 @@ function processSteeringPending(taskFolder: string, statusPath: string): number let annotated = 0; if (existsSync(steeringFlagPath)) { const raw = readFileSync(steeringFlagPath, "utf-8"); - const lines = raw.split("\n").filter(l => l.trim()); + const lines = raw.split("\n").filter((l) => l.trim()); for (const line of lines) { try { const entry = JSON.parse(line) as { ts: number; content: string; id: string }; @@ -771,7 +893,7 @@ describe("TP-090: Steering-pending annotation", () => { { ts: 1774800000000, content: "First steering.", id: "1000-aaa00" }, { ts: 1774800001000, content: "Second steering.", id: "2000-bbb00" }, ]; - const jsonl = entries.map(e => JSON.stringify(e)).join("\n") + "\n"; + const jsonl = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; writeFileSync(join(tmpDir, ".steering-pending"), jsonl); const count = processSteeringPending(tmpDir, statusPath); @@ -786,8 +908,10 @@ describe("TP-090: Steering-pending annotation", () => { const statusPath = join(tmpDir, "STATUS.md"); writeFileSync(statusPath, SAMPLE_STATUS_MD); - const jsonl = '{invalid json\n' + - JSON.stringify({ ts: 1774800000000, content: "Valid entry.", id: "3000-ccc00" }) + '\n'; + const jsonl = + "{invalid json\n" + + JSON.stringify({ ts: 1774800000000, content: "Valid entry.", id: "3000-ccc00" }) + + "\n"; writeFileSync(join(tmpDir, ".steering-pending"), jsonl); const count = processSteeringPending(tmpDir, statusPath); @@ -838,4 +962,3 @@ describe("TP-090: sanitizeSteeringContent", () => { expect(sanitizeSteeringContent("a\nb|c")).toBe("a / b\\|c"); }); }); - diff --git a/extensions/tests/merge-failure-phase.test.ts b/extensions/tests/merge-failure-phase.test.ts index 6e90a94d..5b7ad1b9 100644 --- a/extensions/tests/merge-failure-phase.test.ts +++ b/extensions/tests/merge-failure-phase.test.ts @@ -21,7 +21,11 @@ import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { checkResumeEligibility } from "../taskplane/resume.ts"; import type { OrchBatchPhase, PersistedBatchState } from "../taskplane/types.ts"; -import { BATCH_STATE_SCHEMA_VERSION, defaultResilienceState, defaultBatchDiagnostics } from "../taskplane/types.ts"; +import { + BATCH_STATE_SCHEMA_VERSION, + defaultResilienceState, + defaultBatchDiagnostics, +} from "../taskplane/types.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -63,10 +67,7 @@ function makeState(phase: OrchBatchPhase): PersistedBatchState { describe("merge failure → paused: source verification", () => { it("engine.ts contains failedTasks > 0 → paused transition", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Verify the TP-031 pattern: failedTasks > 0 → "paused" (not "failed") expect(engineSource).toContain('batchState.phase = "paused"'); @@ -77,10 +78,7 @@ describe("merge failure → paused: source verification", () => { }); it("resume.ts contains failedTasks > 0 → paused transition (parity)", () => { - const resumeSource = readFileSync( - join(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Same pattern must exist in resume.ts for parity expect(resumeSource).toContain('batchState.phase = "paused"'); @@ -91,10 +89,7 @@ describe("merge failure → paused: source verification", () => { }); it("engine.ts preserves worktrees before cleanup when failedTasks > 0", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Pre-cleanup preservation must appear BEFORE the cleanup section const preserveIdx = engineSource.indexOf("preserveWorktreesForResume = true"); @@ -106,20 +101,19 @@ describe("merge failure → paused: source verification", () => { // Find the FIRST occurrence of the pre-cleanup preservation (the one before cleanup) // The pattern includes failedTasks > 0 check - const preCleanupPattern = "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume"; + const preCleanupPattern = + "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume"; const preCleanupIdx = engineSource.indexOf(preCleanupPattern); expect(preCleanupIdx).toBeGreaterThan(-1); expect(preCleanupIdx).toBeLessThan(cleanupIdx); }); it("resume.ts preserves worktrees before cleanup when failedTasks > 0 (parity)", () => { - const resumeSource = readFileSync( - join(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Same pre-cleanup preservation must exist in resume.ts - const preCleanupPattern = "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume"; + const preCleanupPattern = + "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume"; const preCleanupIdx = resumeSource.indexOf(preCleanupPattern); expect(preCleanupIdx).toBeGreaterThan(-1); @@ -132,10 +126,7 @@ describe("merge failure → paused: source verification", () => { }); it("engine.ts transitions to 'completed' when failedTasks === 0 (success path)", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Use "Normal completion" as the unique anchor for the finalization block const anchorMarker = "Normal completion (not stopped, paused, or aborted)"; @@ -155,10 +146,7 @@ describe("merge failure → paused: source verification", () => { }); it("resume.ts transitions to 'completed' when failedTasks === 0 (success path parity)", () => { - const resumeSource = readFileSync( - join(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Use the TP-031 parity comment as unique anchor for the finalization block const anchorMarker = "TP-031: Parity with engine.ts"; diff --git a/extensions/tests/merge-repo-scoped.test.ts b/extensions/tests/merge-repo-scoped.test.ts index 18630266..b386de66 100644 --- a/extensions/tests/merge-repo-scoped.test.ts +++ b/extensions/tests/merge-repo-scoped.test.ts @@ -79,7 +79,9 @@ function makeLane( return { laneNumber, laneId: opts?.repoId ? `${opts.repoId}/lane-${laneNumber}` : `lane-${laneNumber}`, - laneSessionId: opts?.repoId ? `orch-${opts.repoId}-lane-${laneNumber}` : `orch-lane-${laneNumber}`, + laneSessionId: opts?.repoId + ? `orch-${opts.repoId}-lane-${laneNumber}` + : `orch-lane-${laneNumber}`, worktreePath: `/worktrees/wt-${laneNumber}`, branch: opts?.branch ?? `task/lane-${laneNumber}-20260315T100000`, tasks: taskIds.map((id, i) => makeAllocatedTask(id, i, opts?.fileScope)), @@ -139,10 +141,13 @@ function runAllTests(): void { assert(groups[1].lanes.length === 2, "multi-repo: frontend group has 2 lanes"); // Lane numbers within each group - const apiLanes = groups[0].lanes.map(l => l.laneNumber).sort(); - const frontendLanes = groups[1].lanes.map(l => l.laneNumber).sort(); + const apiLanes = groups[0].lanes.map((l) => l.laneNumber).sort(); + const frontendLanes = groups[1].lanes.map((l) => l.laneNumber).sort(); assert(apiLanes[0] === 2 && apiLanes[1] === 4, "multi-repo: api group contains lanes 2, 4"); - assert(frontendLanes[0] === 1 && frontendLanes[1] === 3, "multi-repo: frontend group contains lanes 1, 3"); + assert( + frontendLanes[0] === 1 && frontendLanes[1] === 3, + "multi-repo: frontend group contains lanes 1, 3", + ); } // ─── 2. groupLanesByRepo: mono-repo (no repoId) → single group ── @@ -165,9 +170,9 @@ function runAllTests(): void { console.log("\n── 3. groupLanesByRepo: mixed undefined + repoId ──"); { const lanes: AllocatedLane[] = [ - makeLane(1, ["TP-030"]), // undefined repoId + makeLane(1, ["TP-030"]), // undefined repoId makeLane(2, ["TP-031"], { repoId: "backend" }), - makeLane(3, ["TP-032"]), // undefined repoId + makeLane(3, ["TP-032"]), // undefined repoId ]; const groups = groupLanesByRepo(lanes); @@ -244,21 +249,23 @@ function runAllTests(): void { makeLane(1, ["TP-071"], { repoId: "a-repo" }), makeLane(3, ["TP-072"], { repoId: "z-repo" }), makeLane(2, ["TP-073"], { repoId: "a-repo" }), - makeLane(4, ["TP-074"]), // undefined + makeLane(4, ["TP-074"]), // undefined ]; // Run grouping multiple times const results = []; for (let i = 0; i < 3; i++) { const groups = groupLanesByRepo(lanes); - const summary = groups.map(g => - `${g.repoId ?? ""}:[${g.lanes.map(l => l.laneNumber).join(",")}]` - ).join("|"); + const summary = groups + .map((g) => `${g.repoId ?? ""}:[${g.lanes.map((l) => l.laneNumber).join(",")}]`) + .join("|"); results.push(summary); } - assert(results[0] === results[1] && results[1] === results[2], - "deterministic: groupLanesByRepo produces identical output across 3 runs"); + assert( + results[0] === results[1] && results[1] === results[2], + "deterministic: groupLanesByRepo produces identical output across 3 runs", + ); // Verify the exact expected order const groups = groupLanesByRepo(lanes); @@ -285,9 +292,9 @@ function runAllTests(): void { repoStatuses: Array<"succeeded" | "failed" | "partial">, ): "succeeded" | "failed" | "partial" { const anyLaneSucceeded = laneResults.some( - r => r.resultStatus === "SUCCESS" || r.resultStatus === "CONFLICT_RESOLVED", + (r) => r.resultStatus === "SUCCESS" || r.resultStatus === "CONFLICT_RESOLVED", ); - const anyRepoFailed = repoStatuses.some(s => s !== "succeeded"); + const anyRepoFailed = repoStatuses.some((s) => s !== "succeeded"); if (!anyRepoFailed) return "succeeded"; if (anyLaneSucceeded) return "partial"; return "failed"; @@ -296,7 +303,10 @@ function runAllTests(): void { // Case A: All lanes succeed → succeeded assert( computeAggregateStatus( - [{ resultStatus: "SUCCESS", error: null }, { resultStatus: "SUCCESS", error: null }], + [ + { resultStatus: "SUCCESS", error: null }, + { resultStatus: "SUCCESS", error: null }, + ], ["succeeded", "succeeded"], ) === "succeeded", "rollup: all SUCCESS → succeeded", @@ -305,7 +315,10 @@ function runAllTests(): void { // Case B: Some lanes succeed, some fail → partial assert( computeAggregateStatus( - [{ resultStatus: "SUCCESS", error: null }, { resultStatus: "CONFLICT_UNRESOLVED", error: null }], + [ + { resultStatus: "SUCCESS", error: null }, + { resultStatus: "CONFLICT_UNRESOLVED", error: null }, + ], ["partial"], ) === "partial", "rollup: mixed SUCCESS + failure → partial", @@ -314,7 +327,10 @@ function runAllTests(): void { // Case C: All lanes fail → failed assert( computeAggregateStatus( - [{ resultStatus: "CONFLICT_UNRESOLVED", error: null }, { resultStatus: "BUILD_FAILURE", error: null }], + [ + { resultStatus: "CONFLICT_UNRESOLVED", error: null }, + { resultStatus: "BUILD_FAILURE", error: null }, + ], ["failed"], ) === "failed", "rollup: all failures → failed", @@ -327,10 +343,10 @@ function runAllTests(): void { assert( computeAggregateStatus( [ - { resultStatus: "SUCCESS", error: null }, // repo-a lane 1 - { resultStatus: "CONFLICT_UNRESOLVED", error: null }, // repo-a lane 2 (failure) - { resultStatus: "CONFLICT_RESOLVED", error: null }, // repo-b lane 1 - { resultStatus: "BUILD_FAILURE", error: null }, // repo-b lane 2 (failure) + { resultStatus: "SUCCESS", error: null }, // repo-a lane 1 + { resultStatus: "CONFLICT_UNRESOLVED", error: null }, // repo-a lane 2 (failure) + { resultStatus: "CONFLICT_RESOLVED", error: null }, // repo-b lane 1 + { resultStatus: "BUILD_FAILURE", error: null }, // repo-b lane 2 (failure) ], ["partial", "partial"], ) === "partial", @@ -338,24 +354,21 @@ function runAllTests(): void { ); // Case E: No lanes at all (vacuous) → succeeded - assert( - computeAggregateStatus([], []) === "succeeded", - "rollup: no lanes → succeeded (vacuous)", - ); + assert(computeAggregateStatus([], []) === "succeeded", "rollup: no lanes → succeeded (vacuous)"); // Case F: Error lanes (no result, only error) → failed assert( - computeAggregateStatus( - [{ resultStatus: null, error: "spawn failed" }], - ["failed"], - ) === "failed", + computeAggregateStatus([{ resultStatus: null, error: "spawn failed" }], ["failed"]) === "failed", "rollup: error lane without result → failed", ); // Case G: Mix of success + error → partial assert( computeAggregateStatus( - [{ resultStatus: "SUCCESS", error: null }, { resultStatus: null, error: "timeout" }], + [ + { resultStatus: "SUCCESS", error: null }, + { resultStatus: null, error: "timeout" }, + ], ["partial"], ) === "partial", "rollup: success + error → partial", @@ -399,8 +412,8 @@ function runAllTests(): void { assert( computeAggregateStatus( [ - { resultStatus: "SUCCESS", error: null }, // repo B lane 1 - { resultStatus: "BUILD_FAILURE", error: null }, // repo B lane 2 + { resultStatus: "SUCCESS", error: null }, // repo B lane 1 + { resultStatus: "BUILD_FAILURE", error: null }, // repo B lane 2 ], ["failed", "partial"], // repo A setup fail, repo B partial ) === "partial", @@ -441,14 +454,38 @@ function runAllTests(): void { status: "partial", laneResults: [ { - laneNumber: 1, laneId: "api/lane-1", sourceBranch: "task/lane-1", - targetBranch: "main", result: { status: "SUCCESS", source_branch: "task/lane-1", target_branch: "main", merge_commit: "abc1234", conflicts: [], verification: { ran: true, passed: true, output: "" } }, - error: null, durationMs: 5000, repoId: "api", + laneNumber: 1, + laneId: "api/lane-1", + sourceBranch: "task/lane-1", + targetBranch: "main", + result: { + status: "SUCCESS", + source_branch: "task/lane-1", + target_branch: "main", + merge_commit: "abc1234", + conflicts: [], + verification: { ran: true, passed: true, output: "" }, + }, + error: null, + durationMs: 5000, + repoId: "api", }, { - laneNumber: 2, laneId: "frontend/lane-2", sourceBranch: "task/lane-2", - targetBranch: "main", result: { status: "CONFLICT_UNRESOLVED", source_branch: "task/lane-2", target_branch: "main", merge_commit: "", conflicts: [{ file: "index.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" } }, - error: null, durationMs: 3000, repoId: "frontend", + laneNumber: 2, + laneId: "frontend/lane-2", + sourceBranch: "task/lane-2", + targetBranch: "main", + result: { + status: "CONFLICT_UNRESOLVED", + source_branch: "task/lane-2", + target_branch: "main", + merge_commit: "", + conflicts: [{ file: "index.ts", type: "content", resolved: false }], + verification: { ran: false, passed: false, output: "" }, + }, + error: null, + durationMs: 3000, + repoId: "frontend", }, ], failedLane: 2, @@ -458,22 +495,50 @@ function runAllTests(): void { { repoId: "api", status: "succeeded", - laneResults: [{ - laneNumber: 1, laneId: "api/lane-1", sourceBranch: "task/lane-1", - targetBranch: "main", result: { status: "SUCCESS", source_branch: "task/lane-1", target_branch: "main", merge_commit: "abc1234", conflicts: [], verification: { ran: true, passed: true, output: "" } }, - error: null, durationMs: 5000, repoId: "api", - }], + laneResults: [ + { + laneNumber: 1, + laneId: "api/lane-1", + sourceBranch: "task/lane-1", + targetBranch: "main", + result: { + status: "SUCCESS", + source_branch: "task/lane-1", + target_branch: "main", + merge_commit: "abc1234", + conflicts: [], + verification: { ran: true, passed: true, output: "" }, + }, + error: null, + durationMs: 5000, + repoId: "api", + }, + ], failedLane: null, failureReason: null, }, { repoId: "frontend", status: "failed", - laneResults: [{ - laneNumber: 2, laneId: "frontend/lane-2", sourceBranch: "task/lane-2", - targetBranch: "main", result: { status: "CONFLICT_UNRESOLVED", source_branch: "task/lane-2", target_branch: "main", merge_commit: "", conflicts: [{ file: "index.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" } }, - error: null, durationMs: 3000, repoId: "frontend", - }], + laneResults: [ + { + laneNumber: 2, + laneId: "frontend/lane-2", + sourceBranch: "task/lane-2", + targetBranch: "main", + result: { + status: "CONFLICT_UNRESOLVED", + source_branch: "task/lane-2", + target_branch: "main", + merge_commit: "", + conflicts: [{ file: "index.ts", type: "content", resolved: false }], + verification: { ran: false, passed: false, output: "" }, + }, + error: null, + durationMs: 3000, + repoId: "frontend", + }, + ], failedLane: 2, failureReason: "Unresolved merge conflicts in lane 2: index.ts", }, @@ -500,19 +565,33 @@ function runAllTests(): void { status: "partial", laneResults: [ { - laneNumber: 1, laneId: "lane-1", sourceBranch: "task/lane-1", - targetBranch: "main", result: { status: "SUCCESS", source_branch: "task/lane-1", target_branch: "main", merge_commit: "abc", conflicts: [], verification: { ran: true, passed: true, output: "" } }, - error: null, durationMs: 5000, + laneNumber: 1, + laneId: "lane-1", + sourceBranch: "task/lane-1", + targetBranch: "main", + result: { + status: "SUCCESS", + source_branch: "task/lane-1", + target_branch: "main", + merge_commit: "abc", + conflicts: [], + verification: { ran: true, passed: true, output: "" }, + }, + error: null, + durationMs: 5000, }, ], failedLane: 2, failureReason: "some error", totalDurationMs: 5000, - repoResults: [], // Empty = mono-repo mode + repoResults: [], // Empty = mono-repo mode }; const summary = formatRepoMergeSummary(mergeResult); - assert(summary === null, "mono-repo: formatRepoMergeSummary returns null when repoResults is empty"); + assert( + summary === null, + "mono-repo: formatRepoMergeSummary returns null when repoResults is empty", + ); } // ─── 13. formatRepoMergeSummary: no summary when undefined ─────── @@ -545,12 +624,18 @@ function runAllTests(): void { totalDurationMs: 1000, repoResults: [ { - repoId: "api", status: "partial", - laneResults: [], failedLane: 1, failureReason: "err1", + repoId: "api", + status: "partial", + laneResults: [], + failedLane: 1, + failureReason: "err1", }, { - repoId: "web", status: "partial", - laneResults: [], failedLane: 2, failureReason: "err2", + repoId: "web", + status: "partial", + laneResults: [], + failedLane: 2, + failureReason: "err2", }, ], }; @@ -571,8 +656,11 @@ function runAllTests(): void { totalDurationMs: 1000, repoResults: [ { - repoId: "api", status: "partial", - laneResults: [], failedLane: 2, failureReason: "err", + repoId: "api", + status: "partial", + laneResults: [], + failedLane: 2, + failureReason: "err", }, ], }; @@ -594,31 +682,79 @@ function runAllTests(): void { totalDurationMs: 1000, repoResults: [ { - repoId: "alpha", status: "succeeded", - laneResults: [{ - laneNumber: 1, laneId: "alpha/lane-1", sourceBranch: "b1", targetBranch: "main", - result: { status: "SUCCESS", source_branch: "b1", target_branch: "main", merge_commit: "a", conflicts: [], verification: { ran: true, passed: true, output: "" } }, - error: null, durationMs: 1000, repoId: "alpha", - }], - failedLane: null, failureReason: null, + repoId: "alpha", + status: "succeeded", + laneResults: [ + { + laneNumber: 1, + laneId: "alpha/lane-1", + sourceBranch: "b1", + targetBranch: "main", + result: { + status: "SUCCESS", + source_branch: "b1", + target_branch: "main", + merge_commit: "a", + conflicts: [], + verification: { ran: true, passed: true, output: "" }, + }, + error: null, + durationMs: 1000, + repoId: "alpha", + }, + ], + failedLane: null, + failureReason: null, }, { - repoId: "beta", status: "failed", - laneResults: [{ - laneNumber: 2, laneId: "beta/lane-2", sourceBranch: "b2", targetBranch: "main", - result: { status: "BUILD_FAILURE", source_branch: "b2", target_branch: "main", merge_commit: "", conflicts: [], verification: { ran: true, passed: false, output: "tests failed" } }, - error: null, durationMs: 2000, repoId: "beta", - }], - failedLane: 2, failureReason: "build fail", + repoId: "beta", + status: "failed", + laneResults: [ + { + laneNumber: 2, + laneId: "beta/lane-2", + sourceBranch: "b2", + targetBranch: "main", + result: { + status: "BUILD_FAILURE", + source_branch: "b2", + target_branch: "main", + merge_commit: "", + conflicts: [], + verification: { ran: true, passed: false, output: "tests failed" }, + }, + error: null, + durationMs: 2000, + repoId: "beta", + }, + ], + failedLane: 2, + failureReason: "build fail", }, { - repoId: "gamma", status: "succeeded", - laneResults: [{ - laneNumber: 3, laneId: "gamma/lane-3", sourceBranch: "b3", targetBranch: "main", - result: { status: "CONFLICT_RESOLVED", source_branch: "b3", target_branch: "main", merge_commit: "g", conflicts: [{ file: "x.ts", type: "content", resolved: true }], verification: { ran: true, passed: true, output: "" } }, - error: null, durationMs: 1500, repoId: "gamma", - }], - failedLane: null, failureReason: null, + repoId: "gamma", + status: "succeeded", + laneResults: [ + { + laneNumber: 3, + laneId: "gamma/lane-3", + sourceBranch: "b3", + targetBranch: "main", + result: { + status: "CONFLICT_RESOLVED", + source_branch: "b3", + target_branch: "main", + merge_commit: "g", + conflicts: [{ file: "x.ts", type: "content", resolved: true }], + verification: { ran: true, passed: true, output: "" }, + }, + error: null, + durationMs: 1500, + repoId: "gamma", + }, + ], + failedLane: null, + failureReason: null, }, ], }; @@ -645,8 +781,14 @@ function runAllTests(): void { const lines = [" ✅ api: 1/1 lane(s) merged", " ❌ web: 0/1 lane(s) merged"]; const templateOutput = ORCH_MESSAGES.orchMergePartialRepoSummary(2, lines); assert(templateOutput.includes("Wave 2"), "template: includes wave number"); - assert(templateOutput.includes("partially succeeded"), "template: includes 'partially succeeded'"); - assert(templateOutput.includes("repo outcomes diverged"), "template: includes 'repo outcomes diverged'"); + assert( + templateOutput.includes("partially succeeded"), + "template: includes 'partially succeeded'", + ); + assert( + templateOutput.includes("repo outcomes diverged"), + "template: includes 'repo outcomes diverged'", + ); assert(templateOutput.includes("api"), "template: includes repo lines"); assert(templateOutput.includes("web"), "template: includes repo lines"); } @@ -665,18 +807,27 @@ function runAllTests(): void { totalDurationMs: 1000, repoResults: [ { - repoId: "api", status: "partial", - laneResults: [], failedLane: 1, failureReason: "mixed lanes", + repoId: "api", + status: "partial", + laneResults: [], + failedLane: 1, + failureReason: "mixed lanes", }, { - repoId: "web", status: "partial", - laneResults: [], failedLane: 3, failureReason: "mixed lanes", + repoId: "web", + status: "partial", + laneResults: [], + failedLane: 3, + failureReason: "mixed lanes", }, ], }; const summary = formatRepoMergeSummary(mergeResult); - assert(summary === null, "mixed-outcome-lanes: no repo summary when all repos partial (same status)"); + assert( + summary === null, + "mixed-outcome-lanes: no repo summary when all repos partial (same status)", + ); } // ─── 19. computeMergeFailurePolicy: pause policy ──────────────── @@ -687,13 +838,21 @@ function runAllTests(): void { status: "failed", laneResults: [ { - laneNumber: 3, laneId: "api/lane-3", sourceBranch: "task/lane-3", - targetBranch: "main", result: { - status: "CONFLICT_UNRESOLVED", source_branch: "task/lane-3", target_branch: "main", - merge_commit: "", conflicts: [{ file: "index.ts", type: "content", resolved: false }], + laneNumber: 3, + laneId: "api/lane-3", + sourceBranch: "task/lane-3", + targetBranch: "main", + result: { + status: "CONFLICT_UNRESOLVED", + source_branch: "task/lane-3", + target_branch: "main", + merge_commit: "", + conflicts: [{ file: "index.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" }, }, - error: null, durationMs: 5000, repoId: "api", + error: null, + durationMs: 5000, + repoId: "api", }, ], failedLane: 3, @@ -705,7 +864,10 @@ function runAllTests(): void { assert(result.policy === "pause", "pause-policy: policy is 'pause'"); assert(result.targetPhase === "paused", "pause-policy: targetPhase is 'paused'"); - assert(result.persistTrigger === "merge-failure-pause", "pause-policy: persistTrigger is 'merge-failure-pause'"); + assert( + result.persistTrigger === "merge-failure-pause", + "pause-policy: persistTrigger is 'merge-failure-pause'", + ); assert(result.notifyLevel === "error", "pause-policy: notifyLevel is 'error'"); assert(result.failedLaneIds === "lane-3", "pause-policy: failedLaneIds is 'lane-3'"); assert(result.notifyMessage.includes("⏸️"), "pause-policy: notify has pause emoji"); @@ -727,22 +889,36 @@ function runAllTests(): void { status: "partial", laneResults: [ { - laneNumber: 1, laneId: "lane-1", sourceBranch: "task/lane-1", - targetBranch: "main", result: { - status: "SUCCESS", source_branch: "task/lane-1", target_branch: "main", - merge_commit: "abc1234", conflicts: [], + laneNumber: 1, + laneId: "lane-1", + sourceBranch: "task/lane-1", + targetBranch: "main", + result: { + status: "SUCCESS", + source_branch: "task/lane-1", + target_branch: "main", + merge_commit: "abc1234", + conflicts: [], verification: { ran: true, passed: true, output: "" }, }, - error: null, durationMs: 5000, + error: null, + durationMs: 5000, }, { - laneNumber: 2, laneId: "lane-2", sourceBranch: "task/lane-2", - targetBranch: "main", result: { - status: "BUILD_FAILURE", source_branch: "task/lane-2", target_branch: "main", - merge_commit: "", conflicts: [], + laneNumber: 2, + laneId: "lane-2", + sourceBranch: "task/lane-2", + targetBranch: "main", + result: { + status: "BUILD_FAILURE", + source_branch: "task/lane-2", + target_branch: "main", + merge_commit: "", + conflicts: [], verification: { ran: true, passed: false, output: "tests failed" }, }, - error: null, durationMs: 3000, + error: null, + durationMs: 3000, }, ], failedLane: 2, @@ -754,13 +930,19 @@ function runAllTests(): void { assert(result.policy === "abort", "abort-policy: policy is 'abort'"); assert(result.targetPhase === "stopped", "abort-policy: targetPhase is 'stopped'"); - assert(result.persistTrigger === "merge-failure-abort", "abort-policy: persistTrigger is 'merge-failure-abort'"); + assert( + result.persistTrigger === "merge-failure-abort", + "abort-policy: persistTrigger is 'merge-failure-abort'", + ); assert(result.notifyMessage.includes("⛔"), "abort-policy: notify has stop emoji"); assert(result.notifyMessage.includes("lane-2"), "abort-policy: notify includes lane ID"); assert(result.notifyMessage.includes("wave 1"), "abort-policy: notify includes wave number"); assert(result.notifyMessage.includes("Reason:"), "abort-policy: notify includes reason prefix"); assert(result.failedLaneIds === "lane-2", "abort-policy: failedLaneIds is 'lane-2'"); - assert(result.errorMessage.includes("on_merge_failure"), "abort-policy: error mentions policy name"); + assert( + result.errorMessage.includes("on_merge_failure"), + "abort-policy: error mentions policy name", + ); } // ─── 21. computeMergeFailurePolicy: setup failure (failedLane=null) ── @@ -779,11 +961,20 @@ function runAllTests(): void { const result = computeMergeFailurePolicy(mergeResult, 0, config); assert(result.failedLaneIds === "", "setup-failure: failedLaneIds is empty"); - assert(result.logDetails.failedLane === 0, "setup-failure: logDetails.failedLane is 0 (null mapped to 0)"); + assert( + result.logDetails.failedLane === 0, + "setup-failure: logDetails.failedLane is 0 (null mapped to 0)", + ); assert(result.notifyMessage.includes("wave 1"), "setup-failure: notify includes wave number"); - assert(!result.notifyMessage.includes("(lane-"), "setup-failure: notify does NOT include lane detail"); + assert( + !result.notifyMessage.includes("(lane-"), + "setup-failure: notify does NOT include lane detail", + ); assert(result.notifyMessage.includes("Reason:"), "setup-failure: notify includes reason"); - assert(result.notifyMessage.includes("temp branch"), "setup-failure: notify includes actual reason"); + assert( + result.notifyMessage.includes("temp branch"), + "setup-failure: notify includes actual reason", + ); } // ─── 22. computeMergeFailurePolicy: multi-lane failure attribution ── @@ -794,17 +985,29 @@ function runAllTests(): void { status: "failed", laneResults: [ { - laneNumber: 1, laneId: "lane-1", sourceBranch: "b1", targetBranch: "main", - result: null, error: "spawn failed", durationMs: 100, + laneNumber: 1, + laneId: "lane-1", + sourceBranch: "b1", + targetBranch: "main", + result: null, + error: "spawn failed", + durationMs: 100, }, { - laneNumber: 4, laneId: "lane-4", sourceBranch: "b4", targetBranch: "main", + laneNumber: 4, + laneId: "lane-4", + sourceBranch: "b4", + targetBranch: "main", result: { - status: "BUILD_FAILURE", source_branch: "b4", target_branch: "main", - merge_commit: "", conflicts: [], + status: "BUILD_FAILURE", + source_branch: "b4", + target_branch: "main", + merge_commit: "", + conflicts: [], verification: { ran: true, passed: false, output: "err" }, }, - error: null, durationMs: 200, + error: null, + durationMs: 200, }, ], failedLane: 1, @@ -815,7 +1018,10 @@ function runAllTests(): void { const result = computeMergeFailurePolicy(mergeResult, 2, config); assert(result.failedLaneIds === "lane-1, lane-4", "multi-lane: failedLaneIds lists both lanes"); - assert(result.notifyMessage.includes("lane-1, lane-4"), "multi-lane: notify includes both lane IDs"); + assert( + result.notifyMessage.includes("lane-1, lane-4"), + "multi-lane: notify includes both lane IDs", + ); } // ─── 23. computeMergeFailurePolicy: engine vs resume parity ────── @@ -829,13 +1035,21 @@ function runAllTests(): void { status: "partial", laneResults: [ { - laneNumber: 5, laneId: "api/lane-5", sourceBranch: "task/lane-5", - targetBranch: "develop", result: { - status: "CONFLICT_UNRESOLVED", source_branch: "task/lane-5", target_branch: "develop", - merge_commit: "", conflicts: [{ file: "a.ts", type: "content", resolved: false }], + laneNumber: 5, + laneId: "api/lane-5", + sourceBranch: "task/lane-5", + targetBranch: "develop", + result: { + status: "CONFLICT_UNRESOLVED", + source_branch: "task/lane-5", + target_branch: "develop", + merge_commit: "", + conflicts: [{ file: "a.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" }, }, - error: null, durationMs: 1000, repoId: "api", + error: null, + durationMs: 1000, + repoId: "api", }, ], failedLane: 5, @@ -843,12 +1057,18 @@ function runAllTests(): void { totalDurationMs: 1000, repoResults: [ { - repoId: "api", status: "failed", - laneResults: [], failedLane: 5, failureReason: "Unresolved merge conflicts", + repoId: "api", + status: "failed", + laneResults: [], + failedLane: 5, + failureReason: "Unresolved merge conflicts", }, { - repoId: "web", status: "succeeded", - laneResults: [], failedLane: null, failureReason: null, + repoId: "web", + status: "succeeded", + laneResults: [], + failedLane: null, + failureReason: null, }, ], }; @@ -864,14 +1084,23 @@ function runAllTests(): void { assert(engineResult.targetPhase === resumeResult.targetPhase, "parity: same targetPhase"); assert(engineResult.errorMessage === resumeResult.errorMessage, "parity: same errorMessage"); assert(engineResult.notifyMessage === resumeResult.notifyMessage, "parity: same notifyMessage"); - assert(engineResult.persistTrigger === resumeResult.persistTrigger, "parity: same persistTrigger"); + assert( + engineResult.persistTrigger === resumeResult.persistTrigger, + "parity: same persistTrigger", + ); assert(engineResult.failedLaneIds === resumeResult.failedLaneIds, "parity: same failedLaneIds"); - assert(JSON.stringify(engineResult.logDetails) === JSON.stringify(resumeResult.logDetails), "parity: same logDetails"); + assert( + JSON.stringify(engineResult.logDetails) === JSON.stringify(resumeResult.logDetails), + "parity: same logDetails", + ); // Also check abort policy produces different result const abortResult = computeMergeFailurePolicy(mergeResult, 1, abortConfig); assert(abortResult.policy !== engineResult.policy, "parity: different config → different policy"); - assert(abortResult.targetPhase !== engineResult.targetPhase, "parity: different config → different phase"); + assert( + abortResult.targetPhase !== engineResult.targetPhase, + "parity: different config → different phase", + ); } // ─── 24. computeMergeFailurePolicy: reason truncation ──────────── @@ -890,7 +1119,10 @@ function runAllTests(): void { const result = computeMergeFailurePolicy(mergeResult, 0, config); // Notification should truncate to 200 chars - assert(result.notifyMessage.length < longReason.length + 200, "truncation: notify is shorter than full reason"); + assert( + result.notifyMessage.length < longReason.length + 200, + "truncation: notify is shorter than full reason", + ); assert(result.logDetails.reason.length === 200, "truncation: logDetails.reason is 200 chars"); // Error message stores the full reason for batchState.errors assert(result.errorMessage.includes(longReason), "truncation: errorMessage stores full reason"); @@ -907,14 +1139,38 @@ function runAllTests(): void { status: "partial", laneResults: [ { - laneNumber: 1, laneId: "api/lane-1", sourceBranch: "b1", targetBranch: "main", - result: { status: "SUCCESS", source_branch: "b1", target_branch: "main", merge_commit: "a", conflicts: [], verification: { ran: true, passed: true, output: "" } }, - error: null, durationMs: 100, repoId: "api", + laneNumber: 1, + laneId: "api/lane-1", + sourceBranch: "b1", + targetBranch: "main", + result: { + status: "SUCCESS", + source_branch: "b1", + target_branch: "main", + merge_commit: "a", + conflicts: [], + verification: { ran: true, passed: true, output: "" }, + }, + error: null, + durationMs: 100, + repoId: "api", }, { - laneNumber: 2, laneId: "web/lane-2", sourceBranch: "b2", targetBranch: "main", - result: { status: "CONFLICT_UNRESOLVED", source_branch: "b2", target_branch: "main", merge_commit: "", conflicts: [{ file: "x.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" } }, - error: null, durationMs: 200, repoId: "web", + laneNumber: 2, + laneId: "web/lane-2", + sourceBranch: "b2", + targetBranch: "main", + result: { + status: "CONFLICT_UNRESOLVED", + source_branch: "b2", + target_branch: "main", + merge_commit: "", + conflicts: [{ file: "x.ts", type: "content", resolved: false }], + verification: { ran: false, passed: false, output: "" }, + }, + error: null, + durationMs: 200, + repoId: "web", }, ], failedLane: 2, @@ -931,12 +1187,21 @@ function runAllTests(): void { computeMergeFailurePolicy(mergeResult, 0, config), ]; - assert(results[0].failedLaneIds === results[1].failedLaneIds && results[1].failedLaneIds === results[2].failedLaneIds, - "deterministic: failedLaneIds identical across 3 calls"); - assert(results[0].notifyMessage === results[1].notifyMessage && results[1].notifyMessage === results[2].notifyMessage, - "deterministic: notifyMessage identical across 3 calls"); - assert(results[0].errorMessage === results[1].errorMessage && results[1].errorMessage === results[2].errorMessage, - "deterministic: errorMessage identical across 3 calls"); + assert( + results[0].failedLaneIds === results[1].failedLaneIds && + results[1].failedLaneIds === results[2].failedLaneIds, + "deterministic: failedLaneIds identical across 3 calls", + ); + assert( + results[0].notifyMessage === results[1].notifyMessage && + results[1].notifyMessage === results[2].notifyMessage, + "deterministic: notifyMessage identical across 3 calls", + ); + assert( + results[0].errorMessage === results[1].errorMessage && + results[1].errorMessage === results[2].errorMessage, + "deterministic: errorMessage identical across 3 calls", + ); } // ─── 26. computeMergeFailurePolicy: repo-level fallback for setup failures ── @@ -954,12 +1219,18 @@ function runAllTests(): void { totalDurationMs: 50, repoResults: [ { - repoId: "backend", status: "failed", - laneResults: [], failedLane: null, failureReason: "Merge failed (setup error)", + repoId: "backend", + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "Merge failed (setup error)", }, { - repoId: "frontend", status: "succeeded", - laneResults: [], failedLane: null, failureReason: null, + repoId: "frontend", + status: "succeeded", + laneResults: [], + failedLane: null, + failureReason: null, }, ], }; @@ -967,9 +1238,15 @@ function runAllTests(): void { const result = computeMergeFailurePolicy(mergeResult, 1, config); assert(result.failedLaneIds === "repo:backend", "repo-fallback: failedLaneIds uses repo:backend"); - assert(result.notifyMessage.includes("repo:backend"), "repo-fallback: notify includes repo:backend"); + assert( + result.notifyMessage.includes("repo:backend"), + "repo-fallback: notify includes repo:backend", + ); assert(result.notifyMessage.includes("wave 2"), "repo-fallback: notify includes wave number"); - assert(result.logDetails.failedLaneIds === "repo:backend", "repo-fallback: logDetails uses repo:backend"); + assert( + result.logDetails.failedLaneIds === "repo:backend", + "repo-fallback: logDetails uses repo:backend", + ); } // ─── 27. computeMergeFailurePolicy: multi-repo setup failure fallback ── @@ -985,20 +1262,32 @@ function runAllTests(): void { totalDurationMs: 50, repoResults: [ { - repoId: "api", status: "failed", - laneResults: [], failedLane: null, failureReason: "setup error", + repoId: "api", + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "setup error", }, { - repoId: "web", status: "failed", - laneResults: [], failedLane: null, failureReason: "setup error", + repoId: "web", + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "setup error", }, ], }; const config = makeConfig("abort"); const result = computeMergeFailurePolicy(mergeResult, 0, config); - assert(result.failedLaneIds === "repo:api, repo:web", "multi-repo-setup: failedLaneIds lists both repos"); - assert(result.notifyMessage.includes("repo:api, repo:web"), "multi-repo-setup: notify includes both repos"); + assert( + result.failedLaneIds === "repo:api, repo:web", + "multi-repo-setup: failedLaneIds lists both repos", + ); + assert( + result.notifyMessage.includes("repo:api, repo:web"), + "multi-repo-setup: notify includes both repos", + ); assert(result.targetPhase === "stopped", "multi-repo-setup: abort → stopped"); } @@ -1011,13 +1300,21 @@ function runAllTests(): void { status: "partial", laneResults: [ { - laneNumber: 3, laneId: "web/lane-3", sourceBranch: "b3", targetBranch: "main", + laneNumber: 3, + laneId: "web/lane-3", + sourceBranch: "b3", + targetBranch: "main", result: { - status: "CONFLICT_UNRESOLVED", source_branch: "b3", target_branch: "main", - merge_commit: "", conflicts: [{ file: "y.ts", type: "content", resolved: false }], + status: "CONFLICT_UNRESOLVED", + source_branch: "b3", + target_branch: "main", + merge_commit: "", + conflicts: [{ file: "y.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" }, }, - error: null, durationMs: 200, repoId: "web", + error: null, + durationMs: 200, + repoId: "web", }, ], failedLane: 3, @@ -1025,12 +1322,18 @@ function runAllTests(): void { totalDurationMs: 200, repoResults: [ { - repoId: "api", status: "succeeded", - laneResults: [], failedLane: null, failureReason: null, + repoId: "api", + status: "succeeded", + laneResults: [], + failedLane: null, + failureReason: null, }, { - repoId: "web", status: "failed", - laneResults: [], failedLane: 3, failureReason: "conflicts", + repoId: "web", + status: "failed", + laneResults: [], + failedLane: 3, + failureReason: "conflicts", }, ], }; @@ -1038,8 +1341,14 @@ function runAllTests(): void { const result = computeMergeFailurePolicy(mergeResult, 0, config); // Lane-level attribution should be used, NOT repo-level - assert(result.failedLaneIds === "lane-3", "lane-priority: failedLaneIds is lane-3 (not repo:web)"); - assert(!result.failedLaneIds.includes("repo:"), "lane-priority: no repo: prefix when lane-level exists"); + assert( + result.failedLaneIds === "lane-3", + "lane-priority: failedLaneIds is lane-3 (not repo:web)", + ); + assert( + !result.failedLaneIds.includes("repo:"), + "lane-priority: no repo: prefix when lane-level exists", + ); } // ─── 29. computeMergeFailurePolicy: preserveWorktrees contract ─── @@ -1062,12 +1371,24 @@ function runAllTests(): void { // Both policies produce a definite targetPhase that engine/resume use to trigger // preserveWorktreesForResume = true and skip final cleanup. - assert(pauseResult.targetPhase === "paused", "preserve-contract: pause → paused (triggers worktree preservation)"); - assert(abortResult.targetPhase === "stopped", "preserve-contract: abort → stopped (triggers worktree preservation)"); + assert( + pauseResult.targetPhase === "paused", + "preserve-contract: pause → paused (triggers worktree preservation)", + ); + assert( + abortResult.targetPhase === "stopped", + "preserve-contract: abort → stopped (triggers worktree preservation)", + ); // Both persist triggers are recognized by persistRuntimeState() - assert(pauseResult.persistTrigger === "merge-failure-pause", "preserve-contract: pause persistTrigger"); - assert(abortResult.persistTrigger === "merge-failure-abort", "preserve-contract: abort persistTrigger"); + assert( + pauseResult.persistTrigger === "merge-failure-pause", + "preserve-contract: pause persistTrigger", + ); + assert( + abortResult.persistTrigger === "merge-failure-abort", + "preserve-contract: abort persistTrigger", + ); // Error messages are pushed to batchState.errors for state persistence assert(pauseResult.errorMessage.length > 0, "preserve-contract: pause error non-empty"); diff --git a/extensions/tests/merge-result-schema-compat.test.ts b/extensions/tests/merge-result-schema-compat.test.ts index 40e26b3d..4073d91b 100644 --- a/extensions/tests/merge-result-schema-compat.test.ts +++ b/extensions/tests/merge-result-schema-compat.test.ts @@ -81,7 +81,8 @@ describe("merge result parser compatibility", () => { mergeCommit: "ghi789", verification: { exitCode: 0, - command: "node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts", + command: + "node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts", summary: "all passing", }, }); @@ -103,7 +104,9 @@ describe("merge result parser compatibility", () => { status: "SUCCESS", source_branch: "task/lane-4", verification_passed: true, - verification_commands: ["cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts"], + verification_commands: [ + "cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts", + ], verification_output: "ok", }); @@ -137,13 +140,13 @@ describe("merge request schema guidance", () => { laneNumber: 1, laneId: "lane-1", branch: "task/lane-1", - tasks: [ - { taskId: "TP-999", task: { taskName: "Example Task", fileScope: [] } }, - ], + tasks: [{ taskId: "TP-999", task: { taskName: "Example Task", fileScope: [] } }], } as any, "orch/op", 1, - ["cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts"], + [ + "cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts", + ], "/tmp/result.json", ); diff --git a/extensions/tests/merge-timeout-resilience.test.ts b/extensions/tests/merge-timeout-resilience.test.ts index 921b5e0a..fb255cbb 100644 --- a/extensions/tests/merge-timeout-resilience.test.ts +++ b/extensions/tests/merge-timeout-resilience.test.ts @@ -61,7 +61,7 @@ describe("1.x — Result-exists-at-timeout: accept successful result", () => { const mergeSource = readSource("merge.ts"); // Both statuses should be accepted at timeout - expect(mergeSource).toContain('const SUCCESSFUL_MERGE_STATUSES = new Set'); + expect(mergeSource).toContain("const SUCCESSFUL_MERGE_STATUSES = new Set"); expect(mergeSource).toContain('"SUCCESS"'); expect(mergeSource).toContain('"CONFLICT_RESOLVED"'); }); @@ -109,7 +109,9 @@ describe("2.x — Kill-and-retry: timeout triggers retry with 2x timeout", () => // The retry loop must reference the constant expect(mergeSource).toContain("MERGE_TIMEOUT_MAX_RETRIES"); - expect(mergeSource).toContain("for (let attempt = 0; attempt <= MERGE_TIMEOUT_MAX_RETRIES; attempt++)"); + expect(mergeSource).toContain( + "for (let attempt = 0; attempt <= MERGE_TIMEOUT_MAX_RETRIES; attempt++)", + ); }); it("2.2: MERGE_TIMEOUT_MAX_RETRIES is set to 2", () => { @@ -153,13 +155,15 @@ describe("2.x — Kill-and-retry: timeout triggers retry with 2x timeout", () => mergeSource.indexOf("// First attempt: spawn merge agent"), ); expect(retrySection).toContain("killMergeAgentV2(sessionName)"); - expect(retrySection).toContain("spawnMergeAgentV2(sessionName"); + expect(retrySection).toContainNormalized("spawnMergeAgentV2(sessionName"); }); it("2.7: retry logs attempt number and new timeout values", () => { const mergeSource = readSource("merge.ts"); - expect(mergeSource).toContain("retry ${attempt}/${MERGE_TIMEOUT_MAX_RETRIES} after timeout — respawning merge agent"); + expect(mergeSource).toContain( + "retry ${attempt}/${MERGE_TIMEOUT_MAX_RETRIES} after timeout — respawning merge agent", + ); expect(mergeSource).toContain("newTimeoutMs: currentTimeoutMs"); expect(mergeSource).toContain("newTimeoutMin:"); }); @@ -198,9 +202,9 @@ describe("3.x — Second retry uses 4x timeout (backoff verification)", () => { const attempt1Timeout = baseTimeout * Math.pow(2, 1); const attempt2Timeout = baseTimeout * Math.pow(2, 2); - expect(attempt0Timeout).toBe(600_000); // 10 min - expect(attempt1Timeout).toBe(1_200_000); // 20 min (2x) - expect(attempt2Timeout).toBe(2_400_000); // 40 min (4x) + expect(attempt0Timeout).toBe(600_000); // 10 min + expect(attempt1Timeout).toBe(1_200_000); // 20 min (2x) + expect(attempt2Timeout).toBe(2_400_000); // 40 min (4x) // Verify the progression ratio expect(attempt1Timeout / baseTimeout).toBe(2); @@ -228,7 +232,9 @@ describe("3.x — Second retry uses 4x timeout (backoff verification)", () => { const mergeSource = readSource("merge.ts"); // The retry loop calls waitForMergeResult with the computed timeout + backend - expect(mergeSource).toContain("waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs, runtimeBackend)"); + expect(mergeSource).toContainNormalized( + "waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs, runtimeBackend)", + ); }); it("3.5: with custom config timeout of 15 min, retries use 30 min and 60 min", () => { @@ -261,7 +267,7 @@ describe("4.x — All retries exhausted: failure propagation", () => { // On the final attempt, the catch condition fails (attempt === MAX_RETRIES), // so it falls through to "throw waitErr" const catchBlock = mergeSource.substring( - mergeSource.indexOf("waitErr.code === \"MERGE_TIMEOUT\""), + mergeSource.indexOf('waitErr.code === "MERGE_TIMEOUT"'), mergeSource.indexOf("throw waitErr") + 20, ); expect(catchBlock).toContain("attempt < MERGE_TIMEOUT_MAX_RETRIES"); @@ -278,7 +284,7 @@ describe("4.x — All retries exhausted: failure propagation", () => { const mergeSource = readSource("merge.ts"); // waitForMergeResult throws MERGE_TIMEOUT on timeout - expect(mergeSource).toContain('throw new MergeError('); + expect(mergeSource).toContain("throw new MergeError("); expect(mergeSource).toContain('"MERGE_TIMEOUT"'); // Both patterns appear in the same function (waitForMergeResult) const waitFn = mergeSource.substring( diff --git a/extensions/tests/migrations.test.ts b/extensions/tests/migrations.test.ts index a1abc4fd..829338b3 100644 --- a/extensions/tests/migrations.test.ts +++ b/extensions/tests/migrations.test.ts @@ -26,7 +26,10 @@ import type { MigrationState, TaskplaneMeta } from "../taskplane/migrations.ts"; // ── Test Helpers ───────────────────────────────────────────────────── function createTempDir(): string { - const dir = join(tmpdir(), `tp-migration-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const dir = join( + tmpdir(), + `tp-migration-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } @@ -279,7 +282,7 @@ describe("migrations", () => { }); it("has unique migration IDs", () => { - const ids = MIGRATION_REGISTRY.map(m => m.id); + const ids = MIGRATION_REGISTRY.map((m) => m.id); expect(new Set(ids).size).toBe(ids.length); }); diff --git a/extensions/tests/mocks/pi-ai.ts b/extensions/tests/mocks/pi-ai.ts index abe86077..62edf3c0 100644 --- a/extensions/tests/mocks/pi-ai.ts +++ b/extensions/tests/mocks/pi-ai.ts @@ -2,12 +2,12 @@ export type Model = any; export type Api = any; export const Type = { - Object: (props: any) => ({ type: "object", properties: props }), - String: (opts?: any) => ({ type: "string", ...opts }), - Boolean: (opts?: any) => ({ type: "boolean", ...opts }), - Number: (opts?: any) => ({ type: "number", ...opts }), - Optional: (schema: any) => ({ ...schema, optional: true }), - Union: (schemas: any[]) => ({ anyOf: schemas }), - Literal: (value: any) => ({ const: value }), - Array: (schema: any) => ({ type: "array", items: schema }), + Object: (props: any) => ({ type: "object", properties: props }), + String: (opts?: any) => ({ type: "string", ...opts }), + Boolean: (opts?: any) => ({ type: "boolean", ...opts }), + Number: (opts?: any) => ({ type: "number", ...opts }), + Optional: (schema: any) => ({ ...schema, optional: true }), + Union: (schemas: any[]) => ({ anyOf: schemas }), + Literal: (value: any) => ({ const: value }), + Array: (schema: any) => ({ type: "array", items: schema }), }; diff --git a/extensions/tests/mocks/pi-coding-agent.ts b/extensions/tests/mocks/pi-coding-agent.ts index b13ef1ed..6f8f3554 100644 --- a/extensions/tests/mocks/pi-coding-agent.ts +++ b/extensions/tests/mocks/pi-coding-agent.ts @@ -3,4 +3,6 @@ export type ExtensionContext = any; // Stub value exports used by source files export class DynamicBorder {} -export function getSettingsListTheme(): any { return {}; } +export function getSettingsListTheme(): any { + return {}; +} diff --git a/extensions/tests/mocks/pi-tui.ts b/extensions/tests/mocks/pi-tui.ts index 00831825..bc7469dc 100644 --- a/extensions/tests/mocks/pi-tui.ts +++ b/extensions/tests/mocks/pi-tui.ts @@ -1,5 +1,5 @@ export function truncateToWidth(input: string): string { - return input; + return input; } // Stub TUI components used by source files diff --git a/extensions/tests/monorepo-compat-regression.test.ts b/extensions/tests/monorepo-compat-regression.test.ts index ce344036..fd5ce1d6 100644 --- a/extensions/tests/monorepo-compat-regression.test.ts +++ b/extensions/tests/monorepo-compat-regression.test.ts @@ -54,10 +54,7 @@ import { computeResumePoint, reconstructAllocatedLanes, } from "../taskplane/resume.ts"; -import { - freshOrchBatchState, - BATCH_STATE_SCHEMA_VERSION, -} from "../taskplane/types.ts"; +import { freshOrchBatchState, BATCH_STATE_SCHEMA_VERSION } from "../taskplane/types.ts"; import type { AllocatedLane, AllocatedTask, @@ -110,10 +107,7 @@ function monoTask(taskId: string, opts?: Partial): ParsedTask { } /** Build a monorepo AllocatedLane (no repoId). */ -function monoLane( - laneNum: number, - tasks: AllocatedTask[], -): AllocatedLane { +function monoLane(laneNum: number, tasks: AllocatedTask[]): AllocatedLane { return { laneNumber: laneNum, laneId: `lane-${laneNum}`, @@ -161,7 +155,6 @@ ${deps} `; } - // ═══════════════════════════════════════════════════════════════════════ // 8.1 — Repo-mode persisted state defaults // ═══════════════════════════════════════════════════════════════════════ @@ -222,14 +215,13 @@ describe("8.1: Repo-mode state — mode=repo, no repo fields", () => { const validated = validatePersistedState(data); expect(validated.lanes[0].laneSessionId).toBe("orch-legacy-lane-1"); expect((validated.lanes[0] as Record).tmuxSessionName).toBeUndefined(); - expect(errors.some(line => line.includes("lanes[].tmuxSessionName"))).toBe(true); + expect(errors.some((line) => line.includes("lanes[].tmuxSessionName"))).toBe(true); } finally { console.error = originalConsoleError; } }); }); - // ═══════════════════════════════════════════════════════════════════════ // 8.2 — Repo-mode discovery: no routing // ═══════════════════════════════════════════════════════════════════════ @@ -268,7 +260,7 @@ describe("8.2: Repo-mode discovery — no routing applied", () => { // No routing errors (TASK_REPO_UNKNOWN, TASK_REPO_UNRESOLVED) const routingErrors = result.errors.filter( - e => e.code === "TASK_REPO_UNKNOWN" || e.code === "TASK_REPO_UNRESOLVED", + (e) => e.code === "TASK_REPO_UNKNOWN" || e.code === "TASK_REPO_UNRESOLVED", ); expect(routingErrors).toHaveLength(0); }); @@ -368,7 +360,6 @@ Repo: api }); }); - // ═══════════════════════════════════════════════════════════════════════ // 8.3 — Repo-mode naming: un-scoped IDs // ═══════════════════════════════════════════════════════════════════════ @@ -399,13 +390,13 @@ describe("8.3: Repo-mode naming — no repoId segments", () => { }); it("8.3.5: multiple repo-mode lane IDs are unique", () => { - const ids = [1, 2, 3].map(n => generateLaneId(n)); + const ids = [1, 2, 3].map((n) => generateLaneId(n)); expect(new Set(ids).size).toBe(3); expect(ids).toEqual(["lane-1", "lane-2", "lane-3"]); }); it("8.3.6: multiple repo-mode session names are unique", () => { - const names = [1, 2, 3].map(n => generateLaneSessionId("orch", n, "alice")); + const names = [1, 2, 3].map((n) => generateLaneSessionId("orch", n, "alice")); expect(new Set(names).size).toBe(3); for (const name of names) { expect(name).toMatch(/^orch-alice-lane-\d+$/); @@ -413,7 +404,6 @@ describe("8.3: Repo-mode naming — no repoId segments", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 8.4 — Repo-mode serialization round-trip // ═══════════════════════════════════════════════════════════════════════ @@ -530,7 +520,6 @@ describe("8.4: Repo-mode serialization — round-trip preserves mode=repo", () = }); }); - // ═══════════════════════════════════════════════════════════════════════ // 8.5 — Repo-mode resume: v1→v2 upconvert and eligibility // ═══════════════════════════════════════════════════════════════════════ @@ -585,25 +574,29 @@ describe("8.5: Repo-mode resume — v1→v2 upconvert and mode-agnostic eligibil currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-100"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-op-lane-1", - worktreePath: "/wt-1", - branch: "task/op-lane-1-20260316T120000", - taskIds: ["TP-100"], - }], - tasks: [{ - taskId: "TP-100", - laneNumber: 1, - sessionName: "orch-op-lane-1", - status: "running", - taskFolder: "/tasks/TP-100", - startedAt: 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-op-lane-1", + worktreePath: "/wt-1", + branch: "task/op-lane-1-20260316T120000", + taskIds: ["TP-100"], + }, + ], + tasks: [ + { + taskId: "TP-100", + laneNumber: 1, + sessionName: "orch-op-lane-1", + status: "running", + taskFolder: "/tasks/TP-100", + startedAt: 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -634,25 +627,29 @@ describe("8.5: Repo-mode resume — v1→v2 upconvert and mode-agnostic eligibil currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-100"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-op-lane-1", - worktreePath: "/wt-1", - branch: "task/op-lane-1-20260316T120000", - taskIds: ["TP-100"], - }], - tasks: [{ - taskId: "TP-100", - laneNumber: 1, - sessionName: "orch-op-lane-1", - status: "running", - taskFolder: "/tasks/TP-100", - startedAt: 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-op-lane-1", + worktreePath: "/wt-1", + branch: "task/op-lane-1-20260316T120000", + taskIds: ["TP-100"], + }, + ], + tasks: [ + { + taskId: "TP-100", + laneNumber: 1, + sessionName: "orch-op-lane-1", + status: "running", + taskFolder: "/tasks/TP-100", + startedAt: 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -690,25 +687,29 @@ describe("8.5: Repo-mode resume — v1→v2 upconvert and mode-agnostic eligibil currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-100"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-op-lane-1", - worktreePath: "/wt-1", - branch: "task/op-lane-1-20260316T120000", - taskIds: ["TP-100"], - }], - tasks: [{ - taskId: "TP-100", - laneNumber: 1, - sessionName: "orch-op-lane-1", - status: "running", - taskFolder: "/tasks/TP-100", - startedAt: 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-op-lane-1", + worktreePath: "/wt-1", + branch: "task/op-lane-1-20260316T120000", + taskIds: ["TP-100"], + }, + ], + tasks: [ + { + taskId: "TP-100", + laneNumber: 1, + sessionName: "orch-op-lane-1", + status: "running", + taskFolder: "/tasks/TP-100", + startedAt: 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -734,25 +735,29 @@ describe("8.5: Repo-mode resume — v1→v2 upconvert and mode-agnostic eligibil }); it("8.5.6: reconstructAllocatedLanes from repo-mode state has no repoId", () => { - const persistedLanes = [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-op-lane-1", - worktreePath: "/wt-1", - branch: "task/op-lane-1-20260316T120000", - taskIds: ["TP-100"], - }]; - const persistedTasks = [{ - taskId: "TP-100", - laneNumber: 1, - sessionName: "orch-op-lane-1", - status: "succeeded" as const, - taskFolder: "/tasks/TP-100", - startedAt: 1000, - endedAt: 2000, - doneFileFound: true, - exitReason: "done", - }]; + const persistedLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-op-lane-1", + worktreePath: "/wt-1", + branch: "task/op-lane-1-20260316T120000", + taskIds: ["TP-100"], + }, + ]; + const persistedTasks = [ + { + taskId: "TP-100", + laneNumber: 1, + sessionName: "orch-op-lane-1", + status: "succeeded" as const, + taskFolder: "/tasks/TP-100", + startedAt: 1000, + endedAt: 2000, + doneFileFound: true, + exitReason: "done", + }, + ]; const lanes = reconstructAllocatedLanes(persistedLanes, persistedTasks); @@ -763,7 +768,6 @@ describe("8.5: Repo-mode resume — v1→v2 upconvert and mode-agnostic eligibil }); }); - // ═══════════════════════════════════════════════════════════════════════ // 8.6 — Repo-mode merge: groupLanesByRepo returns single default group // ═══════════════════════════════════════════════════════════════════════ @@ -787,9 +791,7 @@ describe("8.6: Repo-mode merge — groupLanesByRepo returns single default group it("8.6.2: single lane without repoId grouped correctly", () => { const t1 = monoTask("TP-710"); - const lanes: AllocatedLane[] = [ - monoLane(1, [monoAllocatedTask("TP-710", 0, t1)]), - ]; + const lanes: AllocatedLane[] = [monoLane(1, [monoAllocatedTask("TP-710", 0, t1)])]; const groups = groupLanesByRepo(lanes); @@ -799,7 +801,6 @@ describe("8.6: Repo-mode merge — groupLanesByRepo returns single default group }); }); - // ═══════════════════════════════════════════════════════════════════════ // 8.7 — Repo-mode wave computation: groupTasksByRepo returns single group // ═══════════════════════════════════════════════════════════════════════ diff --git a/extensions/tests/naming-collision.test.ts b/extensions/tests/naming-collision.test.ts index 8bb5d14f..731c92af 100644 --- a/extensions/tests/naming-collision.test.ts +++ b/extensions/tests/naming-collision.test.ts @@ -23,7 +23,12 @@ import { resolve, basename } from "path"; // Direct imports from production modules import { sanitizeNameComponent, resolveOperatorId, resolveRepoSlug } from "../taskplane/naming.ts"; import { generateLaneSessionId, generateLaneId } from "../taskplane/waves.ts"; -import { generateBranchName, generateWorktreePath, generateMergeWorktreePath, generateBatchContainerPath } from "../taskplane/worktree.ts"; +import { + generateBranchName, + generateWorktreePath, + generateMergeWorktreePath, + generateBatchContainerPath, +} from "../taskplane/worktree.ts"; import { parseOrchSessionNames } from "../taskplane/persistence.ts"; import type { OrchestratorConfig } from "../taskplane/types.ts"; import { DEFAULT_ORCHESTRATOR_CONFIG } from "../taskplane/types.ts"; @@ -52,10 +57,20 @@ function mergeTempBranch(opId: string, batchId: string): string { function mergeSessionName(sessionPrefix: string, opId: string, laneNumber: number): string { return `${sessionPrefix}-${opId}-merge-${laneNumber}`; } -function mergeResultFileName(waveIndex: number, laneNumber: number, opId: string, batchId: string): string { +function mergeResultFileName( + waveIndex: number, + laneNumber: number, + opId: string, + batchId: string, +): string { return `merge-result-w${waveIndex}-lane${laneNumber}-${opId}-${batchId}.json`; } -function mergeRequestFileName(waveIndex: number, laneNumber: number, opId: string, batchId: string): string { +function mergeRequestFileName( + waveIndex: number, + laneNumber: number, + opId: string, + batchId: string, +): string { return `merge-request-w${waveIndex}-lane${laneNumber}-${opId}-${batchId}.txt`; } function mergeWorkspaceDir(opId: string): string { @@ -139,8 +154,22 @@ describe("2a — Collision Matrix", () => { it("same operator, different batchIds produce different container paths", () => { const repoRoot = "/home/user/project"; - const pathA = generateWorktreePath(wtPrefix, lane, repoRoot, "alice", undefined, "20260315T120000"); - const pathB = generateWorktreePath(wtPrefix, lane, repoRoot, "alice", undefined, "20260315T120001"); + const pathA = generateWorktreePath( + wtPrefix, + lane, + repoRoot, + "alice", + undefined, + "20260315T120000", + ); + const pathB = generateWorktreePath( + wtPrefix, + lane, + repoRoot, + "alice", + undefined, + "20260315T120001", + ); expect(pathA).not.toBe(pathB); expect(basename(resolve(pathA, ".."))).toBe("alice-20260315T120000"); expect(basename(resolve(pathB, ".."))).toBe("alice-20260315T120001"); @@ -415,7 +444,6 @@ describe("2a — Collision Matrix", () => { // ═══════════════════════════════════════════════════════════════════════ describe("2b — Shared-Environment Interference", () => { - describe("parseOrchSessionNames() prefix filtering behavior", () => { const tmuxOutput = [ "orch-alice-lane-1", @@ -474,12 +502,14 @@ describe("2b — Shared-Environment Interference", () => { * Simulate the regex matching from listWorktrees() for the primary pattern. */ function matchesPrimaryPattern(wtBasename: string, prefix: string, opId: string): boolean { - const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-${opId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-(\\d+)$`); + const pattern = new RegExp( + `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-${opId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d+)$`, + ); return pattern.test(wtBasename); } function matchesLegacyPattern(wtBasename: string, prefix: string): boolean { - const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-(\\d+)$`); + const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d+)$`); return pattern.test(wtBasename); } @@ -618,7 +648,7 @@ describe("2b — Shared-Environment Interference", () => { "orch-bob-merge-2", ]; - const matched = sessions.filter(name => name.startsWith(`${prefix}-`)); + const matched = sessions.filter((name) => name.startsWith(`${prefix}-`)); expect(matched.length).toBe(4); }); @@ -630,7 +660,7 @@ describe("2b — Shared-Environment Interference", () => { "orchestrator-lane-1", // does NOT start with "orch-" ]; - const matched = sessions.filter(name => name.startsWith(`${prefix}-`)); + const matched = sessions.filter((name) => name.startsWith(`${prefix}-`)); expect(matched.length).toBe(1); expect(matched[0]).toBe("orch-alice-lane-1"); }); @@ -642,7 +672,6 @@ describe("2b — Shared-Environment Interference", () => { // ═══════════════════════════════════════════════════════════════════════ describe("2c — Human-Readability Acceptance", () => { - describe("TMUX session names stay under 64 characters", () => { it("worst-case repo mode: long prefix + long opId", () => { const session = generateLaneSessionId("taskplane-orch", 99, "ci-runner-01xx"); @@ -680,21 +709,21 @@ describe("2c — Human-Readability Acceptance", () => { const session = generateLaneSessionId(prefix, 1, opId); expect(session).toBe("orch-henrylach-lane-1"); const tokens = session.split("-"); - expect(tokens[0]).toBe("orch"); // prefix + expect(tokens[0]).toBe("orch"); // prefix expect(tokens[1]).toBe("henrylach"); // opId - expect(tokens[2]).toBe("lane"); // role - expect(tokens[3]).toBe("1"); // lane number + expect(tokens[2]).toBe("lane"); // role + expect(tokens[3]).toBe("1"); // lane number }); it("TMUX workspace sessions: prefix → opId → repoId → lane-N", () => { const session = generateLaneSessionId(prefix, 2, opId, "api"); expect(session).toBe("orch-henrylach-api-lane-2"); const tokens = session.split("-"); - expect(tokens[0]).toBe("orch"); // prefix - expect(tokens[1]).toBe("henrylach"); // opId - expect(tokens[2]).toBe("api"); // repoId - expect(tokens[3]).toBe("lane"); // role - expect(tokens[4]).toBe("2"); // lane number + expect(tokens[0]).toBe("orch"); // prefix + expect(tokens[1]).toBe("henrylach"); // opId + expect(tokens[2]).toBe("api"); // repoId + expect(tokens[3]).toBe("lane"); // role + expect(tokens[4]).toBe("2"); // lane number }); it("Merge sessions: prefix → opId → merge → N", () => { @@ -714,13 +743,20 @@ describe("2c — Human-Readability Acceptance", () => { const tokens = wtBasename.split("-"); // "taskplane-wt" is the prefix (contains a hyphen) expect(tokens.slice(0, 2).join("-")).toBe("taskplane-wt"); // prefix - expect(tokens[2]).toBe("henrylach"); // opId - expect(tokens[3]).toBe("1"); // lane number + expect(tokens[2]).toBe("henrylach"); // opId + expect(tokens[3]).toBe("1"); // lane number }); it("Worktree paths (batch-scoped): container = opId-batchId, basename = lane-N", () => { const batchIdVal = "20260315T120000"; - const wtPath = generateWorktreePath(wtPrefix, 1, "/home/user/project", opId, undefined, batchIdVal); + const wtPath = generateWorktreePath( + wtPrefix, + 1, + "/home/user/project", + opId, + undefined, + batchIdVal, + ); const wtBasename = basename(resolve(wtPath)); expect(wtBasename).toBe("lane-1"); const containerName = basename(resolve(wtPath, "..")); @@ -742,9 +778,9 @@ describe("2c — Human-Readability Acceptance", () => { // After "task/" prefix const afterSlash = branch.split("/")[1]; const tokens = afterSlash.split("-"); - expect(tokens[0]).toBe("henrylach"); // opId - expect(tokens[1]).toBe("lane"); // role marker - expect(tokens[2]).toBe("1"); // lane number + expect(tokens[0]).toBe("henrylach"); // opId + expect(tokens[1]).toBe("lane"); // role marker + expect(tokens[2]).toBe("1"); // lane number expect(tokens[3]).toBe("20260315T120000"); // batchId }); }); @@ -845,13 +881,15 @@ describe("2c — Human-Readability Acceptance", () => { }); it("Branch name example", () => { - expect(generateBranchName(1, "20260308T214300", "henrylach")) - .toBe("task/henrylach-lane-1-20260308T214300"); + expect(generateBranchName(1, "20260308T214300", "henrylach")).toBe( + "task/henrylach-lane-1-20260308T214300", + ); }); it("Merge temp branch example", () => { - expect(mergeTempBranch("henrylach", "20260308T214300")) - .toBe("_merge-temp-henrylach-20260308T214300"); + expect(mergeTempBranch("henrylach", "20260308T214300")).toBe( + "_merge-temp-henrylach-20260308T214300", + ); }); it("Worktree path basename example (legacy)", () => { @@ -860,13 +898,24 @@ describe("2c — Human-Readability Acceptance", () => { }); it("Worktree path example (batch-scoped, TP-021)", () => { - const wtPath = generateWorktreePath("taskplane-wt", 1, "/home/user/project", "henrylach", undefined, "20260308T214300"); + const wtPath = generateWorktreePath( + "taskplane-wt", + 1, + "/home/user/project", + "henrylach", + undefined, + "20260308T214300", + ); expect(basename(resolve(wtPath))).toBe("lane-1"); expect(basename(resolve(wtPath, ".."))).toBe("henrylach-20260308T214300"); }); it("Merge worktree path example (TP-021)", () => { - const mergePath = generateMergeWorktreePath("/home/user/project", "henrylach", "20260308T214300"); + const mergePath = generateMergeWorktreePath( + "/home/user/project", + "henrylach", + "20260308T214300", + ); expect(basename(resolve(mergePath))).toBe("merge"); expect(basename(resolve(mergePath, ".."))).toBe("henrylach-20260308T214300"); }); diff --git a/extensions/tests/non-blocking-engine.test.ts b/extensions/tests/non-blocking-engine.test.ts index f2e69646..2437c356 100644 --- a/extensions/tests/non-blocking-engine.test.ts +++ b/extensions/tests/non-blocking-engine.test.ts @@ -25,9 +25,7 @@ import { join, dirname } from "path"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; -import { - emitEngineEvent, -} from "../taskplane/persistence.ts"; +import { emitEngineEvent } from "../taskplane/persistence.ts"; import { buildEngineEventBase, @@ -36,11 +34,7 @@ import { DEFAULT_TASK_RUNNER_CONFIG, } from "../taskplane/types.ts"; -import type { - EngineEvent, - EngineEventCallback, - EngineEventType, -} from "../taskplane/types.ts"; +import type { EngineEvent, EngineEventCallback, EngineEventType } from "../taskplane/types.ts"; import { startBatchAsync } from "../taskplane/extension.ts"; import { resumeOrchBatch } from "../taskplane/resume.ts"; @@ -58,8 +52,8 @@ function readEngineEvents(stateRoot: string): EngineEvent[] { const content = readFileSync(eventsPath, "utf-8"); return content .split("\n") - .filter(line => line.trim().length > 0) - .map(line => JSON.parse(line) as EngineEvent); + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as EngineEvent); } // ══════════════════════════════════════════════════════════════════════ @@ -176,9 +170,14 @@ describe("2.x — Engine event emission infrastructure", () => { it("2.2: buildEngineEventBase accepts all valid EngineEventType values", () => { const types: EngineEventType[] = [ - "wave_start", "task_complete", "task_failed", - "merge_start", "merge_success", "merge_failed", - "batch_complete", "batch_paused", + "wave_start", + "task_complete", + "task_failed", + "merge_start", + "merge_success", + "merge_failed", + "batch_complete", + "batch_paused", ]; for (const type of types) { const base = buildEngineEventBase(type, "batch-1", 0, "executing"); @@ -259,9 +258,13 @@ describe("2.x — Engine event emission infrastructure", () => { throw new Error("callback exploded"); }; expect(() => { - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("wave_start", "batch-1", 0, "executing"), - }, throwingCallback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("wave_start", "batch-1", 0, "executing"), + }, + throwingCallback, + ); }).not.toThrow(); // Event should still have been written to disk before callback const events = readEngineEvents(tmpDir); @@ -287,11 +290,24 @@ describe("3.x — JSONL persistence: events.jsonl lifecycle records", () => { it("3.1: full lifecycle sequence produces correct JSONL entries", () => { // Simulate a full batch lifecycle: wave_start → task_complete → merge_start → merge_success → batch_complete const events: EngineEvent[] = [ - { ...buildEngineEventBase("wave_start", "batch-1", 0, "executing"), taskIds: ["TP-001"], laneCount: 1 }, - { ...buildEngineEventBase("task_complete", "batch-1", 0, "executing"), taskId: "TP-001", durationMs: 30000 }, + { + ...buildEngineEventBase("wave_start", "batch-1", 0, "executing"), + taskIds: ["TP-001"], + laneCount: 1, + }, + { + ...buildEngineEventBase("task_complete", "batch-1", 0, "executing"), + taskId: "TP-001", + durationMs: 30000, + }, { ...buildEngineEventBase("merge_start", "batch-1", 0, "merging"), laneCount: 1 }, { ...buildEngineEventBase("merge_success", "batch-1", 0, "merging"), totalWaves: 1 }, - { ...buildEngineEventBase("batch_complete", "batch-1", 0, "completed"), succeededTasks: 1, failedTasks: 0, batchDurationMs: 35000 }, + { + ...buildEngineEventBase("batch_complete", "batch-1", 0, "completed"), + succeededTasks: 1, + failedTasks: 0, + batchDurationMs: 35000, + }, ]; for (const event of events) { @@ -300,8 +316,12 @@ describe("3.x — JSONL persistence: events.jsonl lifecycle records", () => { const written = readEngineEvents(tmpDir); expect(written).toHaveLength(5); - expect(written.map(e => e.type)).toEqual([ - "wave_start", "task_complete", "merge_start", "merge_success", "batch_complete", + expect(written.map((e) => e.type)).toEqual([ + "wave_start", + "task_complete", + "merge_start", + "merge_success", + "batch_complete", ]); // Verify terminal event has summary fields expect(written[4].succeededTasks).toBe(1); @@ -310,9 +330,21 @@ describe("3.x — JSONL persistence: events.jsonl lifecycle records", () => { it("3.2: failed lifecycle produces batch_paused terminal event", () => { const events: EngineEvent[] = [ - { ...buildEngineEventBase("wave_start", "batch-2", 0, "executing"), taskIds: ["TP-002"], laneCount: 1 }, - { ...buildEngineEventBase("task_failed", "batch-2", 0, "executing"), taskId: "TP-002", reason: "test failure" }, - { ...buildEngineEventBase("batch_paused", "batch-2", 0, "paused"), reason: "stop-wave policy: all tasks failed", failedTasks: 1 }, + { + ...buildEngineEventBase("wave_start", "batch-2", 0, "executing"), + taskIds: ["TP-002"], + laneCount: 1, + }, + { + ...buildEngineEventBase("task_failed", "batch-2", 0, "executing"), + taskId: "TP-002", + reason: "test failure", + }, + { + ...buildEngineEventBase("batch_paused", "batch-2", 0, "paused"), + reason: "stop-wave policy: all tasks failed", + failedTasks: 1, + }, ]; for (const event of events) { @@ -571,7 +603,8 @@ describe("6.x — /orch-resume early-return paths reset phase from 'launching' t // Find the StateFileError catch block const catchBlock = resumeSource.substring( resumeSource.indexOf("if (err instanceof StateFileError)"), - resumeSource.indexOf("throw err", resumeSource.indexOf("if (err instanceof StateFileError)")) + 20, + resumeSource.indexOf("throw err", resumeSource.indexOf("if (err instanceof StateFileError)")) + + 20, ); expect(catchBlock).toContain('batchState.phase = "idle"'); }); @@ -698,7 +731,9 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { it("8.1: startBatchAsync returns synchronously before engine work begins", async () => { let engineStarted = false; - const engineFn = async () => { engineStarted = true; }; + const engineFn = async () => { + engineStarted = true; + }; const batchState = freshOrchBatchState(); batchState.phase = "launching"; batchState.batchId = "test-batch"; @@ -713,14 +748,16 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { // Advance past the setTimeout(0) detach mock.timers.tick(1); // Let microtasks settle - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); // Now engine should have run expect(engineStarted).toBe(true); }); it("8.2: startBatchAsync calls updateWidget on successful engine completion", async () => { - const engineFn = async () => { /* success */ }; + const engineFn = async () => { + /* success */ + }; const batchState = freshOrchBatchState(); batchState.phase = "executing"; batchState.batchId = "test-batch"; @@ -734,14 +771,16 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { // Advance past setTimeout(0) and let microtask (.then) resolve mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); // Widget should have been updated after successful completion expect(updateWidget).toHaveBeenCalledTimes(1); }); it("8.3: startBatchAsync error boundary sets phase to 'failed' on engine rejection", async () => { - const engineFn = async () => { throw new Error("engine explosion"); }; + const engineFn = async () => { + throw new Error("engine explosion"); + }; const batchState = freshOrchBatchState(); batchState.phase = "executing"; batchState.batchId = "crash-batch"; @@ -752,7 +791,7 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { // Advance timer and let rejection propagate mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); // Error boundary should have set phase to "failed" expect(batchState.phase).toBe("failed"); @@ -768,7 +807,9 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { }); it("8.4: startBatchAsync error boundary does NOT overwrite already-completed phase", async () => { - const engineFn = async () => { throw new Error("late crash"); }; + const engineFn = async () => { + throw new Error("late crash"); + }; const batchState = freshOrchBatchState(); // Simulate engine having already set completed before the catch fires batchState.phase = "completed"; @@ -780,7 +821,7 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { startBatchAsync(engineFn, batchState, mockCtx, updateWidget); mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); // Phase should remain "completed" — error boundary checks for terminal phases expect(batchState.phase).toBe("completed"); @@ -789,7 +830,9 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { }); it("8.5: startBatchAsync error boundary does NOT overwrite already-failed phase", async () => { - const engineFn = async () => { throw new Error("double crash"); }; + const engineFn = async () => { + throw new Error("double crash"); + }; const batchState = freshOrchBatchState(); batchState.phase = "failed"; batchState.batchId = "already-failed"; @@ -801,7 +844,7 @@ describe("8.x — Behavioral: startBatchAsync non-blocking pattern", () => { startBatchAsync(engineFn, batchState, mockCtx, updateWidget); mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); // Phase should remain "failed" — no double-set expect(batchState.phase).toBe("failed"); @@ -836,7 +879,12 @@ describe("9.x — Behavioral: launch-window command compatibility", () => { expect(hasActiveBatch).toBe(true); // /orch-resume guard: "launching" should be recognized as actively running - const resumeBlockedPhases: Set = new Set(["launching", "executing", "merging", "planning"]); + const resumeBlockedPhases: Set = new Set([ + "launching", + "executing", + "merging", + "planning", + ]); expect(resumeBlockedPhases.has(batchState.phase)).toBe(true); }); @@ -912,7 +960,12 @@ describe("10.x — Behavioral: engine event emission sequences", () => { terminalEventEmitted = true; if (batchState.phase === "completed" || batchState.phase === "failed") { const event: EngineEvent = { - ...buildEngineEventBase("batch_complete", batchState.batchId, batchState.currentWaveIndex, batchState.phase), + ...buildEngineEventBase( + "batch_complete", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), succeededTasks: batchState.succeededTasks, failedTasks: batchState.failedTasks, skippedTasks: batchState.skippedTasks, @@ -955,8 +1008,15 @@ describe("10.x — Behavioral: engine event emission sequences", () => { terminalEventEmitted = true; if (batchState.phase === "paused" || batchState.phase === "stopped") { const event: EngineEvent = { - ...buildEngineEventBase("batch_paused", batchState.batchId, batchState.currentWaveIndex, batchState.phase), - reason: reason || (batchState.errors.length > 0 ? batchState.errors[batchState.errors.length - 1] : "paused"), + ...buildEngineEventBase( + "batch_paused", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), + reason: + reason || + (batchState.errors.length > 0 ? batchState.errors[batchState.errors.length - 1] : "paused"), failedTasks: batchState.failedTasks, }; emitEngineEvent(tmpDir, event, callback); @@ -988,11 +1048,20 @@ describe("10.x — Behavioral: engine event emission sequences", () => { if (terminalEventEmitted) return; terminalEventEmitted = true; if (batchState.phase === "completed" || batchState.phase === "failed") { - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("batch_complete", batchState.batchId, batchState.currentWaveIndex, batchState.phase), - succeededTasks: batchState.succeededTasks, - failedTasks: batchState.failedTasks, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase( + "batch_complete", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), + succeededTasks: batchState.succeededTasks, + failedTasks: batchState.failedTasks, + }, + callback, + ); } }; @@ -1022,12 +1091,21 @@ describe("10.x — Behavioral: engine event emission sequences", () => { if (terminalEventEmitted) return; terminalEventEmitted = true; if (batchState.phase === "completed" || batchState.phase === "failed") { - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("batch_complete", batchState.batchId, batchState.currentWaveIndex, batchState.phase), - succeededTasks: batchState.succeededTasks, - failedTasks: batchState.failedTasks, - batchDurationMs: batchState.endedAt ? batchState.endedAt - batchState.startedAt : undefined, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase( + "batch_complete", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), + succeededTasks: batchState.succeededTasks, + failedTasks: batchState.failedTasks, + batchDurationMs: batchState.endedAt ? batchState.endedAt - batchState.startedAt : undefined, + }, + callback, + ); } }; @@ -1053,11 +1131,20 @@ describe("10.x — Behavioral: engine event emission sequences", () => { if (terminalEventEmitted) return; terminalEventEmitted = true; if (batchState.phase === "paused" || batchState.phase === "stopped") { - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("batch_paused", batchState.batchId, batchState.currentWaveIndex, batchState.phase), - reason: reason || "stopped", - failedTasks: batchState.failedTasks, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase( + "batch_paused", + batchState.batchId, + batchState.currentWaveIndex, + batchState.phase, + ), + reason: reason || "stopped", + failedTasks: batchState.failedTasks, + }, + callback, + ); } }; @@ -1077,57 +1164,89 @@ describe("10.x — Behavioral: engine event emission sequences", () => { const batchId = "lifecycle-test"; // Wave 0 start - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("wave_start", batchId, 0, "executing"), - taskIds: ["TP-001", "TP-002"], - laneCount: 2, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("wave_start", batchId, 0, "executing"), + taskIds: ["TP-001", "TP-002"], + laneCount: 2, + }, + callback, + ); // Tasks complete - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("task_complete", batchId, 0, "executing"), - taskId: "TP-001", - durationMs: 15000, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("task_complete", batchId, 0, "executing"), + taskId: "TP-001", + durationMs: 15000, + }, + callback, + ); - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("task_failed", batchId, 0, "executing"), - taskId: "TP-002", - durationMs: 8000, - reason: "test failures", - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("task_failed", batchId, 0, "executing"), + taskId: "TP-002", + durationMs: 8000, + reason: "test failures", + }, + callback, + ); // Merge - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("merge_start", batchId, 0, "merging"), - laneCount: 1, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("merge_start", batchId, 0, "merging"), + laneCount: 1, + }, + callback, + ); - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("merge_success", batchId, 0, "merging"), - totalWaves: 1, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("merge_success", batchId, 0, "merging"), + totalWaves: 1, + }, + callback, + ); // Terminal - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("batch_complete", batchId, 0, "completed"), - succeededTasks: 1, - failedTasks: 1, - batchDurationMs: 25000, - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("batch_complete", batchId, 0, "completed"), + succeededTasks: 1, + failedTasks: 1, + batchDurationMs: 25000, + }, + callback, + ); // Verify order in both callback and disk expect(received).toHaveLength(6); - expect(received.map(e => e.type)).toEqual([ - "wave_start", "task_complete", "task_failed", - "merge_start", "merge_success", "batch_complete", + expect(received.map((e) => e.type)).toEqual([ + "wave_start", + "task_complete", + "task_failed", + "merge_start", + "merge_success", + "batch_complete", ]); const diskEvents = readEngineEvents(tmpDir); expect(diskEvents).toHaveLength(6); - expect(diskEvents.map(e => e.type)).toEqual([ - "wave_start", "task_complete", "task_failed", - "merge_start", "merge_success", "batch_complete", + expect(diskEvents.map((e) => e.type)).toEqual([ + "wave_start", + "task_complete", + "task_failed", + "merge_start", + "merge_success", + "batch_complete", ]); // Verify event-specific fields survived serialization roundtrip @@ -1149,13 +1268,21 @@ describe("10.x — Behavioral: engine event emission sequences", () => { if (terminalEventEmitted) return; terminalEventEmitted = true; if (batchState.phase === "completed" || batchState.phase === "failed") { - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("batch_complete", batchState.batchId, 0, batchState.phase), - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("batch_complete", batchState.batchId, 0, batchState.phase), + }, + callback, + ); } else if (batchState.phase === "paused" || batchState.phase === "stopped") { - emitEngineEvent(tmpDir, { - ...buildEngineEventBase("batch_paused", batchState.batchId, 0, batchState.phase), - }, callback); + emitEngineEvent( + tmpDir, + { + ...buildEngineEventBase("batch_paused", batchState.batchId, 0, batchState.phase), + }, + callback, + ); } }; @@ -1181,10 +1308,11 @@ describe("8.x — Behavioral: startBatchAsync returns immediately, defers engine it("8.1: startBatchAsync returns synchronously (handler is not blocked)", () => { let engineStarted = false; - const engineFn = () => new Promise((resolve) => { - engineStarted = true; - resolve(); - }); + const engineFn = () => + new Promise((resolve) => { + engineStarted = true; + resolve(); + }); const batchState = freshOrchBatchState(); batchState.phase = "launching"; const mockCtx = { ui: { notify: mock.fn(), setWidget: mock.fn() } } as any; @@ -1199,10 +1327,11 @@ describe("8.x — Behavioral: startBatchAsync returns immediately, defers engine it("8.2: engine runs after setTimeout fires (next tick)", async () => { let engineStarted = false; - const engineFn = () => new Promise((resolve) => { - engineStarted = true; - resolve(); - }); + const engineFn = () => + new Promise((resolve) => { + engineStarted = true; + resolve(); + }); const batchState = freshOrchBatchState(); batchState.phase = "launching"; const mockCtx = { ui: { notify: mock.fn(), setWidget: mock.fn() } } as any; @@ -1213,7 +1342,7 @@ describe("8.x — Behavioral: startBatchAsync returns immediately, defers engine // Fire the setTimeout mock.timers.tick(1); // Let microtasks (promise .then) settle - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); expect(engineStarted).toBe(true); // Widget should be updated after engine completes @@ -1232,7 +1361,7 @@ describe("8.x — Behavioral: startBatchAsync returns immediately, defers engine // Fire setTimeout and let promise rejection settle mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); expect(batchState.phase).toBe("failed"); expect(batchState.endedAt).not.toBeNull(); @@ -1254,7 +1383,7 @@ describe("8.x — Behavioral: startBatchAsync returns immediately, defers engine startBatchAsync(engineFn, batchState, mockCtx, updateWidget); mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); // Should still be "completed", not overwritten to "failed" expect(batchState.phase).toBe("completed"); @@ -1269,7 +1398,7 @@ describe("8.x — Behavioral: startBatchAsync returns immediately, defers engine startBatchAsync(engineFn, batchState, mockCtx, updateWidget); mock.timers.tick(1); - await new Promise(r => setImmediate(r)); + await new Promise((r) => setImmediate(r)); expect(updateWidget).toHaveBeenCalledTimes(1); }); @@ -1284,7 +1413,8 @@ describe("9.x — Behavioral: launch-window command logic with 'launching' phase const batchState = freshOrchBatchState(); batchState.phase = "launching"; - const isBlocked = batchState.phase !== "idle" && + const isBlocked = + batchState.phase !== "idle" && batchState.phase !== "completed" && batchState.phase !== "failed" && batchState.phase !== "stopped"; @@ -1306,7 +1436,8 @@ describe("9.x — Behavioral: launch-window command logic with 'launching' phase const batchState = freshOrchBatchState(); batchState.phase = "launching"; - const isInactive = batchState.phase === "idle" || + const isInactive = + batchState.phase === "idle" || batchState.phase === "completed" || batchState.phase === "failed" || batchState.phase === "stopped"; @@ -1318,7 +1449,8 @@ describe("9.x — Behavioral: launch-window command logic with 'launching' phase const batchState = freshOrchBatchState(); batchState.phase = "launching"; - const hasActiveBatch = batchState.phase !== "idle" && + const hasActiveBatch = + batchState.phase !== "idle" && batchState.phase !== "completed" && batchState.phase !== "failed" && batchState.phase !== "stopped"; @@ -1330,7 +1462,8 @@ describe("9.x — Behavioral: launch-window command logic with 'launching' phase const batchState = freshOrchBatchState(); batchState.phase = "launching"; - const isRunning = batchState.phase === "launching" || + const isRunning = + batchState.phase === "launching" || batchState.phase === "executing" || batchState.phase === "merging" || batchState.phase === "planning"; @@ -1341,10 +1474,8 @@ describe("9.x — Behavioral: launch-window command logic with 'launching' phase it("9.6: all active phases blocked by /orch-resume guard", () => { const activePhases = ["launching", "executing", "merging", "planning"] as const; for (const phase of activePhases) { - const isRunning = phase === "launching" || - phase === "executing" || - phase === "merging" || - phase === "planning"; + const isRunning = + phase === "launching" || phase === "executing" || phase === "merging" || phase === "planning"; expect(isRunning).toBe(true); } }); @@ -1352,10 +1483,8 @@ describe("9.x — Behavioral: launch-window command logic with 'launching' phase it("9.7: idle/completed/failed/stopped not blocked by /orch-resume guard", () => { const resumablePhases = ["idle", "completed", "failed", "stopped"] as const; for (const phase of resumablePhases) { - const isRunning = phase === "launching" || - phase === "executing" || - phase === "merging" || - phase === "planning"; + const isRunning = + phase === "launching" || phase === "executing" || phase === "merging" || phase === "planning"; expect(isRunning).toBe(false); } }); @@ -1401,7 +1530,7 @@ describe("11.x — Behavioral: resumeOrchBatch early-return resets phase to 'idl // Phase must reset to idle (not stuck at "launching") expect(batchState.phase).toBe("idle"); // Should have notified about no state - expect(notifications.some(n => n.level === "error")).toBe(true); + expect(notifications.some((n) => n.level === "error")).toBe(true); }); it("11.2: phase resets from 'launching' to 'idle' when state file is corrupt", async () => { @@ -1433,7 +1562,7 @@ describe("11.x — Behavioral: resumeOrchBatch early-return resets phase to 'idl // Phase must reset to idle expect(batchState.phase).toBe("idle"); - expect(notifications.some(n => n.level === "error")).toBe(true); + expect(notifications.some((n) => n.level === "error")).toBe(true); }); it("11.3: idle phase stays idle when no persisted state exists (no regression for non-launched state)", async () => { diff --git a/extensions/tests/orch-direct-implementation.integration.test.ts b/extensions/tests/orch-direct-implementation.integration.test.ts index ce679117..14827f4f 100644 --- a/extensions/tests/orch-direct-implementation.integration.test.ts +++ b/extensions/tests/orch-direct-implementation.integration.test.ts @@ -49,15 +49,13 @@ function runAllTests(): void { state.totalWaves = 2; state.totalTasks = 3; - const json = serializeBatchState( - state, - [["TS-100", "TS-101"], ["TS-102"]], - [], - [], - ); + const json = serializeBatchState(state, [["TS-100", "TS-101"], ["TS-102"]], [], []); const parsed = JSON.parse(json); assert(parsed.tasks.length === 3, "serializeBatchState writes all 3 planned tasks into registry"); - assert(parsed.tasks.every((t: any) => t.status === "pending"), "tasks default to pending without outcomes"); + assert( + parsed.tasks.every((t: any) => t.status === "pending"), + "tasks default to pending without outcomes", + ); } // 2) computeResumePoint should NOT re-queue mark-failed tasks as pending. @@ -67,25 +65,31 @@ function runAllTests(): void { }; const reconciledTasks: any[] = [ { taskId: "TS-200", action: "mark-failed", liveStatus: "failed", persistedStatus: "running" }, - { taskId: "TS-201", action: "mark-complete", liveStatus: "succeeded", persistedStatus: "running" }, + { + taskId: "TS-201", + action: "mark-complete", + liveStatus: "succeeded", + persistedStatus: "running", + }, ]; const resumePoint = computeResumePoint(persistedState, reconciledTasks); - assert(!resumePoint.pendingTaskIds.includes("TS-200"), "mark-failed task is not re-queued as pending"); + assert( + !resumePoint.pendingTaskIds.includes("TS-200"), + "mark-failed task is not re-queued as pending", + ); assert(resumePoint.failedTaskIds.includes("TS-200"), "mark-failed task remains in failed bucket"); } // 3) selectAbortTargetSessions honors exact prefix (including hyphenated prefixes). { - const sessions = [ - "orch-prod-lane-1", - "orch-prod-merge-1", - "orch-lane-1", - "orch-prod-metrics", - ]; + const sessions = ["orch-prod-lane-1", "orch-prod-merge-1", "orch-lane-1", "orch-prod-metrics"]; const targets = selectAbortTargetSessions(sessions, null, [], "C:/repo", "orch-prod"); - const names = targets.map(t => t.sessionName).sort(); + const names = targets.map((t) => t.sessionName).sort(); assert(names.length === 2, "hyphenated prefix filters to 2 abort targets"); - assert(names[0] === "orch-prod-lane-1" && names[1] === "orch-prod-merge-1", "only lane/merge sessions for exact prefix are selected"); + assert( + names[0] === "orch-prod-lane-1" && names[1] === "orch-prod-merge-1", + "only lane/merge sessions for exact prefix are selected", + ); } // 4) hasTaskDoneMarker checks archived path fallback. @@ -98,7 +102,10 @@ function runAllTests(): void { mkdirSync(archiveTaskFolder, { recursive: true }); writeFileSync(join(archiveTaskFolder, ".DONE"), "done\n", "utf-8"); - assert(hasTaskDoneMarker(taskFolder), "archived .DONE marker is detected from original task folder path"); + assert( + hasTaskDoneMarker(taskFolder), + "archived .DONE marker is detected from original task folder path", + ); } finally { rmSync(base, { recursive: true, force: true }); } @@ -112,12 +119,20 @@ function runAllTests(): void { try { // Init a test repo with an initial commit on main execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Generate expected orch branch name const orchConfig = { @@ -153,12 +168,20 @@ function runAllTests(): void { const repoDir = join(tempBase, "repo"); try { execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Create the branch first const orchBranch = "orch/testop-duplicate"; @@ -185,7 +208,10 @@ function runAllTests(): void { batchState.errors.push(`Failed to create orch branch '${orchBranch}': ${errDetail}`); } - assert(batchState.phase === "failed", "batch state phase set to 'failed' on branch creation failure"); + assert( + batchState.phase === "failed", + "batch state phase set to 'failed' on branch creation failure", + ); assert(batchState.endedAt !== null, "batch state endedAt set on failure"); assert(batchState.errors.length === 1, "exactly one error recorded"); assert(batchState.errors[0].includes(orchBranch), "error message contains branch name"); @@ -205,24 +231,40 @@ function runAllTests(): void { const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Find positions of key planning-phase markers and branch creation - const preflightReturnPos = engineSource.indexOf('batchState.errors.push("Preflight check failed")'); - const discoveryReturnPos = engineSource.indexOf('batchState.errors.push("Discovery had fatal errors'); + const preflightReturnPos = engineSource.indexOf( + 'batchState.errors.push("Preflight check failed")', + ); + const discoveryReturnPos = engineSource.indexOf( + 'batchState.errors.push("Discovery had fatal errors', + ); const noPendingReturnPos = engineSource.indexOf("No pending tasks found"); const graphReturnPos = engineSource.indexOf("Graph validation failed"); const waveReturnPos = engineSource.indexOf("Wave computation failed"); - const branchCreationPos = engineSource.indexOf('runGit(["branch", orchBranch, batchState.baseBranch]'); + const branchCreationPos = engineSource.indexOf( + 'runGit(["branch", orchBranch, batchState.baseBranch]', + ); assert(branchCreationPos > 0, "branch creation block found in engine.ts"); - assert(preflightReturnPos > 0 && branchCreationPos > preflightReturnPos, - "orch branch creation occurs after preflight early return"); - assert(discoveryReturnPos > 0 && branchCreationPos > discoveryReturnPos, - "orch branch creation occurs after discovery fatal error early return"); - assert(noPendingReturnPos > 0 && branchCreationPos > noPendingReturnPos, - "orch branch creation occurs after no-pending-tasks early return"); - assert(graphReturnPos > 0 && branchCreationPos > graphReturnPos, - "orch branch creation occurs after graph validation early return"); - assert(waveReturnPos > 0 && branchCreationPos > waveReturnPos, - "orch branch creation occurs after wave computation early return"); + assert( + preflightReturnPos > 0 && branchCreationPos > preflightReturnPos, + "orch branch creation occurs after preflight early return", + ); + assert( + discoveryReturnPos > 0 && branchCreationPos > discoveryReturnPos, + "orch branch creation occurs after discovery fatal error early return", + ); + assert( + noPendingReturnPos > 0 && branchCreationPos > noPendingReturnPos, + "orch branch creation occurs after no-pending-tasks early return", + ); + assert( + graphReturnPos > 0 && branchCreationPos > graphReturnPos, + "orch branch creation occurs after graph validation early return", + ); + assert( + waveReturnPos > 0 && branchCreationPos > waveReturnPos, + "orch branch creation occurs after wave computation early return", + ); } // ── 7b) Orch branch creation: detached HEAD is rejected before branch creation ── @@ -236,12 +278,20 @@ function runAllTests(): void { try { // Init a test repo and create a commit execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Detach HEAD by checking out a specific commit const headSha = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8" }).trim(); @@ -263,20 +313,30 @@ function runAllTests(): void { batchState.errors.push("Cannot determine current branch (detached HEAD or not a git repo)"); } - assert(batchState.phase === "failed", "batch fails on detached HEAD before orch branch creation"); + assert( + batchState.phase === "failed", + "batch fails on detached HEAD before orch branch creation", + ); assert(batchState.errors[0].includes("detached HEAD"), "error message mentions detached HEAD"); assert(batchState.orchBranch === "", "orchBranch remains empty — no orphan branch created"); // Verify no orch branches were accidentally created in the repo const branchList = execSync("git branch", { cwd: repoDir, encoding: "utf-8" }); - assert(!branchList.includes("orch/"), "no orch/ branches exist in repo after detached HEAD rejection"); + assert( + !branchList.includes("orch/"), + "no orch/ branches exist in repo after detached HEAD rejection", + ); // Structural verification: the detached HEAD check in engine.ts is before branch creation const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); const detachedCheckPos = engineSource.indexOf("detached HEAD or not a git repo"); - const branchCreationPos = engineSource.indexOf('runGit(["branch", orchBranch, batchState.baseBranch]'); - assert(detachedCheckPos > 0 && branchCreationPos > 0 && detachedCheckPos < branchCreationPos, - "detached HEAD check occurs before orch branch creation in engine.ts"); + const branchCreationPos = engineSource.indexOf( + 'runGit(["branch", orchBranch, batchState.baseBranch]', + ); + assert( + detachedCheckPos > 0 && branchCreationPos > 0 && detachedCheckPos < branchCreationPos, + "detached HEAD check occurs before orch branch creation in engine.ts", + ); } finally { rmSync(tempBase, { recursive: true, force: true }); } @@ -291,36 +351,53 @@ function runAllTests(): void { // executeWave call should pass orchBranch const executeWaveCallRegex = /executeWave\(\s*waveTasks[\s\S]*?batchState\.orchBranch/; - assert(executeWaveCallRegex.test(engineSource), - "executeWave() receives batchState.orchBranch (not baseBranch)"); + assert( + executeWaveCallRegex.test(engineSource), + "executeWave() receives batchState.orchBranch (not baseBranch)", + ); // Verify baseBranch is NOT passed to executeWave // Find the executeWave call block and check it doesn't use baseBranch - const executeWaveBlock = engineSource.match(/const waveResult = await executeWave\([\s\S]*?\);/)?.[0] ?? ""; - assert(!executeWaveBlock.includes("batchState.baseBranch"), - "executeWave() call block does not reference batchState.baseBranch"); + const executeWaveBlock = + engineSource.match(/const waveResult = await executeWave\([\s\S]*?\);/)?.[0] ?? ""; + assert( + !executeWaveBlock.includes("batchState.baseBranch"), + "executeWave() call block does not reference batchState.baseBranch", + ); // mergeWaveByRepo should pass orchBranch - const mergeCallRegex = /mergeWaveByRepo\(\s*waveResult\.allocatedLanes[\s\S]*?batchState\.orchBranch/; - assert(mergeCallRegex.test(engineSource), - "mergeWaveByRepo() receives batchState.orchBranch (not baseBranch)"); + const mergeCallRegex = + /mergeWaveByRepo\(\s*waveResult\.allocatedLanes[\s\S]*?batchState\.orchBranch/; + assert( + mergeCallRegex.test(engineSource), + "mergeWaveByRepo() receives batchState.orchBranch (not baseBranch)", + ); // Post-merge worktree reset uses orchBranch for primary repo. // TP-029: Now iterates encounteredRepoRoots with per-repo target branch // resolution. Primary repo uses batchState.orchBranch; secondary repos // use resolveBaseBranch. Verify orchBranch is used and baseBranch is not. - const resetBlock = engineSource.match(/Post-merge: Reset worktrees[\s\S]*?targetBranch = batchState\.\w+/)?.[0] ?? ""; - assert(resetBlock.includes("batchState.orchBranch"), - "post-merge worktree reset uses batchState.orchBranch"); - assert(!resetBlock.includes("batchState.baseBranch"), - "post-merge worktree reset does NOT use batchState.baseBranch"); + const resetBlock = + engineSource.match(/Post-merge: Reset worktrees[\s\S]*?targetBranch = batchState\.\w+/)?.[0] ?? + ""; + assert( + resetBlock.includes("batchState.orchBranch"), + "post-merge worktree reset uses batchState.orchBranch", + ); + assert( + !resetBlock.includes("batchState.baseBranch"), + "post-merge worktree reset does NOT use batchState.baseBranch", + ); // Phase 3 cleanup uses orchBranch for unmerged-branch protection // (lane branches were merged into orchBranch, not baseBranch — TP-022 Step 4) // TP-029: Now iterates encounteredRepoRoots with per-repo target branch. - const cleanupBlock = engineSource.match(/Phase 3: Cleanup[\s\S]*?targetBranch = batchState\.\w+/)?.[0] ?? ""; - assert(cleanupBlock.includes("batchState.orchBranch"), - "Phase 3 cleanup uses batchState.orchBranch for unmerged-branch check"); + const cleanupBlock = + engineSource.match(/Phase 3: Cleanup[\s\S]*?targetBranch = batchState\.\w+/)?.[0] ?? ""; + assert( + cleanupBlock.includes("batchState.orchBranch"), + "Phase 3 cleanup uses batchState.orchBranch for unmerged-branch check", + ); } // 6) resume.ts mirrors engine.ts orchBranch routing @@ -329,34 +406,51 @@ function runAllTests(): void { const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // executeWave in resume should use orchBranch - const resumeExecBlock = resumeSource.match(/const waveResult = await executeWave\([\s\S]*?\);/)?.[0] ?? ""; - assert(resumeExecBlock.includes("batchState.orchBranch"), - "resume.ts executeWave() receives batchState.orchBranch"); - assert(!resumeExecBlock.includes("batchState.baseBranch"), - "resume.ts executeWave() does NOT reference batchState.baseBranch"); + const resumeExecBlock = + resumeSource.match(/const waveResult = await executeWave\([\s\S]*?\);/)?.[0] ?? ""; + assert( + resumeExecBlock.includes("batchState.orchBranch"), + "resume.ts executeWave() receives batchState.orchBranch", + ); + assert( + !resumeExecBlock.includes("batchState.baseBranch"), + "resume.ts executeWave() does NOT reference batchState.baseBranch", + ); // Wave mergeWaveByRepo in resume should use orchBranch // There are multiple mergeWaveByRepo calls — find the one in the wave loop (not re-exec) - const waveMergeRegex = /mergeWaveByRepo\(\s*waveResult\.allocatedLanes[\s\S]*?batchState\.orchBranch/; - assert(waveMergeRegex.test(resumeSource), - "resume.ts wave mergeWaveByRepo() receives batchState.orchBranch"); + const waveMergeRegex = + /mergeWaveByRepo\(\s*waveResult\.allocatedLanes[\s\S]*?batchState\.orchBranch/; + assert( + waveMergeRegex.test(resumeSource), + "resume.ts wave mergeWaveByRepo() receives batchState.orchBranch", + ); // Re-exec merge also uses orchBranch - const reExecMergeRegex = /reExecAllocatedLanes[\s\S]*?mergeWaveByRepo\([\s\S]*?batchState\.orchBranch/; - assert(reExecMergeRegex.test(resumeSource), - "resume.ts re-exec mergeWaveByRepo() receives batchState.orchBranch"); + const reExecMergeRegex = + /reExecAllocatedLanes[\s\S]*?mergeWaveByRepo\([\s\S]*?batchState\.orchBranch/; + assert( + reExecMergeRegex.test(resumeSource), + "resume.ts re-exec mergeWaveByRepo() receives batchState.orchBranch", + ); // Post-merge worktree reset and terminal cleanup use per-repo target branch resolution: // Primary repo uses batchState.orchBranch, secondary repos resolve via resolveBaseBranch. // Both the inter-wave reset and terminal cleanup should have this per-repo pattern. - assert(resumeSource.includes("resolveRepoIdFromRoot"), - "resume.ts uses resolveRepoIdFromRoot for per-repo target branch in workspace mode"); - assert(resumeSource.includes("resolveBaseBranch(repoId, perRepoRoot"), - "resume.ts calls resolveBaseBranch per-repo for secondary repos"); + assert( + resumeSource.includes("resolveRepoIdFromRoot"), + "resume.ts uses resolveRepoIdFromRoot for per-repo target branch in workspace mode", + ); + assert( + resumeSource.includes("resolveBaseBranch(repoId, perRepoRoot"), + "resume.ts calls resolveBaseBranch per-repo for secondary repos", + ); // Primary repo path still uses orchBranch in both locations const orchBranchAssignments = resumeSource.match(/targetBranch = batchState\.orchBranch/g) || []; - assert(orchBranchAssignments.length >= 2, - "resume.ts uses orchBranch for primary repo in both inter-wave reset and terminal cleanup (TP-022 Step 4)"); + assert( + orchBranchAssignments.length >= 2, + "resume.ts uses orchBranch for primary repo in both inter-wave reset and terminal cleanup (TP-022 Step 4)", + ); } // 7) resume.ts has orchBranch empty-guard for pre-TP-022 persisted states @@ -365,21 +459,29 @@ function runAllTests(): void { const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Guard checks persistedState (not batchState) — R006: guard before mutation - assert(resumeSource.includes("!persistedState.orchBranch"), - "resume.ts checks persistedState.orchBranch (not batchState) for guard"); - assert(resumeSource.includes("has no orch branch"), - "resume.ts has clear error message for missing orchBranch"); + assert( + resumeSource.includes("!persistedState.orchBranch"), + "resume.ts checks persistedState.orchBranch (not batchState) for guard", + ); + assert( + resumeSource.includes("has no orch branch"), + "resume.ts has clear error message for missing orchBranch", + ); // The guard should appear BEFORE batchState.phase = "executing" mutation const guardPos = resumeSource.indexOf("!persistedState.orchBranch"); const phaseMutationPos = resumeSource.indexOf('batchState.phase = "executing"'); - assert(guardPos > 0 && phaseMutationPos > 0 && guardPos < phaseMutationPos, - "orchBranch guard appears BEFORE batchState.phase mutation (R006 fix)"); + assert( + guardPos > 0 && phaseMutationPos > 0 && guardPos < phaseMutationPos, + "orchBranch guard appears BEFORE batchState.phase mutation (R006 fix)", + ); // The guard should appear BEFORE any orchBranch routing usage const firstRoutingUse = resumeSource.indexOf("batchState.orchBranch,"); - assert(guardPos > 0 && firstRoutingUse > 0 && guardPos < firstRoutingUse, - "orchBranch guard appears before first orchBranch routing usage"); + assert( + guardPos > 0 && firstRoutingUse > 0 && guardPos < firstRoutingUse, + "orchBranch guard appears before first orchBranch routing usage", + ); } // 8) resolveBaseBranch in waves.ts: repo mode returns passed-in branch, workspace mode detects per-repo @@ -388,22 +490,32 @@ function runAllTests(): void { const wavesSource = readFileSync(join(__dirname, "..", "taskplane", "waves.ts"), "utf-8"); // resolveBaseBranch exists - assert(wavesSource.includes("export function resolveBaseBranch"), - "resolveBaseBranch() exists in waves.ts"); + assert( + wavesSource.includes("export function resolveBaseBranch"), + "resolveBaseBranch() exists in waves.ts", + ); // In repo mode (no repoId), it falls through to return batchBaseBranch - assert(wavesSource.includes("return batchBaseBranch"), - "resolveBaseBranch falls back to batchBaseBranch (which is now orchBranch)"); + assert( + wavesSource.includes("return batchBaseBranch"), + "resolveBaseBranch falls back to batchBaseBranch (which is now orchBranch)", + ); // In workspace mode (repoId present), it detects per-repo branch - assert(wavesSource.includes("getCurrentBranch(repoRoot)"), - "resolveBaseBranch detects per-repo branch in workspace mode"); + assert( + wavesSource.includes("getCurrentBranch(repoRoot)"), + "resolveBaseBranch detects per-repo branch in workspace mode", + ); // R006: workspace mode fails fast when fallback is an orch branch - assert(wavesSource.includes('batchBaseBranch.startsWith("orch/")'), - "resolveBaseBranch guards against orch branch fallback in workspace mode"); - assert(wavesSource.includes("does not exist in this repo"), - "resolveBaseBranch has clear error for orch branch fallback"); + assert( + wavesSource.includes('batchBaseBranch.startsWith("orch/")'), + "resolveBaseBranch guards against orch branch fallback in workspace mode", + ); + assert( + wavesSource.includes("does not exist in this repo"), + "resolveBaseBranch has clear error for orch branch fallback", + ); } // 9) R006: orchBranch guard leaves runtime state resumable/consistent after rejection @@ -419,10 +531,14 @@ function runAllTests(): void { assert(section6Start > 0, "Section 6 marker exists in resume.ts"); const guardPos = resumeSource.indexOf("!persistedState.orchBranch"); const textBeforeGuard = resumeSource.substring(section6Start, guardPos); - assert(!textBeforeGuard.includes("batchState.phase"), - "batchState.phase is NOT mutated before orchBranch guard"); - assert(!textBeforeGuard.includes("batchState.batchId"), - "batchState.batchId is NOT mutated before orchBranch guard"); + assert( + !textBeforeGuard.includes("batchState.phase"), + "batchState.phase is NOT mutated before orchBranch guard", + ); + assert( + !textBeforeGuard.includes("batchState.batchId"), + "batchState.batchId is NOT mutated before orchBranch guard", + ); // b) Behavioral simulation: exercise the guard logic with real state objects // A fresh batchState starts as idle — this is the runtime state the extension @@ -451,12 +567,12 @@ function runAllTests(): void { } // After guard rejection, batchState must still be idle - assert(batchState.phase === "idle", - "batchState.phase remains 'idle' after guard rejection (not 'executing')"); - assert(batchState.batchId === "", - "batchState.batchId remains empty after guard rejection"); - assert(batchState.orchBranch === "", - "batchState.orchBranch remains empty after guard rejection"); + assert( + batchState.phase === "idle", + "batchState.phase remains 'idle' after guard rejection (not 'executing')", + ); + assert(batchState.batchId === "", "batchState.batchId remains empty after guard rejection"); + assert(batchState.orchBranch === "", "batchState.orchBranch remains empty after guard rejection"); // This means /orch-resume won't see a phantom "executing" phase that blocks retries, // and /orch-abort can proceed without thinking a batch is running. @@ -468,8 +584,10 @@ function runAllTests(): void { // In repo mode (no repoId), orch branch fallback is allowed (branch exists in same repo) const repoModeResult = resolveBaseBranch(undefined, "/fake/repo", "orch/op-batch123"); - assert(repoModeResult === "orch/op-batch123", - "repo mode returns orch branch as-is (it exists in the primary repo)"); + assert( + repoModeResult === "orch/op-batch123", + "repo mode returns orch branch as-is (it exists in the primary repo)", + ); // In workspace mode (repoId present) with detached HEAD and no defaultBranch, // orch branch fallback should throw @@ -481,20 +599,28 @@ function runAllTests(): void { } as any); } catch (e: any) { threwForOrchFallback = true; - assert(e.message.includes("does not exist in this repo"), - "error message mentions orch branch doesn't exist in this repo"); - assert(e.message.includes("defaultBranch"), - "error message mentions defaultBranch configuration"); + assert( + e.message.includes("does not exist in this repo"), + "error message mentions orch branch doesn't exist in this repo", + ); + assert( + e.message.includes("defaultBranch"), + "error message mentions defaultBranch configuration", + ); } - assert(threwForOrchFallback, - "resolveBaseBranch throws when workspace fallback would be an orch branch"); + assert( + threwForOrchFallback, + "resolveBaseBranch throws when workspace fallback would be an orch branch", + ); // In workspace mode with a non-orch fallback, it should still work (legacy behavior) const legacyResult = resolveBaseBranch("secondary-repo", "/nonexistent/repo/path", "main", { repos: new Map(), } as any); - assert(legacyResult === "main", - "workspace mode with non-orch fallback returns batchBaseBranch as before"); + assert( + legacyResult === "main", + "workspace mode with non-orch fallback returns batchBaseBranch as before", + ); } // ── TP-022 Step 3: update-ref replaces ff-only in merge.ts ─────── @@ -505,28 +631,41 @@ function runAllTests(): void { const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Positive: rev-parse and update-ref are present in the ref advancement block - assert(mergeSource.includes('["rev-parse", tempBranch]'), - "merge.ts calls rev-parse on temp branch to get merged HEAD"); - assert(mergeSource.includes('"update-ref"'), - "merge.ts calls update-ref to advance non-checked-out target branch"); - assert(mergeSource.includes('`refs/heads/${targetBranch}`'), - "merge.ts update-ref targets refs/heads/"); + assert( + mergeSource.includes('["rev-parse", tempBranch]'), + "merge.ts calls rev-parse on temp branch to get merged HEAD", + ); + assert( + mergeSource.includes('"update-ref"'), + "merge.ts calls update-ref to advance non-checked-out target branch", + ); + assert( + mergeSource.includes("`refs/heads/${targetBranch}`"), + "merge.ts update-ref targets refs/heads/", + ); // Gate detection: getCurrentBranch is used to determine checked-out state - assert(mergeSource.includes("getCurrentBranch(repoRoot)"), - "merge.ts detects checked-out branch via getCurrentBranch(repoRoot)"); - assert(mergeSource.includes("targetIsCheckedOut"), - "merge.ts gates on targetIsCheckedOut flag"); + assert( + mergeSource.includes("getCurrentBranch(repoRoot)"), + "merge.ts detects checked-out branch via getCurrentBranch(repoRoot)", + ); + assert(mergeSource.includes("targetIsCheckedOut"), "merge.ts gates on targetIsCheckedOut flag"); // Checked-out path: ff-only with stash fallback (workspace mode safety) - assert(mergeSource.includes("--ff-only"), - "merge.ts uses --ff-only for checked-out target branch (workspace mode)"); - assert(mergeSource.includes('"stash"'), - "merge.ts uses stash fallback for dirty worktree in checked-out path"); + assert( + mergeSource.includes("--ff-only"), + "merge.ts uses --ff-only for checked-out target branch (workspace mode)", + ); + assert( + mergeSource.includes('"stash"'), + "merge.ts uses stash fallback for dirty worktree in checked-out path", + ); // Compare-and-swap: update-ref uses old-ref guard for non-checked-out path - assert(mergeSource.includes('`refs/heads/${targetBranch}`, tempBranchHead, oldRef'), - "merge.ts uses compare-and-swap update-ref (3-arg form with old ref)"); + assert( + mergeSource.includes("`refs/heads/${targetBranch}`, tempBranchHead, oldRef"), + "merge.ts uses compare-and-swap update-ref (3-arg form with old ref)", + ); } // 12) merge.ts update-ref failure path sets failedLane/failureReason correctly @@ -535,32 +674,32 @@ function runAllTests(): void { const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Find the update-ref failure block - const updateRefBlock = mergeSource.match( - /if \(updateRefResult\.status !== 0\)[\s\S]*?failureReason\s*=\s*`[^`]+`/ - )?.[0] ?? ""; - assert(updateRefBlock.length > 0, - "update-ref failure block exists in merge.ts"); - assert(updateRefBlock.includes("failedLane"), - "update-ref failure sets failedLane"); - assert(updateRefBlock.includes("failureReason"), - "update-ref failure sets failureReason"); + const updateRefBlock = + mergeSource.match( + /if \(updateRefResult\.status !== 0\)[\s\S]*?failureReason\s*=\s*`[^`]+`/, + )?.[0] ?? ""; + assert(updateRefBlock.length > 0, "update-ref failure block exists in merge.ts"); + assert(updateRefBlock.includes("failedLane"), "update-ref failure sets failedLane"); + assert(updateRefBlock.includes("failureReason"), "update-ref failure sets failureReason"); // Find the rev-parse failure block - const revParseBlock = mergeSource.match( - /if \(revParseResult\.status !== 0\)[\s\S]*?failureReason\s*=\s*`[^`]+`/ - )?.[0] ?? ""; - assert(revParseBlock.length > 0, - "rev-parse failure block exists in merge.ts"); - assert(revParseBlock.includes("failedLane"), - "rev-parse failure sets failedLane"); - assert(revParseBlock.includes("failureReason"), - "rev-parse failure sets failureReason"); + const revParseBlock = + mergeSource.match( + /if \(revParseResult\.status !== 0\)[\s\S]*?failureReason\s*=\s*`[^`]+`/, + )?.[0] ?? ""; + assert(revParseBlock.length > 0, "rev-parse failure block exists in merge.ts"); + assert(revParseBlock.includes("failedLane"), "rev-parse failure sets failedLane"); + assert(revParseBlock.includes("failureReason"), "rev-parse failure sets failureReason"); // Both failures use failedLane ?? -1 (doesn't overwrite a lane-level failure) - assert(updateRefBlock.includes("failedLane ?? -1"), - "update-ref failure uses failedLane ?? -1 (preserves prior lane failure)"); - assert(revParseBlock.includes("failedLane ?? -1"), - "rev-parse failure uses failedLane ?? -1 (preserves prior lane failure)"); + assert( + updateRefBlock.includes("failedLane ?? -1"), + "update-ref failure uses failedLane ?? -1 (preserves prior lane failure)", + ); + assert( + revParseBlock.includes("failedLane ?? -1"), + "rev-parse failure uses failedLane ?? -1 (preserves prior lane failure)", + ); } // 13) merge.ts update-ref success path logs correctly @@ -569,24 +708,19 @@ function runAllTests(): void { const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Success path logs with exec logging - const successLog = mergeSource.match( - /`updated \$\{targetBranch\} ref to merge result`/ - )?.[0] ?? ""; - assert(successLog.length > 0, - "update-ref success logs 'updated ref to merge result'"); + const successLog = + mergeSource.match(/`updated \$\{targetBranch\} ref to merge result`/)?.[0] ?? ""; + assert( + successLog.length > 0, + "update-ref success logs 'updated ref to merge result'", + ); // Failure path logs with exec logging - const failureLog = mergeSource.match( - /`update-ref failed for \$\{targetBranch\}/ - )?.[0] ?? ""; - assert(failureLog.length > 0, - "update-ref failure logs 'update-ref failed for '"); - - const revParseFailLog = mergeSource.match( - /`failed to resolve temp branch HEAD/ - )?.[0] ?? ""; - assert(revParseFailLog.length > 0, - "rev-parse failure logs 'failed to resolve temp branch HEAD'"); + const failureLog = mergeSource.match(/`update-ref failed for \$\{targetBranch\}/)?.[0] ?? ""; + assert(failureLog.length > 0, "update-ref failure logs 'update-ref failed for '"); + + const revParseFailLog = mergeSource.match(/`failed to resolve temp branch HEAD/)?.[0] ?? ""; + assert(revParseFailLog.length > 0, "rev-parse failure logs 'failed to resolve temp branch HEAD'"); } // 14) merge.ts workspace-mode safety: checked-out branch uses ff-only, not update-ref @@ -596,43 +730,50 @@ function runAllTests(): void { // The advancement block must have both paths gated by targetIsCheckedOut. // Extract the block between "Gate advancement strategy" and "Clean up merge worktree" - const advancementBlock = mergeSource.match( - /Gate advancement strategy[\s\S]*?Clean up merge worktree/ - )?.[0] ?? ""; - assert(advancementBlock.length > 0, - "advancement block with gate comment exists"); + const advancementBlock = + mergeSource.match(/Gate advancement strategy[\s\S]*?Clean up merge worktree/)?.[0] ?? ""; + assert(advancementBlock.length > 0, "advancement block with gate comment exists"); // The gate uses getCurrentBranch to detect checked-out state - assert(advancementBlock.includes("getCurrentBranch(repoRoot)"), - "gate calls getCurrentBranch(repoRoot) to detect checked-out branch"); - assert(advancementBlock.includes("checkedOutBranch === targetBranch"), - "gate compares checkedOutBranch to targetBranch"); + assert( + advancementBlock.includes("getCurrentBranch(repoRoot)"), + "gate calls getCurrentBranch(repoRoot) to detect checked-out branch", + ); + assert( + advancementBlock.includes("checkedOutBranch === targetBranch"), + "gate compares checkedOutBranch to targetBranch", + ); // Checked-out path comes first (if targetIsCheckedOut) const checkedOutIdx = advancementBlock.indexOf("if (targetIsCheckedOut)"); const elseIdx = advancementBlock.indexOf("} else {", checkedOutIdx); - assert(checkedOutIdx > 0 && elseIdx > checkedOutIdx, - "gate has if (targetIsCheckedOut) ... else ... structure"); + assert( + checkedOutIdx > 0 && elseIdx > checkedOutIdx, + "gate has if (targetIsCheckedOut) ... else ... structure", + ); // Checked-out path uses ff-only (between if and else) const checkedOutPath = advancementBlock.slice(checkedOutIdx, elseIdx); - assert(checkedOutPath.includes("--ff-only"), - "checked-out path uses --ff-only merge"); - assert(checkedOutPath.includes("stash"), - "checked-out path has stash fallback for dirty worktree"); - assert(!checkedOutPath.includes("update-ref"), - "checked-out path does NOT use update-ref (would desync worktree)"); + assert(checkedOutPath.includes("--ff-only"), "checked-out path uses --ff-only merge"); + assert( + checkedOutPath.includes("stash"), + "checked-out path has stash fallback for dirty worktree", + ); + assert( + !checkedOutPath.includes("update-ref"), + "checked-out path does NOT use update-ref (would desync worktree)", + ); // Non-checked-out path uses update-ref (after else) const nonCheckedOutPath = advancementBlock.slice(elseIdx); - assert(nonCheckedOutPath.includes("update-ref"), - "non-checked-out path uses update-ref"); - assert(!nonCheckedOutPath.includes("--ff-only"), - "non-checked-out path does NOT use --ff-only"); + assert(nonCheckedOutPath.includes("update-ref"), "non-checked-out path uses update-ref"); + assert(!nonCheckedOutPath.includes("--ff-only"), "non-checked-out path does NOT use --ff-only"); // Workspace mode comment explains the rationale - assert(advancementBlock.includes("workspace mode"), - "advancement block documents workspace mode behavior"); + assert( + advancementBlock.includes("workspace mode"), + "advancement block documents workspace mode behavior", + ); } // ── TP-022 Step 3 — Behavioral tests: real git repo ref advancement ── @@ -645,60 +786,115 @@ function runAllTests(): void { try { // Set up repo with initial commit on main execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Create orch branch (simulating engine.ts batch start) const orchBranch = "orch/testop-batch1"; execSync(`git branch ${orchBranch} main`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const orchOldSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const orchOldSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Create temp merge branch from orch branch and add a commit const tempBranch = "_merge-temp-testop-batch1"; - execSync(`git branch ${tempBranch} ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git branch ${tempBranch} ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Use a worktree to add a commit on temp branch (can't checkout in main working tree) const wtDir = join(tempBase, "merge-wt"); - execSync(`git worktree add "${wtDir}" ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir}" ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir, "merged.txt"), "merged content\n"); execSync("git add -A", { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - execSync('git commit -m "merge: wave 1 lane 1"', { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - const tempBranchHead = execSync(`git rev-parse ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + execSync('git commit -m "merge: wave 1 lane 1"', { + cwd: wtDir, + encoding: "utf-8", + stdio: "pipe", + }); + const tempBranchHead = execSync(`git rev-parse ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Clean up worktree - execSync(`git worktree remove "${wtDir}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree remove "${wtDir}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Verify orch branch hasn't moved yet - assert(orchOldSha !== tempBranchHead, - "temp branch HEAD differs from orch branch (commit was added)"); - const orchPreUpdateSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(orchPreUpdateSha === orchOldSha, - "orch branch is still at original commit before update-ref"); + assert( + orchOldSha !== tempBranchHead, + "temp branch HEAD differs from orch branch (commit was added)", + ); + const orchPreUpdateSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert( + orchPreUpdateSha === orchOldSha, + "orch branch is still at original commit before update-ref", + ); // Execute update-ref with compare-and-swap (mirrors merge.ts logic) - const updateResult = spawnSync("git", + const updateResult = spawnSync( + "git", ["update-ref", `refs/heads/${orchBranch}`, tempBranchHead, orchOldSha], - { cwd: repoDir } + { cwd: repoDir }, ); - assert(updateResult.status === 0, - "update-ref succeeds with correct old OID"); + assert(updateResult.status === 0, "update-ref succeeds with correct old OID"); // Verify orch branch now points to the merged commit - const orchNewSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(orchNewSha === tempBranchHead, - "orch branch now points to temp branch HEAD after update-ref"); + const orchNewSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert( + orchNewSha === tempBranchHead, + "orch branch now points to temp branch HEAD after update-ref", + ); // Verify main (user's branch) was NOT touched - const mainSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(mainSha === orchOldSha, - "main branch is still at original commit (user's branch untouched)"); + const mainSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert( + mainSha === orchOldSha, + "main branch is still at original commit (user's branch untouched)", + ); // Verify working tree is clean (update-ref doesn't touch it) - const statusOutput = execSync("git status --porcelain", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(statusOutput === "", - "working tree is clean after update-ref (no dirty files)"); + const statusOutput = execSync("git status --porcelain", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert(statusOutput === "", "working tree is clean after update-ref (no dirty files)"); // Clean up temp branch execSync(`git branch -D ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -715,59 +911,99 @@ function runAllTests(): void { try { // Set up repo with initial commit execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Create orch branch const orchBranch = "orch/testop-cas"; execSync(`git branch ${orchBranch} main`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const orchOriginalSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const orchOriginalSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Simulate concurrent movement: advance orch branch independently const wtDir = join(tempBase, "concurrent-wt"); - execSync(`git worktree add "${wtDir}" ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir}" ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir, "concurrent.txt"), "concurrent change\n"); execSync("git add -A", { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "concurrent commit"', { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - const concurrentSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - execSync(`git worktree remove "${wtDir}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - - assert(concurrentSha !== orchOriginalSha, - "orch branch moved due to concurrent commit"); + const concurrentSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + execSync(`git worktree remove "${wtDir}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); + + assert(concurrentSha !== orchOriginalSha, "orch branch moved due to concurrent commit"); // Create a temp merge branch with a different commit const tempBranch = "_merge-temp-testop-cas"; execSync(`git branch ${tempBranch} main`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); const wtDir2 = join(tempBase, "merge-wt2"); - execSync(`git worktree add "${wtDir2}" ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir2}" ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir2, "merged.txt"), "merge content\n"); execSync("git add -A", { cwd: wtDir2, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "merge commit"', { cwd: wtDir2, encoding: "utf-8", stdio: "pipe" }); - const mergeHead = execSync(`git rev-parse ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - execSync(`git worktree remove "${wtDir2}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + const mergeHead = execSync(`git rev-parse ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + execSync(`git worktree remove "${wtDir2}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Attempt update-ref with stale old OID (orchOriginalSha, but branch moved to concurrentSha) - const updateResult = spawnSync("git", + const updateResult = spawnSync( + "git", ["update-ref", `refs/heads/${orchBranch}`, mergeHead, orchOriginalSha], - { cwd: repoDir } + { cwd: repoDir }, ); - assert(updateResult.status !== 0, - "update-ref REJECTS stale old OID (compare-and-swap failure)"); + assert(updateResult.status !== 0, "update-ref REJECTS stale old OID (compare-and-swap failure)"); // Verify orch branch was NOT clobbered — still at concurrent commit - const orchAfterSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(orchAfterSha === concurrentSha, - "orch branch preserved at concurrent commit (not clobbered)"); + const orchAfterSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert( + orchAfterSha === concurrentSha, + "orch branch preserved at concurrent commit (not clobbered)", + ); // Verify the error message contains relevant info const errMsg = updateResult.stderr?.toString() || ""; - assert(errMsg.length > 0, - "update-ref failure produces stderr error message"); + assert(errMsg.length > 0, "update-ref failure produces stderr error message"); // Clean up execSync(`git branch -D ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -784,45 +1020,84 @@ function runAllTests(): void { try { // Set up repo with initial commit on main execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } - const mainOldSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const mainOldSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Create temp branch from main with an additional commit const tempBranch = "_merge-temp-workspace"; execSync(`git branch ${tempBranch} main`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); const wtDir = join(tempBase, "merge-wt"); - execSync(`git worktree add "${wtDir}" ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir}" ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir, "new-file.txt"), "workspace merge\n"); execSync("git add -A", { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - execSync('git commit -m "workspace merge commit"', { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - const tempHead = execSync(`git rev-parse ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - execSync(`git worktree remove "${wtDir}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - - assert(tempHead !== mainOldSha, - "temp branch advanced beyond main"); + execSync('git commit -m "workspace merge commit"', { + cwd: wtDir, + encoding: "utf-8", + stdio: "pipe", + }); + const tempHead = execSync(`git rev-parse ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + execSync(`git worktree remove "${wtDir}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); + + assert(tempHead !== mainOldSha, "temp branch advanced beyond main"); // We're on main (checked out). Simulate the workspace ff-only path. - const ffResult = execSync(`git merge --ff-only ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + const ffResult = execSync(`git merge --ff-only ${tempBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Verify main advanced - const mainNewSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(mainNewSha === tempHead, - "main branch advanced to temp branch HEAD via ff-only"); + const mainNewSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert(mainNewSha === tempHead, "main branch advanced to temp branch HEAD via ff-only"); // Verify working tree has the new file (ff-only updates worktree) - assert(existsSync(join(repoDir, "new-file.txt")), - "new-file.txt exists in working tree after ff-only (worktree updated)"); + assert( + existsSync(join(repoDir, "new-file.txt")), + "new-file.txt exists in working tree after ff-only (worktree updated)", + ); // Verify working tree is clean - const statusOutput = execSync("git status --porcelain", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - assert(statusOutput === "", - "working tree is clean after ff-only merge"); + const statusOutput = execSync("git status --porcelain", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + assert(statusOutput === "", "working tree is clean after ff-only merge"); // Clean up temp branch execSync(`git branch -D ${tempBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -842,14 +1117,26 @@ function runAllTests(): void { try { // Set up repo with initial commit on main execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } - const mainOriginalSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const mainOriginalSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Create orch branch and advance it (simulating merged wave work) const orchBranch = "orch/testop-autointegrate"; @@ -857,12 +1144,28 @@ function runAllTests(): void { // Add a commit to orch branch via worktree const wtDir = join(tempBase, "orch-wt"); - execSync(`git worktree add "${wtDir}" ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir}" ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir, "task-work.txt"), "task work\n"); execSync("git add -A", { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - execSync('git commit -m "task: completed work"', { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - const orchHead = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - execSync(`git worktree remove "${wtDir}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync('git commit -m "task: completed work"', { + cwd: wtDir, + encoding: "utf-8", + stdio: "pipe", + }); + const orchHead = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + execSync(`git worktree remove "${wtDir}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); assert(orchHead !== mainOriginalSha, "orch branch has advanced beyond main"); @@ -879,12 +1182,18 @@ function runAllTests(): void { assert(ffResult.ok, "ff-only auto-integration succeeds"); // Verify main advanced to orchBranch HEAD - const mainNewSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const mainNewSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); assert(mainNewSha === orchHead, "main advanced to orch branch HEAD after auto-integration"); // Verify working tree has the new file - assert(existsSync(join(repoDir, "task-work.txt")), - "task-work.txt present in working tree after auto-integration"); + assert( + existsSync(join(repoDir, "task-work.txt")), + "task-work.txt present in working tree after auto-integration", + ); // Orch branch still exists (never deleted) const orchExists = runGit(["rev-parse", "--verify", `refs/heads/${orchBranch}`], repoDir); @@ -902,12 +1211,20 @@ function runAllTests(): void { try { // Set up repo with initial commit execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Create orch branch from main const orchBranch = "orch/testop-diverged"; @@ -915,19 +1232,39 @@ function runAllTests(): void { // Advance orch branch const wtDir = join(tempBase, "orch-wt"); - execSync(`git worktree add "${wtDir}" ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir}" ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir, "orch-work.txt"), "orch work\n"); execSync("git add -A", { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "orch: task work"', { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - execSync(`git worktree remove "${wtDir}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree remove "${wtDir}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Also advance main (user commits during batch) → divergence writeFileSync(join(repoDir, "user-change.txt"), "user work\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - execSync('git commit -m "user: concurrent work"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - - const mainSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - const orchSha = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + execSync('git commit -m "user: concurrent work"', { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); + + const mainSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + const orchSha = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); assert(mainSha !== orchSha, "branches have diverged"); // Check fast-forwardability fails (main is NOT ancestor of orchBranch) @@ -939,7 +1276,11 @@ function runAllTests(): void { assert(orchExists.ok, "orch branch preserved when integration fails (divergence fallback)"); // Main was not touched - const mainAfter = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const mainAfter = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); assert(mainAfter === mainSha, "main branch unchanged after failed auto-integration"); } finally { rmSync(tempBase, { recursive: true, force: true }); @@ -960,11 +1301,18 @@ function runAllTests(): void { // Auto-integration success message const autoSuccessMsg = ORCH_MESSAGES.orchIntegrationAutoSuccess("orch/op-batch1", "main"); - assert(autoSuccessMsg.includes("Auto-integrated"), "auto-success message indicates auto-integration"); + assert( + autoSuccessMsg.includes("Auto-integrated"), + "auto-success message indicates auto-integration", + ); assert(autoSuccessMsg.includes("fast-forwarded"), "auto-success message mentions fast-forward"); // Auto-integration failure message - const autoFailedMsg = ORCH_MESSAGES.orchIntegrationAutoFailed("orch/op-batch1", "main", "branches diverged"); + const autoFailedMsg = ORCH_MESSAGES.orchIntegrationAutoFailed( + "orch/op-batch1", + "main", + "branches diverged", + ); assert(autoFailedMsg.includes("skipped"), "auto-failed message says skipped"); assert(autoFailedMsg.includes("branches diverged"), "auto-failed message includes reason"); assert(autoFailedMsg.includes("preserved"), "auto-failed message says branch preserved"); @@ -977,76 +1325,112 @@ function runAllTests(): void { const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Cleanup section uses orchBranch for targetBranch - const cleanupSection = engineSource.match(/Phase 3: Cleanup[\s\S]*?Post-worktree-removal/)?.[0] ?? ""; - assert(cleanupSection.includes("batchState.orchBranch"), - "Phase 3 cleanup references batchState.orchBranch for unmerged-branch detection"); + const cleanupSection = + engineSource.match(/Phase 3: Cleanup[\s\S]*?Post-worktree-removal/)?.[0] ?? ""; + assert( + cleanupSection.includes("batchState.orchBranch"), + "Phase 3 cleanup references batchState.orchBranch for unmerged-branch detection", + ); // No deletion of orchBranch anywhere in engine.ts - assert(!engineSource.includes('deleteBranchBestEffort(batchState.orchBranch'), - "engine.ts never calls deleteBranchBestEffort on orchBranch"); - assert(!engineSource.includes('deleteBranchBestEffort(orchBranch'), - "engine.ts never calls deleteBranchBestEffort on orchBranch variable"); + assert( + !engineSource.includes("deleteBranchBestEffort(batchState.orchBranch"), + "engine.ts never calls deleteBranchBestEffort on orchBranch", + ); + assert( + !engineSource.includes("deleteBranchBestEffort(orchBranch"), + "engine.ts never calls deleteBranchBestEffort on orchBranch variable", + ); // Auto-integration block exists and is gated by integration config - assert(engineSource.includes('orchestrator.integration === "auto"'), - "auto-integration is gated by config.orchestrator.integration"); + assert( + engineSource.includes('orchestrator.integration === "auto"'), + "auto-integration is gated by config.orchestrator.integration", + ); // Manual mode preserves orchBranch with guidance message - assert(engineSource.includes("orchIntegrationManual"), - "engine.ts calls orchIntegrationManual for manual mode guidance"); + assert( + engineSource.includes("orchIntegrationManual"), + "engine.ts calls orchIntegrationManual for manual mode guidance", + ); } // 22) Structural: resume.ts section 11 mirrors engine.ts Phase 3 (auto-integration + cleanup + messaging) { - console.log(" 22) Structural: resume.ts mirrors engine.ts auto-integration + cleanup + messaging"); + console.log( + " 22) Structural: resume.ts mirrors engine.ts auto-integration + cleanup + messaging", + ); const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // a) resume.ts has auto-integration block - assert(resumeSource.includes('orchestrator.integration === "auto"'), - "resume.ts gates auto-integration by config.orchestrator.integration"); + assert( + resumeSource.includes('orchestrator.integration === "auto"'), + "resume.ts gates auto-integration by config.orchestrator.integration", + ); // b) TP-043: resume.ts defers integration to supervisor for supervised/auto modes. // attemptAutoIntegration is no longer imported — integration is supervisor-managed. - assert(resumeSource.includes("integration deferred to supervisor"), - "resume.ts defers supervised/auto integration to supervisor"); - assert(!resumeSource.includes("function attemptAutoIntegrationResume"), - "resume.ts does NOT have a local duplicate auto-integration function"); + assert( + resumeSource.includes("integration deferred to supervisor"), + "resume.ts defers supervised/auto integration to supervisor", + ); + assert( + !resumeSource.includes("function attemptAutoIntegrationResume"), + "resume.ts does NOT have a local duplicate auto-integration function", + ); // c) resume.ts shows manual integration guidance on non-auto path - assert(resumeSource.includes("orchIntegrationManual"), - "resume.ts calls orchIntegrationManual for manual mode guidance"); + assert( + resumeSource.includes("orchIntegrationManual"), + "resume.ts calls orchIntegrationManual for manual mode guidance", + ); // d) resume.ts cleanup uses orchBranch (not baseBranch) for primary repo unmerged detection - const resumeCleanupSection = resumeSource.match( - /11\. Cleanup and terminal state[\s\S]*?batchState\.endedAt = Date\.now/ - )?.[0] ?? ""; - assert(resumeCleanupSection.includes("batchState.orchBranch"), - "resume.ts cleanup references batchState.orchBranch for unmerged-branch detection"); + const resumeCleanupSection = + resumeSource.match( + /11\. Cleanup and terminal state[\s\S]*?batchState\.endedAt = Date\.now/, + )?.[0] ?? ""; + assert( + resumeCleanupSection.includes("batchState.orchBranch"), + "resume.ts cleanup references batchState.orchBranch for unmerged-branch detection", + ); // e) Shared attemptAutoIntegration in merge.ts has the required gate structure const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); - const sharedAutoFn = mergeSource.match( - /export function attemptAutoIntegration[\s\S]*?return true;\s*\}/ - )?.[0] ?? ""; - assert(sharedAutoFn.includes("merge-base"), - "shared auto-integration checks merge-base ancestry"); - assert(sharedAutoFn.includes("getCurrentBranch"), - "shared auto-integration gates on checked-out branch"); - assert(sharedAutoFn.includes("update-ref"), - "shared auto-integration uses update-ref for non-checked-out path"); - assert(sharedAutoFn.includes("--ff-only"), - "shared auto-integration uses --ff-only for checked-out path"); - assert(sharedAutoFn.includes("--porcelain"), - "shared auto-integration checks dirty worktree before ff-only"); - assert(sharedAutoFn.includes("logCategory"), - "shared auto-integration accepts logCategory parameter for engine/resume disambiguation"); + const sharedAutoFn = + mergeSource.match(/export function attemptAutoIntegration[\s\S]*?return true;\s*\}/)?.[0] ?? ""; + assert(sharedAutoFn.includes("merge-base"), "shared auto-integration checks merge-base ancestry"); + assert( + sharedAutoFn.includes("getCurrentBranch"), + "shared auto-integration gates on checked-out branch", + ); + assert( + sharedAutoFn.includes("update-ref"), + "shared auto-integration uses update-ref for non-checked-out path", + ); + assert( + sharedAutoFn.includes("--ff-only"), + "shared auto-integration uses --ff-only for checked-out path", + ); + assert( + sharedAutoFn.includes("--porcelain"), + "shared auto-integration checks dirty worktree before ff-only", + ); + assert( + sharedAutoFn.includes("logCategory"), + "shared auto-integration accepts logCategory parameter for engine/resume disambiguation", + ); // f) TP-043: Both engine and resume defer integration to supervisor for supervised/auto modes - assert(engineSource.includes("integration deferred to supervisor"), - "engine.ts defers supervised/auto integration to supervisor"); - assert(engineSource.includes("orchIntegrationManual") && resumeSource.includes("orchIntegrationManual"), - "both engine.ts and resume.ts use orchIntegrationManual message for manual mode"); + assert( + engineSource.includes("integration deferred to supervisor"), + "engine.ts defers supervised/auto integration to supervisor", + ); + assert( + engineSource.includes("orchIntegrationManual") && resumeSource.includes("orchIntegrationManual"), + "both engine.ts and resume.ts use orchIntegrationManual message for manual mode", + ); } // 23) Behavioral: auto-integration via update-ref when baseBranch is NOT checked out @@ -1057,52 +1441,89 @@ function runAllTests(): void { try { // Set up repo with initial commit on main execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); writeFileSync(join(repoDir, "README.md"), "# Test\n"); execSync("git add -A", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync('git commit -m "initial"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); } catch { /* already main */ } + try { + execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + } catch { + /* already main */ + } // Create a feature branch and check it out (so main is NOT checked out) execSync("git checkout -b feature", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const mainOriginalSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const mainOriginalSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Create orch branch from main and advance it const orchBranch = "orch/testop-refintegrate"; execSync(`git branch ${orchBranch} main`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); const wtDir = join(tempBase, "orch-wt"); - execSync(`git worktree add "${wtDir}" ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree add "${wtDir}" ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); writeFileSync(join(wtDir, "task-work.txt"), "task work\n"); execSync("git add -A", { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - execSync('git commit -m "task: completed work"', { cwd: wtDir, encoding: "utf-8", stdio: "pipe" }); - const orchHead = execSync(`git rev-parse ${orchBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - execSync(`git worktree remove "${wtDir}" --force`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync('git commit -m "task: completed work"', { + cwd: wtDir, + encoding: "utf-8", + stdio: "pipe", + }); + const orchHead = execSync(`git rev-parse ${orchBranch}`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + execSync(`git worktree remove "${wtDir}" --force`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Verify main is NOT checked out const currentBranch = getCurrentBranch(repoDir); assert(currentBranch === "feature", "feature is checked out, not main"); // Execute update-ref (mirrors attemptAutoIntegration's non-checked-out path) - const baseOldRef = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); - const updateResult = runGit( - ["update-ref", "refs/heads/main", orchHead, baseOldRef], - repoDir, - ); + const baseOldRef = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); + const updateResult = runGit(["update-ref", "refs/heads/main", orchHead, baseOldRef], repoDir); assert(updateResult.ok, "update-ref succeeds for auto-integration"); // Verify main advanced - const mainNewSha = execSync("git rev-parse main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const mainNewSha = execSync("git rev-parse main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); assert(mainNewSha === orchHead, "main advanced to orchBranch HEAD via update-ref"); // Verify working tree was NOT affected (we're on feature branch) - assert(!existsSync(join(repoDir, "task-work.txt")), - "task-work.txt NOT in working tree (update-ref doesn't touch it)"); + assert( + !existsSync(join(repoDir, "task-work.txt")), + "task-work.txt NOT in working tree (update-ref doesn't touch it)", + ); // Verify we're still on feature branch - assert(getCurrentBranch(repoDir) === "feature", - "still on feature branch after update-ref (checkout untouched)"); + assert( + getCurrentBranch(repoDir) === "feature", + "still on feature branch after update-ref (checkout untouched)", + ); } finally { rmSync(tempBase, { recursive: true, force: true }); } @@ -1110,78 +1531,115 @@ function runAllTests(): void { // 24) Structural: auto-integration is gated to terminal phases only (no integration on paused/stopped) { - console.log(" 24) Structural: auto-integration gated to terminal phases (completed/failed) only"); + console.log( + " 24) Structural: auto-integration gated to terminal phases (completed/failed) only", + ); const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // engine.ts: isTerminalPhase gate before auto-integration - const engineAutoBlock = engineSource.match( - /Auto-Integration[\s\S]*?orchIntegrationManual/ - )?.[0] ?? ""; - assert(engineAutoBlock.includes('batchState.phase === "completed"'), - "engine.ts auto-integration checks for completed phase"); - assert(engineAutoBlock.includes('batchState.phase === "failed"'), - "engine.ts auto-integration checks for failed phase"); - assert(engineAutoBlock.includes("isTerminalPhase"), - "engine.ts auto-integration uses isTerminalPhase gate"); + const engineAutoBlock = + engineSource.match(/Auto-Integration[\s\S]*?orchIntegrationManual/)?.[0] ?? ""; + assert( + engineAutoBlock.includes('batchState.phase === "completed"'), + "engine.ts auto-integration checks for completed phase", + ); + assert( + engineAutoBlock.includes('batchState.phase === "failed"'), + "engine.ts auto-integration checks for failed phase", + ); + assert( + engineAutoBlock.includes("isTerminalPhase"), + "engine.ts auto-integration uses isTerminalPhase gate", + ); // resume.ts: same isTerminalPhase gate - const resumeAutoBlock = resumeSource.match( - /Auto-Integration[\s\S]*?orchIntegrationManual/ - )?.[0] ?? ""; - assert(resumeAutoBlock.includes('batchState.phase === "completed"'), - "resume.ts auto-integration checks for completed phase"); - assert(resumeAutoBlock.includes('batchState.phase === "failed"'), - "resume.ts auto-integration checks for failed phase"); - assert(resumeAutoBlock.includes("isTerminalPhase"), - "resume.ts auto-integration uses isTerminalPhase gate"); - - // Neither file should run auto-integration when phase is paused or stopped - // Verify the gate is used in the if condition (not just defined) - const engineGateIf = engineSource.match(/if \(isTerminalPhase && !preserveWorktreesForResume/); - assert(engineGateIf !== null, - "engine.ts gates auto-integration with isTerminalPhase in if condition"); - const resumeGateIf = resumeSource.match(/if \(isTerminalPhase && !preserveWorktreesForResume/); - assert(resumeGateIf !== null, - "resume.ts gates auto-integration with isTerminalPhase in if condition"); + const resumeAutoBlock = + resumeSource.match(/Auto-Integration[\s\S]*?orchIntegrationManual/)?.[0] ?? ""; + assert( + resumeAutoBlock.includes('batchState.phase === "completed"'), + "resume.ts auto-integration checks for completed phase", + ); + assert( + resumeAutoBlock.includes('batchState.phase === "failed"'), + "resume.ts auto-integration checks for failed phase", + ); + assert( + resumeAutoBlock.includes("isTerminalPhase"), + "resume.ts auto-integration uses isTerminalPhase gate", + ); + + // Neither file should run auto-integration when phase is paused or stopped. + // Verify the gate is used in the if condition (not just defined). + // TP-193: regex uses `\s*` between tokens because the formatter wraps long + // boolean expressions vertically (`if (\n\tisTerminalPhase &&\n\t!preserveWorktreesForResume`). + const gateRegex = /if \(\s*isTerminalPhase\s*&&\s*!preserveWorktreesForResume/; + const engineGateIf = engineSource.match(gateRegex); + assert( + engineGateIf !== null, + "engine.ts gates auto-integration with isTerminalPhase in if condition", + ); + const resumeGateIf = resumeSource.match(gateRegex); + assert( + resumeGateIf !== null, + "resume.ts gates auto-integration with isTerminalPhase in if condition", + ); } // 25) Structural: resume.ts workspace-mode cleanup resolves per-repo target branch { - console.log(" 25) Structural: resume.ts resolves per-repo target branch for workspace-mode cleanup"); + console.log( + " 25) Structural: resume.ts resolves per-repo target branch for workspace-mode cleanup", + ); const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Section 11 cleanup should resolve per-repo target branches - const cleanupSection = resumeSource.match( - /11\. Cleanup and terminal state[\s\S]*?batchState\.endedAt = Date\.now/ - )?.[0] ?? ""; + const cleanupSection = + resumeSource.match( + /11\. Cleanup and terminal state[\s\S]*?batchState\.endedAt = Date\.now/, + )?.[0] ?? ""; // Primary repo uses orchBranch - assert(cleanupSection.includes("perRepoRoot === repoRoot"), - "resume.ts cleanup distinguishes primary repo from secondary repos"); - assert(cleanupSection.includes("batchState.orchBranch"), - "resume.ts cleanup uses orchBranch for primary repo"); + assert( + cleanupSection.includes("perRepoRoot === repoRoot"), + "resume.ts cleanup distinguishes primary repo from secondary repos", + ); + assert( + cleanupSection.includes("batchState.orchBranch"), + "resume.ts cleanup uses orchBranch for primary repo", + ); // Secondary repos resolve via resolveBaseBranch - assert(cleanupSection.includes("resolveRepoIdFromRoot"), - "resume.ts cleanup resolves repoId for secondary repos"); - assert(cleanupSection.includes("resolveBaseBranch(repoId, perRepoRoot"), - "resume.ts cleanup calls resolveBaseBranch per secondary repo"); + assert( + cleanupSection.includes("resolveRepoIdFromRoot"), + "resume.ts cleanup resolves repoId for secondary repos", + ); + assert( + cleanupSection.includes("resolveBaseBranch(repoId, perRepoRoot"), + "resume.ts cleanup calls resolveBaseBranch per secondary repo", + ); // Graceful fallback when resolveBaseBranch throws - assert(cleanupSection.includes("targetBranch = undefined"), - "resume.ts cleanup falls back to undefined targetBranch when resolveBaseBranch throws"); + assert( + cleanupSection.includes("targetBranch = undefined"), + "resume.ts cleanup falls back to undefined targetBranch when resolveBaseBranch throws", + ); // resolveRepoIdFromRoot helper exists and works correctly - assert(resumeSource.includes("export function resolveRepoIdFromRoot"), - "resolveRepoIdFromRoot helper is exported from resume.ts"); - const helperFn = resumeSource.match( - /function resolveRepoIdFromRoot[\s\S]*?return undefined;\s*\}/ - )?.[0] ?? ""; - assert(helperFn.includes("workspaceConfig"), - "resolveRepoIdFromRoot uses workspaceConfig for reverse lookup"); - assert(helperFn.includes("repoConfig.path === repoRoot"), - "resolveRepoIdFromRoot matches by repo path"); + assert( + resumeSource.includes("export function resolveRepoIdFromRoot"), + "resolveRepoIdFromRoot helper is exported from resume.ts", + ); + const helperFn = + resumeSource.match(/function resolveRepoIdFromRoot[\s\S]*?return undefined;\s*\}/)?.[0] ?? ""; + assert( + helperFn.includes("workspaceConfig"), + "resolveRepoIdFromRoot uses workspaceConfig for reverse lookup", + ); + assert( + helperFn.includes("repoConfig.path === repoRoot"), + "resolveRepoIdFromRoot matches by repo path", + ); } // 26) Structural: resume.ts inter-wave reset also uses per-repo target branch @@ -1190,16 +1648,23 @@ function runAllTests(): void { const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Inter-wave reset section (between wave executions) should resolve per-repo - const resetSection = resumeSource.match( - /waveIdx < persistedState\.wavePlan\.length - 1[\s\S]*?forceCleanupWorktree/ - )?.[0] ?? ""; - - assert(resetSection.includes("perRepoRoot === repoRoot"), - "inter-wave reset distinguishes primary repo from secondary repos"); - assert(resetSection.includes("resolveRepoIdFromRoot"), - "inter-wave reset resolves repoId for secondary repos"); - assert(resetSection.includes("resolveBaseBranch"), - "inter-wave reset calls resolveBaseBranch for secondary repos"); + const resetSection = + resumeSource.match( + /waveIdx < persistedState\.wavePlan\.length - 1[\s\S]*?forceCleanupWorktree/, + )?.[0] ?? ""; + + assert( + resetSection.includes("perRepoRoot === repoRoot"), + "inter-wave reset distinguishes primary repo from secondary repos", + ); + assert( + resetSection.includes("resolveRepoIdFromRoot"), + "inter-wave reset resolves repoId for secondary repos", + ); + assert( + resetSection.includes("resolveBaseBranch"), + "inter-wave reset calls resolveBaseBranch for secondary repos", + ); } console.log(`\nResults: ${passed} passed, ${failed} failed`); diff --git a/extensions/tests/orch-integrate.integration.test.ts b/extensions/tests/orch-integrate.integration.test.ts index d9c73a9d..5c054e27 100644 --- a/extensions/tests/orch-integrate.integration.test.ts +++ b/extensions/tests/orch-integrate.integration.test.ts @@ -11,7 +11,13 @@ import { describe, it } from "node:test"; import { expect } from "./expect.ts"; -import { parseIntegrateArgs, resolveIntegrationContext, executeIntegration, dropBatchAutostash, collectRepoCleanupFindings } from "../taskplane/extension.ts"; +import { + parseIntegrateArgs, + resolveIntegrationContext, + executeIntegration, + dropBatchAutostash, + collectRepoCleanupFindings, +} from "../taskplane/extension.ts"; import { computeIntegrateCleanupResult } from "../taskplane/messages.ts"; import type { IntegrateArgs, @@ -24,7 +30,11 @@ import type { } from "../taskplane/extension.ts"; import type { IntegrateCleanupRepoFindings } from "../taskplane/messages.ts"; import { StateFileError, DEFAULT_ORCHESTRATOR_CONFIG } from "../taskplane/types.ts"; -import type { PersistedBatchState, OrchBatchPhase, OrchestratorConfig } from "../taskplane/types.ts"; +import type { + PersistedBatchState, + OrchBatchPhase, + OrchestratorConfig, +} from "../taskplane/types.ts"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "fs"; import { join } from "path"; @@ -218,7 +228,10 @@ describe("parseIntegrateArgs — multiple positionals", () => { }); it("rejects multiple positionals with flags mixed in", () => { - expectError(parseIntegrateArgs("branch1 --force branch2"), "Expected at most one branch argument, got 2"); + expectError( + parseIntegrateArgs("branch1 --force branch2"), + "Expected at most one branch argument, got 2", + ); }); }); @@ -337,7 +350,16 @@ describe("resolveIntegrationContext — phase gating", () => { expect(ctx.currentBranch).toBe("main"); }); - const nonCompletedPhases: OrchBatchPhase[] = ["idle", "launching", "planning", "executing", "merging", "paused", "stopped", "failed"]; + const nonCompletedPhases: OrchBatchPhase[] = [ + "idle", + "launching", + "planning", + "executing", + "merging", + "paused", + "stopped", + "failed", + ]; for (const phase of nonCompletedPhases) { it(`rejects phase "${phase}" with info severity`, () => { const deps = makeDeps({ @@ -399,7 +421,7 @@ describe("resolveIntegrationContext — no state + branch scan", () => { const result = resolveIntegrationContext(defaultParsed(), deps); const ctx = expectContext(result); expect(ctx.orchBranch).toBe("orch/auto-detected"); - expect(ctx.notices.some(n => n.includes("Auto-detected"))).toBe(true); + expect(ctx.notices.some((n) => n.includes("Auto-detected"))).toBe(true); }); it("returns error when no state, no arg, and multiple orch branches", () => { @@ -437,7 +459,9 @@ describe("resolveIntegrationContext — no state + branch scan", () => { describe("resolveIntegrationContext — StateFileError", () => { it("returns error on IO error without branch arg", () => { const deps = makeDeps({ - loadBatchState: () => { throw new StateFileError("STATE_FILE_IO_ERROR", "permission denied"); }, + loadBatchState: () => { + throw new StateFileError("STATE_FILE_IO_ERROR", "permission denied"); + }, }); const result = resolveIntegrationContext(defaultParsed(), deps); const err = expectContextError(result, "error"); @@ -446,7 +470,9 @@ describe("resolveIntegrationContext — StateFileError", () => { it("returns error on parse error without branch arg", () => { const deps = makeDeps({ - loadBatchState: () => { throw new StateFileError("STATE_FILE_PARSE_ERROR", "unexpected token"); }, + loadBatchState: () => { + throw new StateFileError("STATE_FILE_PARSE_ERROR", "unexpected token"); + }, }); const result = resolveIntegrationContext(defaultParsed(), deps); const err = expectContextError(result, "error"); @@ -455,7 +481,9 @@ describe("resolveIntegrationContext — StateFileError", () => { it("returns error on schema error without branch arg", () => { const deps = makeDeps({ - loadBatchState: () => { throw new StateFileError("STATE_SCHEMA_INVALID", "missing batchId"); }, + loadBatchState: () => { + throw new StateFileError("STATE_SCHEMA_INVALID", "missing batchId"); + }, }); const result = resolveIntegrationContext(defaultParsed(), deps); const err = expectContextError(result, "error"); @@ -464,47 +492,46 @@ describe("resolveIntegrationContext — StateFileError", () => { it("falls back to branch arg on IO error when arg provided", () => { const deps = makeDeps({ - loadBatchState: () => { throw new StateFileError("STATE_FILE_IO_ERROR", "permission denied"); }, + loadBatchState: () => { + throw new StateFileError("STATE_FILE_IO_ERROR", "permission denied"); + }, orchBranchExists: () => true, }); - const result = resolveIntegrationContext( - defaultParsed({ orchBranchArg: "orch/fallback" }), - deps, - ); + const result = resolveIntegrationContext(defaultParsed({ orchBranchArg: "orch/fallback" }), deps); const ctx = expectContext(result); expect(ctx.orchBranch).toBe("orch/fallback"); - expect(ctx.notices.some(n => n.includes("Could not read"))).toBe(true); + expect(ctx.notices.some((n) => n.includes("Could not read"))).toBe(true); }); it("falls back to branch arg on parse error when arg provided", () => { const deps = makeDeps({ - loadBatchState: () => { throw new StateFileError("STATE_FILE_PARSE_ERROR", "bad json"); }, + loadBatchState: () => { + throw new StateFileError("STATE_FILE_PARSE_ERROR", "bad json"); + }, orchBranchExists: () => true, }); - const result = resolveIntegrationContext( - defaultParsed({ orchBranchArg: "orch/fallback" }), - deps, - ); + const result = resolveIntegrationContext(defaultParsed({ orchBranchArg: "orch/fallback" }), deps); const ctx = expectContext(result); expect(ctx.orchBranch).toBe("orch/fallback"); }); it("falls back to branch arg on non-StateFileError when arg provided", () => { const deps = makeDeps({ - loadBatchState: () => { throw new Error("something unexpected"); }, + loadBatchState: () => { + throw new Error("something unexpected"); + }, orchBranchExists: () => true, }); - const result = resolveIntegrationContext( - defaultParsed({ orchBranchArg: "orch/fallback" }), - deps, - ); + const result = resolveIntegrationContext(defaultParsed({ orchBranchArg: "orch/fallback" }), deps); const ctx = expectContext(result); expect(ctx.orchBranch).toBe("orch/fallback"); }); it("returns error on non-StateFileError without branch arg", () => { const deps = makeDeps({ - loadBatchState: () => { throw new Error("unknown failure"); }, + loadBatchState: () => { + throw new Error("unknown failure"); + }, }); const result = resolveIntegrationContext(defaultParsed(), deps); const err = expectContextError(result, "error"); @@ -529,7 +556,10 @@ describe("resolveIntegrationContext — branch existence", () => { it("passes orchBranch to orchBranchExists for verification", () => { let checkedBranch = ""; const deps = makeDeps({ - orchBranchExists: (b) => { checkedBranch = b; return true; }, + orchBranchExists: (b) => { + checkedBranch = b; + return true; + }, }); resolveIntegrationContext(defaultParsed(), deps); expect(checkedBranch).toBe("orch/henry-20260318T140000"); @@ -580,10 +610,7 @@ describe("resolveIntegrationContext — branch safety", () => { loadBatchState: () => makeBatchState({ baseBranch: "main" }), getCurrentBranch: () => "feature/other", }); - const result = resolveIntegrationContext( - defaultParsed({ force: true }), - deps, - ); + const result = resolveIntegrationContext(defaultParsed({ force: true }), deps); const ctx = expectContext(result); expect(ctx.currentBranch).toBe("feature/other"); expect(ctx.baseBranch).toBe("main"); @@ -624,10 +651,7 @@ describe("resolveIntegrationContext — happy path", () => { const deps = makeDeps({ orchBranchExists: (b) => b === "orch/override", }); - const result = resolveIntegrationContext( - defaultParsed({ orchBranchArg: "orch/override" }), - deps, - ); + const result = resolveIntegrationContext(defaultParsed({ orchBranchArg: "orch/override" }), deps); const ctx = expectContext(result); expect(ctx.orchBranch).toBe("orch/override"); // baseBranch still comes from state @@ -689,9 +713,9 @@ describe("executeIntegration — fast-forward mode", () => { }); executeIntegration("ff", makeContext(), deps); // status --porcelain (stash check) must occur before merge - const statusIdx = gitCalls.findIndex(c => c[0] === "status"); - const mergeCall = gitCalls.find(c => c[0] === "merge"); - const mergeIdx = gitCalls.findIndex(c => c[0] === "merge"); + const statusIdx = gitCalls.findIndex((c) => c[0] === "status"); + const mergeCall = gitCalls.find((c) => c[0] === "merge"); + const mergeIdx = gitCalls.findIndex((c) => c[0] === "merge"); expect(statusIdx).toBeGreaterThanOrEqual(0); expect(mergeCall).toEqual(["merge", "--ff-only", "orch/henry-20260318T140000"]); expect(statusIdx).toBeLessThan(mergeIdx); @@ -730,7 +754,9 @@ describe("executeIntegration — fast-forward mode", () => { } return { ok: true, stdout: "", stderr: "" }; }, - deleteBatchState: () => { cleanupCalled = true; }, + deleteBatchState: () => { + cleanupCalled = true; + }, }); executeIntegration("ff", makeContext(), deps); expect(cleanupCalled).toBe(false); @@ -759,9 +785,9 @@ describe("executeIntegration — merge mode", () => { }); executeIntegration("merge", makeContext(), deps); // status --porcelain (stash check) must occur before merge - const statusIdx = gitCalls.findIndex(c => c[0] === "status"); - const mergeCall = gitCalls.find(c => c[0] === "merge"); - const mergeIdx = gitCalls.findIndex(c => c[0] === "merge"); + const statusIdx = gitCalls.findIndex((c) => c[0] === "status"); + const mergeCall = gitCalls.find((c) => c[0] === "merge"); + const mergeIdx = gitCalls.findIndex((c) => c[0] === "merge"); expect(statusIdx).toBeGreaterThanOrEqual(0); expect(mergeCall).toEqual(["merge", "orch/henry-20260318T140000", "--no-edit"]); expect(statusIdx).toBeLessThan(mergeIdx); @@ -798,7 +824,9 @@ describe("executeIntegration — merge mode", () => { } return { ok: true, stdout: "", stderr: "" }; }, - deleteBatchState: () => { cleanupCalled = true; }, + deleteBatchState: () => { + cleanupCalled = true; + }, }); executeIntegration("merge", makeContext(), deps); expect(cleanupCalled).toBe(false); @@ -839,8 +867,8 @@ describe("executeIntegration — PR mode", () => { }); executeIntegration("pr", makeContext(), deps); // git push must occur before gh pr create - const pushCall = calls.find(c => c.type === "git" && c.args[0] === "push"); - const prCall = calls.find(c => c.type === "gh"); + const pushCall = calls.find((c) => c.type === "git" && c.args[0] === "push"); + const prCall = calls.find((c) => c.type === "gh"); expect(pushCall).toBeDefined(); expect(pushCall!.args).toEqual(["push", "origin", "orch/henry-20260318T140000"]); expect(prCall).toBeDefined(); @@ -884,7 +912,9 @@ describe("executeIntegration — PR mode", () => { return { ok: true, stdout: "", stderr: "" }; }, runCommand: () => ({ ok: true, stdout: "https://example.com/pr/1", stderr: "" }), - deleteBatchState: () => { stateDeleted = true; }, + deleteBatchState: () => { + stateDeleted = true; + }, }); executeIntegration("pr", makeContext(), deps); expect(branchDeleted).toBe(false); @@ -935,13 +965,15 @@ describe("executeIntegration — already merged detection", () => { if (args[0] === "branch" && args[1] === "-D") branchDeleted = true; return { ok: true, stdout: "", stderr: "" }; }, - deleteBatchState: () => { stateDeleted = true; }, + deleteBatchState: () => { + stateDeleted = true; + }, }); const result = executeIntegration("ff", makeContext(), deps); expect(result.success).toBe(true); - expect(mergeAttempted).toBe(false); // no merge attempt - expect(branchDeleted).toBe(true); // cleanup ran - expect(stateDeleted).toBe(true); // cleanup ran + expect(mergeAttempted).toBe(false); // no merge attempt + expect(branchDeleted).toBe(true); // cleanup ran + expect(stateDeleted).toBe(true); // cleanup ran expect(result.message).toContain("Already integrated"); }); }); @@ -960,7 +992,9 @@ describe("executeIntegration — cleanup", () => { } return { ok: true, stdout: "", stderr: "" }; }, - deleteBatchState: () => { stateDeleted = true; }, + deleteBatchState: () => { + stateDeleted = true; + }, }); executeIntegration("ff", makeContext(), deps); expect(branchDeleted).toBe(true); @@ -975,7 +1009,9 @@ describe("executeIntegration — cleanup", () => { if (args[0] === "branch" && args[1] === "-D") branchDeleted = true; return { ok: true, stdout: "", stderr: "" }; }, - deleteBatchState: () => { stateDeleted = true; }, + deleteBatchState: () => { + stateDeleted = true; + }, }); executeIntegration("merge", makeContext(), deps); expect(branchDeleted).toBe(true); @@ -998,7 +1034,9 @@ describe("executeIntegration — cleanup", () => { it("warns but still succeeds if state deletion throws", () => { const deps = makeExecDeps({ - deleteBatchState: () => { throw new Error("permission denied"); }, + deleteBatchState: () => { + throw new Error("permission denied"); + }, }); const result = executeIntegration("ff", makeContext(), deps); expect(result.success).toBe(true); @@ -1013,7 +1051,9 @@ describe("executeIntegration — cleanup", () => { } return { ok: true, stdout: "", stderr: "" }; }, - deleteBatchState: () => { throw new Error("state error"); }, + deleteBatchState: () => { + throw new Error("state error"); + }, }); const result = executeIntegration("ff", makeContext(), deps); expect(result.success).toBe(true); @@ -1134,9 +1174,9 @@ describe("computeIntegrateCleanupResult — pure function", () => { }, ]; const result = computeIntegrateCleanupResult(findings); - expect(result.report).toContain('git worktree remove --force'); - expect(result.report).toContain('git branch -D'); - expect(result.report).toContain('git stash drop'); + expect(result.report).toContain("git worktree remove --force"); + expect(result.report).toContain("git branch -D"); + expect(result.report).toContain("git stash drop"); }); }); @@ -1395,7 +1435,9 @@ describe("collectRepoCleanupFindings — real git repo", () => { return dir; } - function makeConfig(overrides: Partial = {}): OrchestratorConfig { + function makeConfig( + overrides: Partial = {}, + ): OrchestratorConfig { return { ...DEFAULT_ORCHESTRATOR_CONFIG, orchestrator: { @@ -1409,7 +1451,15 @@ describe("collectRepoCleanupFindings — real git repo", () => { it("returns empty findings for a clean repo", () => { const dir = initRepo(); const config = makeConfig(); - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findings.staleWorktrees).toHaveLength(0); expect(findings.staleLaneBranches).toHaveLength(0); expect(findings.staleOrchBranches).toHaveLength(0); @@ -1423,7 +1473,15 @@ describe("collectRepoCleanupFindings — real git repo", () => { execSync(`git branch "task/${opId}-lane-1-${batchId}"`, { cwd: dir, stdio: "pipe" }); execSync(`git branch "task/${opId}-lane-2-${batchId}"`, { cwd: dir, stdio: "pipe" }); const config = makeConfig(); - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findings.staleLaneBranches).toHaveLength(2); expect(findings.staleLaneBranches).toContain(`task/${opId}-lane-1-${batchId}`); expect(findings.staleLaneBranches).toContain(`task/${opId}-lane-2-${batchId}`); @@ -1434,7 +1492,15 @@ describe("collectRepoCleanupFindings — real git repo", () => { const dir = initRepo(); execSync(`git branch "${orchBranch}"`, { cwd: dir, stdio: "pipe" }); const config = makeConfig(); - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findings.staleOrchBranches).toHaveLength(1); expect(findings.staleOrchBranches[0]).toBe(orchBranch); rmSync(dir, { recursive: true, force: true }); @@ -1443,9 +1509,20 @@ describe("collectRepoCleanupFindings — real git repo", () => { it("detects stale autostash entries", () => { const dir = initRepo(); writeFileSync(join(dir, "dirty.txt"), "dirty"); - execSync(`git stash push --include-untracked -m "orch-integrate-autostash-${batchId}"`, { cwd: dir, stdio: "pipe" }); + execSync(`git stash push --include-untracked -m "orch-integrate-autostash-${batchId}"`, { + cwd: dir, + stdio: "pipe", + }); const config = makeConfig(); - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findings.staleAutostashEntries).toHaveLength(1); rmSync(dir, { recursive: true, force: true }); }); @@ -1456,7 +1533,15 @@ describe("collectRepoCleanupFindings — real git repo", () => { mkdirSync(worktreesDir, { recursive: true }); writeFileSync(join(worktreesDir, "stale-file"), "leftover"); const config = makeConfig({ worktree_location: "subdirectory" }); - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findings.nonEmptyWorktreeContainers).toHaveLength(1); rmSync(dir, { recursive: true, force: true }); }); @@ -1467,7 +1552,15 @@ describe("collectRepoCleanupFindings — real git repo", () => { mkdirSync(worktreesDir, { recursive: true }); writeFileSync(join(worktreesDir, "stale-file"), "leftover"); const config = makeConfig({ worktree_location: "sibling" }); - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findings.nonEmptyWorktreeContainers).toHaveLength(0); rmSync(dir, { recursive: true, force: true }); }); @@ -1479,17 +1572,43 @@ describe("collectRepoCleanupFindings — real git repo", () => { const config = makeConfig(); // Without skipOrchBranch → orch branch is flagged as stale - const findingsDefault = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config); + const findingsDefault = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + ); expect(findingsDefault.staleOrchBranches).toHaveLength(1); expect(findingsDefault.staleOrchBranches[0]).toBe(orchBranch); // With skipOrchBranch → orch branch is NOT flagged (PR mode contract) - const findingsPr = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config, { skipOrchBranch: true }); + const findingsPr = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + { skipOrchBranch: true }, + ); expect(findingsPr.staleOrchBranches).toHaveLength(0); // Other findings still work normally with skipOrchBranch execSync(`git branch "task/${opId}-lane-1-${batchId}"`, { cwd: dir, stdio: "pipe" }); - const findingsWithLane = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config, { skipOrchBranch: true }); + const findingsWithLane = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + { skipOrchBranch: true }, + ); expect(findingsWithLane.staleLaneBranches).toHaveLength(1); expect(findingsWithLane.staleOrchBranches).toHaveLength(0); @@ -1502,7 +1621,16 @@ describe("collectRepoCleanupFindings — real git repo", () => { const config = makeConfig(); // With skipOrchBranch, the repo should be considered clean - const findings = collectRepoCleanupFindings(dir, "myrepo", opId, batchId, prefix, orchBranch, config, { skipOrchBranch: true }); + const findings = collectRepoCleanupFindings( + dir, + "myrepo", + opId, + batchId, + prefix, + orchBranch, + config, + { skipOrchBranch: true }, + ); const result = computeIntegrateCleanupResult([findings]); expect(result.clean).toBe(true); expect(result.dirtyRepos).toHaveLength(0); @@ -1536,10 +1664,11 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { // Create initial task folder with unchecked STATUS.md mkdirSync(join(dir, "taskplane-tasks", "TP-001-test"), { recursive: true }); - writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), - "# TP-001\n- [ ] Item A\n- [ ] Item B\n"); - writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "PROMPT.md"), - "# Task: TP-001\n"); + writeFileSync( + join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), + "# TP-001\n- [ ] Item A\n- [ ] Item B\n", + ); + writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "PROMPT.md"), "# Task: TP-001\n"); writeFileSync(join(dir, "src.txt"), "initial code\n"); execSync("git add -A && git commit -m init", { cwd: dir, stdio: "pipe" }); return dir; @@ -1563,13 +1692,16 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), updatedStatus); writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", ".DONE"), "completed\n"); writeFileSync(join(dir, "src.txt"), "feature code\n"); - execSync('git add -A && git commit -m "lane merge: feature + updated STATUS"', { cwd: dir, stdio: "pipe" }); + execSync('git add -A && git commit -m "lane merge: feature + updated STATUS"', { + cwd: dir, + stdio: "pipe", + }); // Verify the lane merge commit has correct STATUS.md - const laneMergedStatus = execSync( - "git show HEAD:taskplane-tasks/TP-001-test/STATUS.md", - { cwd: dir, encoding: "utf-8" }, - ); + const laneMergedStatus = execSync("git show HEAD:taskplane-tasks/TP-001-test/STATUS.md", { + cwd: dir, + encoding: "utf-8", + }); expect(laneMergedStatus).toContain("[x] Item A"); expect(laneMergedStatus).toContain("[x] Item B"); expect(laneMergedStatus).toContain("Execution Log"); @@ -1608,10 +1740,10 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { expect(existsSync(donePath)).toBe(true); // Verify it's in the git tree - const doneContent = execSync( - "git show HEAD:taskplane-tasks/TP-001-test/.DONE", - { cwd: dir, encoding: "utf-8" }, - ); + const doneContent = execSync("git show HEAD:taskplane-tasks/TP-001-test/.DONE", { + cwd: dir, + encoding: "utf-8", + }); expect(doneContent).toContain("completed"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1627,7 +1759,10 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { join(dir, "taskplane-tasks", "TP-001-test", ".reviews", "R001-code-step1.md"), "# Review\n\nAPPROVE\n", ); - execSync('git add -A && git commit -m "lane merge: add review artifacts"', { cwd: dir, stdio: "pipe" }); + execSync('git add -A && git commit -m "lane merge: add review artifacts"', { + cwd: dir, + stdio: "pipe", + }); // Advance main execSync("git checkout main", { cwd: dir, stdio: "pipe" }); @@ -1657,7 +1792,10 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), updatedStatus); writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", ".DONE"), "completed\n"); writeFileSync(join(dir, "src.txt"), "feature code\n"); - execSync('git add -A && git commit -m "lane merge + correct artifacts"', { cwd: dir, stdio: "pipe" }); + execSync('git add -A && git commit -m "lane merge + correct artifacts"', { + cwd: dir, + stdio: "pipe", + }); // Advance main execSync("git checkout main", { cwd: dir, stdio: "pipe" }); @@ -1669,19 +1807,19 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { execSync('git commit -m "Integrate orch batch (squash)"', { cwd: dir, stdio: "pipe" }); // Verify STATUS.md on main has checked items - const mainStatus = execSync( - "git show HEAD:taskplane-tasks/TP-001-test/STATUS.md", - { cwd: dir, encoding: "utf-8" }, - ); + const mainStatus = execSync("git show HEAD:taskplane-tasks/TP-001-test/STATUS.md", { + cwd: dir, + encoding: "utf-8", + }); expect(mainStatus).toContain("[x] Item A"); expect(mainStatus).toContain("[x] Item B"); expect(mainStatus).toContain("Discoveries"); // Verify .DONE on main - const mainDone = execSync( - "git show HEAD:taskplane-tasks/TP-001-test/.DONE", - { cwd: dir, encoding: "utf-8" }, - ); + const mainDone = execSync("git show HEAD:taskplane-tasks/TP-001-test/.DONE", { + cwd: dir, + encoding: "utf-8", + }); expect(mainDone).toContain("completed"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1693,17 +1831,24 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { try { // Create orch branch with updated STATUS.md execSync("git checkout -b orch/test", { cwd: dir, stdio: "pipe" }); - writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), - "# TP-001\n- [x] Item A\n- [x] Item B\n"); + writeFileSync( + join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), + "# TP-001\n- [x] Item A\n- [x] Item B\n", + ); writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", ".DONE"), "completed\n"); writeFileSync(join(dir, "src.txt"), "feature code\n"); execSync('git add -A && git commit -m "lane merge"', { cwd: dir, stdio: "pipe" }); // Simulate the OLD artifact staging (pre-fix): overwrite with template - writeFileSync(join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), - "# TP-001\n- [ ] Item A\n- [ ] Item B\n"); + writeFileSync( + join(dir, "taskplane-tasks", "TP-001-test", "STATUS.md"), + "# TP-001\n- [ ] Item A\n- [ ] Item B\n", + ); // Old code also removed .DONE from merge worktree if repoRoot didn't have it - execSync('git add -A && git commit -m "checkpoint artifacts (old behavior)"', { cwd: dir, stdio: "pipe" }); + execSync('git add -A && git commit -m "checkpoint artifacts (old behavior)"', { + cwd: dir, + stdio: "pipe", + }); // Advance main execSync("git checkout main", { cwd: dir, stdio: "pipe" }); @@ -1715,10 +1860,10 @@ describe("TP-099: artifact staging preserves lane-merged STATUS.md", () => { execSync('git commit -m "squash"', { cwd: dir, stdio: "pipe" }); // STATUS.md should have been reverted to template (demonstrates the bug) - const mainStatus = execSync( - "git show HEAD:taskplane-tasks/TP-001-test/STATUS.md", - { cwd: dir, encoding: "utf-8" }, - ); + const mainStatus = execSync("git show HEAD:taskplane-tasks/TP-001-test/STATUS.md", { + cwd: dir, + encoding: "utf-8", + }); // This demonstrates the pre-fix bug: STATUS.md has unchecked items expect(mainStatus).toContain("[ ] Item A"); expect(mainStatus).not.toContain("[x]"); diff --git a/extensions/tests/orch-pure-functions.test.ts b/extensions/tests/orch-pure-functions.test.ts index 88539e2d..cc8c8d8b 100644 --- a/extensions/tests/orch-pure-functions.test.ts +++ b/extensions/tests/orch-pure-functions.test.ts @@ -76,7 +76,7 @@ const sourceFiles = [ join(__dirname, "..", "taskplane", "waves.ts"), join(__dirname, "..", "taskplane", "types.ts"), ]; -const source = sourceFiles.map(f => readFileSync(f, "utf8")).join("\n"); +const source = sourceFiles.map((f) => readFileSync(f, "utf8")).join("\n"); /** * Extract a function body from the source by searching for its definition. @@ -118,7 +118,15 @@ function extractFunction(src: string, name: string): string { function computeOrchSummaryCounts( batchState: any, monitorState?: any, -): { completed: number; running: number; queued: number; failed: number; blocked: number; stalled: number; total: number } { +): { + completed: number; + running: number; + queued: number; + failed: number; + blocked: number; + stalled: number; + total: number; +} { let running = 0; let stalled = 0; @@ -135,7 +143,10 @@ function computeOrchSummaryCounts( const failed = batchState.failedTasks; const blocked = batchState.blockedTasks; const total = batchState.totalTasks; - const queued = Math.max(0, total - completed - failed - blocked - stalled - running - batchState.skippedTasks); + const queued = Math.max( + 0, + total - completed - failed - blocked - stalled - running - batchState.skippedTasks, + ); return { completed, running, queued, failed, blocked, stalled, total }; } @@ -182,30 +193,29 @@ function computeTransitiveDependents( // Reimplemented from source (verified by reading the actual implementation) // TP-170: Updated to match wave-aware lane display changes -function buildDashboardViewModel( - batchState: any, - monitorState?: any, -): any { +function buildDashboardViewModel(batchState: any, monitorState?: any): any { const summary = computeOrchSummaryCounts(batchState, monitorState); const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt); - const waveProgress = batchState.totalWaves > 0 - ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}` - : "0/0"; + const waveProgress = + batchState.totalWaves > 0 + ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}` + : "0/0"; const laneCards: any[] = []; // TP-170: Detect stale monitor data from prior waves - const monitorIsFresh = monitorState && monitorState.lanes.length > 0 && ( - (batchState.currentLanes?.length ?? 0) === 0 || - monitorState.lanes.some((ml: any) => - (batchState.currentLanes || []).some((cl: any) => cl.laneNumber === ml.laneNumber), - ) - ); + const monitorIsFresh = + monitorState && + monitorState.lanes.length > 0 && + ((batchState.currentLanes?.length ?? 0) === 0 || + monitorState.lanes.some((ml: any) => + (batchState.currentLanes || []).some((cl: any) => cl.laneNumber === ml.laneNumber), + )); // TP-170: Build allocation index for identity reconciliation const allocatedByLaneNumber = new Map(); - for (const cl of (batchState.currentLanes || [])) { + for (const cl of batchState.currentLanes || []) { allocatedByLaneNumber.set(cl.laneNumber, { laneSessionId: cl.laneSessionId, laneId: cl.laneId }); } @@ -220,8 +230,12 @@ function buildDashboardViewModel( else if (snap?.status === "running") { // TP-170: TOCTOU guard status = lane.sessionAlive ? "running" : "failed"; - } - else if (lane.completedTasks.length > 0 && lane.remainingTasks.length === 0 && !lane.currentTaskId) status = "succeeded"; + } else if ( + lane.completedTasks.length > 0 && + lane.remainingTasks.length === 0 && + !lane.currentTaskId + ) + status = "succeeded"; laneCards.push({ laneNumber: lane.laneNumber, @@ -233,13 +247,19 @@ function buildDashboardViewModel( totalChecked: snap?.totalChecked || 0, totalItems: snap?.totalItems || 0, completedTasks: lane.completedTasks.length, - totalLaneTasks: lane.completedTasks.length + lane.failedTasks.length + lane.remainingTasks.length + (lane.currentTaskId ? 1 : 0), + totalLaneTasks: + lane.completedTasks.length + + lane.failedTasks.length + + lane.remainingTasks.length + + (lane.currentTaskId ? 1 : 0), status, stallReason: snap?.stallReason || null, }); } } else if (batchState.currentLanes?.length > 0) { - const sortedLanes = [...batchState.currentLanes].sort((a: any, b: any) => a.laneNumber - b.laneNumber); + const sortedLanes = [...batchState.currentLanes].sort( + (a: any, b: any) => a.laneNumber - b.laneNumber, + ); for (const lane of sortedLanes) { laneCards.push({ laneNumber: lane.laneNumber, @@ -290,1030 +310,1233 @@ function buildDashboardViewModel( // ── All test logic wrapped in a function for dual-mode execution ───── function runAllTests(): void { + // ── Verify reimplementation matches source ─────────────────────────── -// ── Verify reimplementation matches source ─────────────────────────── + // First, let's verify that our reimplemented functions match the actual + // source code logic by checking key patterns are present in the source. -// First, let's verify that our reimplemented functions match the actual -// source code logic by checking key patterns are present in the source. + console.log("\n─── Source Verification ───"); -console.log("\n─── Source Verification ───"); + { + const fnSrc = extractFunction(source, "computeOrchSummaryCounts"); + // TP-193: Use a regex with `\s*` for `Math.max(0` so the formatter's + // vertical re-wrapping (Math.max(\n\t\t0, ...)) doesn't break the check. + assert(/Math\.max\(\s*0\s*,/.test(fnSrc), "computeOrchSummaryCounts: has Math.max(0 for queued"); + assert( + fnSrc.includes("batchState.skippedTasks"), + "computeOrchSummaryCounts: subtracts skippedTasks", + ); + assert(fnSrc.includes('status === "stalled"'), "computeOrchSummaryCounts: checks stalled status"); + assert(fnSrc.includes('status === "running"'), "computeOrchSummaryCounts: checks running status"); + } -{ - const fnSrc = extractFunction(source, "computeOrchSummaryCounts"); - assert(fnSrc.includes("Math.max(0,"), "computeOrchSummaryCounts: has Math.max(0 for queued"); - assert(fnSrc.includes("batchState.skippedTasks"), "computeOrchSummaryCounts: subtracts skippedTasks"); - assert(fnSrc.includes('status === "stalled"'), "computeOrchSummaryCounts: checks stalled status"); - assert(fnSrc.includes('status === "running"'), "computeOrchSummaryCounts: checks running status"); -} + { + const fnSrc = extractFunction(source, "formatElapsedTime"); + assert(fnSrc.includes("startMs <= 0"), "formatElapsedTime: handles startMs <= 0"); + assert(fnSrc.includes("elapsed < 0"), "formatElapsedTime: handles negative elapsed"); + assert(fnSrc.includes("3600"), "formatElapsedTime: has hour calculation"); + } -{ - const fnSrc = extractFunction(source, "formatElapsedTime"); - assert(fnSrc.includes("startMs <= 0"), "formatElapsedTime: handles startMs <= 0"); - assert(fnSrc.includes("elapsed < 0"), "formatElapsedTime: handles negative elapsed"); - assert(fnSrc.includes("3600"), "formatElapsedTime: has hour calculation"); -} + { + const fnSrc = extractFunction(source, "computeTransitiveDependents"); + assert(fnSrc.includes("queue.shift()"), "computeTransitiveDependents: uses BFS (shift)"); + assert(fnSrc.includes("sort()"), "computeTransitiveDependents: deterministic sort"); + assert( + fnSrc.includes("failedTaskIds.has(dep)"), + "computeTransitiveDependents: skips failed tasks", + ); + } -{ - const fnSrc = extractFunction(source, "computeTransitiveDependents"); - assert(fnSrc.includes("queue.shift()"), "computeTransitiveDependents: uses BFS (shift)"); - assert(fnSrc.includes("sort()"), "computeTransitiveDependents: deterministic sort"); - assert(fnSrc.includes("failedTaskIds.has(dep)"), "computeTransitiveDependents: skips failed tasks"); -} + { + const fnSrc = extractFunction(source, "buildDashboardViewModel"); + assert( + fnSrc.includes("laneNumber - b.laneNumber"), + "buildDashboardViewModel: sorts by laneNumber", + ); + assert( + fnSrc.includes("lane.laneSessionId"), + "buildDashboardViewModel: uses laneSessionId from allocation", + ); + assert(fnSrc.includes("failurePolicy"), "buildDashboardViewModel: includes failurePolicy"); + // TP-170: Verify wave-aware stale monitor detection + assert( + fnSrc.includes("monitorIsFresh"), + "buildDashboardViewModel: detects stale monitor data (TP-170)", + ); + // TP-170: Verify TOCTOU guard (lane.sessionAlive check when snap.status === running) + assert( + fnSrc.includes("lane.sessionAlive") && fnSrc.includes('"running" : "failed"'), + "buildDashboardViewModel: TOCTOU guard for dead session (TP-170)", + ); + // TP-170: Verify session name reconciliation via allocation index + assert( + fnSrc.includes("allocatedByLaneNumber"), + "buildDashboardViewModel: reconciles lane identity from allocation (TP-170)", + ); + } -{ - const fnSrc = extractFunction(source, "buildDashboardViewModel"); - assert(fnSrc.includes("laneNumber - b.laneNumber"), "buildDashboardViewModel: sorts by laneNumber"); - assert(fnSrc.includes("lane.laneSessionId"), "buildDashboardViewModel: uses laneSessionId from allocation"); - assert(fnSrc.includes("failurePolicy"), "buildDashboardViewModel: includes failurePolicy"); - // TP-170: Verify wave-aware stale monitor detection - assert(fnSrc.includes("monitorIsFresh"), "buildDashboardViewModel: detects stale monitor data (TP-170)"); - // TP-170: Verify TOCTOU guard (lane.sessionAlive check when snap.status === running) - assert(fnSrc.includes("lane.sessionAlive") && fnSrc.includes('"running" : "failed"'), "buildDashboardViewModel: TOCTOU guard for dead session (TP-170)"); - // TP-170: Verify session name reconciliation via allocation index - assert(fnSrc.includes("allocatedByLaneNumber"), "buildDashboardViewModel: reconciles lane identity from allocation (TP-170)"); -} + { + // TP-170: Verify renderLaneCard improvements + const fnSrc = extractFunction(source, "renderLaneCard"); + assert( + fnSrc.includes("starting..."), + "renderLaneCard: shows 'starting...' instead of 'waiting for data' (TP-170)", + ); + assert( + fnSrc.includes("session ended"), + "renderLaneCard: softened 'session dead' to 'session ended' (TP-170)", + ); + assert( + fnSrc.includes("no status data"), + "renderLaneCard: distinguishes dead session no-data from startup (TP-170)", + ); + } -{ - // TP-170: Verify renderLaneCard improvements - const fnSrc = extractFunction(source, "renderLaneCard"); - assert(fnSrc.includes("starting..."), "renderLaneCard: shows 'starting...' instead of 'waiting for data' (TP-170)"); - assert(fnSrc.includes("session ended"), "renderLaneCard: softened 'session dead' to 'session ended' (TP-170)"); - assert(fnSrc.includes("no status data"), "renderLaneCard: distinguishes dead session no-data from startup (TP-170)"); -} + // ── Helpers ────────────────────────────────────────────────────────── + + function freshBatchState(overrides: any = {}): any { + return { + phase: "idle", + batchId: "", + pauseSignal: { paused: false }, + waveResults: [], + currentWaveIndex: -1, + totalWaves: 0, + blockedTaskIds: new Set(), + startedAt: 0, + endedAt: null, + totalTasks: 0, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + errors: [], + currentLanes: [], + dependencyGraph: null, + ...overrides, + }; + } -// ── Helpers ────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════ + // 7.1: computeOrchSummaryCounts + // ═══════════════════════════════════════════════════════════════════════ -function freshBatchState(overrides: any = {}): any { - return { - phase: "idle", - batchId: "", - pauseSignal: { paused: false }, - waveResults: [], - currentWaveIndex: -1, - totalWaves: 0, - blockedTaskIds: new Set(), - startedAt: 0, - endedAt: null, - totalTasks: 0, - succeededTasks: 0, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - errors: [], - currentLanes: [], - dependencyGraph: null, - ...overrides, - }; -} + console.log("\n─── 7.1: computeOrchSummaryCounts ───"); -// ═══════════════════════════════════════════════════════════════════════ -// 7.1: computeOrchSummaryCounts -// ═══════════════════════════════════════════════════════════════════════ + { + console.log(" ▸ idle batch with no tasks"); + const result = computeOrchSummaryCounts(freshBatchState()); + assertEqual(result.completed, 0, "completed=0"); + assertEqual(result.running, 0, "running=0"); + assertEqual(result.queued, 0, "queued=0"); + assertEqual(result.failed, 0, "failed=0"); + assertEqual(result.blocked, 0, "blocked=0"); + assertEqual(result.stalled, 0, "stalled=0"); + assertEqual(result.total, 0, "total=0"); + } -console.log("\n─── 7.1: computeOrchSummaryCounts ───"); - -{ - console.log(" ▸ idle batch with no tasks"); - const result = computeOrchSummaryCounts(freshBatchState()); - assertEqual(result.completed, 0, "completed=0"); - assertEqual(result.running, 0, "running=0"); - assertEqual(result.queued, 0, "queued=0"); - assertEqual(result.failed, 0, "failed=0"); - assertEqual(result.blocked, 0, "blocked=0"); - assertEqual(result.stalled, 0, "stalled=0"); - assertEqual(result.total, 0, "total=0"); -} + { + console.log(" ▸ batch with succeeded/failed/blocked tasks, no monitor"); + const batch = freshBatchState({ + totalTasks: 10, + succeededTasks: 5, + failedTasks: 2, + blockedTasks: 1, + }); + const result = computeOrchSummaryCounts(batch); + assertEqual(result.completed, 5, "completed=5"); + assertEqual(result.failed, 2, "failed=2"); + assertEqual(result.blocked, 1, "blocked=1"); + assertEqual(result.queued, 2, "queued=2 (10-5-2-1-0-0-0)"); + assertEqual(result.total, 10, "total=10"); + assertEqual(result.running, 0, "running=0 (no monitor)"); + assertEqual(result.stalled, 0, "stalled=0 (no monitor)"); + } -{ - console.log(" ▸ batch with succeeded/failed/blocked tasks, no monitor"); - const batch = freshBatchState({ totalTasks: 10, succeededTasks: 5, failedTasks: 2, blockedTasks: 1 }); - const result = computeOrchSummaryCounts(batch); - assertEqual(result.completed, 5, "completed=5"); - assertEqual(result.failed, 2, "failed=2"); - assertEqual(result.blocked, 1, "blocked=1"); - assertEqual(result.queued, 2, "queued=2 (10-5-2-1-0-0-0)"); - assertEqual(result.total, 10, "total=10"); - assertEqual(result.running, 0, "running=0 (no monitor)"); - assertEqual(result.stalled, 0, "stalled=0 (no monitor)"); -} + { + console.log(" ▸ batch with live monitor data — running and stalled"); + const batch = freshBatchState({ totalTasks: 4, succeededTasks: 1 }); + const monitor = { + lanes: [ + { currentTaskSnapshot: { status: "running" } }, + { currentTaskSnapshot: { status: "stalled" } }, + ], + }; + const result = computeOrchSummaryCounts(batch, monitor); + assertEqual(result.running, 1, "running=1"); + assertEqual(result.stalled, 1, "stalled=1"); + assertEqual(result.completed, 1, "completed=1"); + assertEqual(result.queued, 1, "queued=1 (4-1-0-0-1-1-0)"); + } -{ - console.log(" ▸ batch with live monitor data — running and stalled"); - const batch = freshBatchState({ totalTasks: 4, succeededTasks: 1 }); - const monitor = { - lanes: [ - { currentTaskSnapshot: { status: "running" } }, - { currentTaskSnapshot: { status: "stalled" } }, - ], - }; - const result = computeOrchSummaryCounts(batch, monitor); - assertEqual(result.running, 1, "running=1"); - assertEqual(result.stalled, 1, "stalled=1"); - assertEqual(result.completed, 1, "completed=1"); - assertEqual(result.queued, 1, "queued=1 (4-1-0-0-1-1-0)"); -} + { + console.log(" ▸ queued cannot go negative"); + const batch = freshBatchState({ totalTasks: 2, succeededTasks: 2 }); + const result = computeOrchSummaryCounts(batch); + assertEqual(result.queued, 0, "queued=0"); + } -{ - console.log(" ▸ queued cannot go negative"); - const batch = freshBatchState({ totalTasks: 2, succeededTasks: 2 }); - const result = computeOrchSummaryCounts(batch); - assertEqual(result.queued, 0, "queued=0"); -} + { + console.log(" ▸ skipped tasks reduce queued count"); + const batch = freshBatchState({ totalTasks: 5, succeededTasks: 2, skippedTasks: 1 }); + const result = computeOrchSummaryCounts(batch); + assertEqual(result.queued, 2, "queued=2 (5-2-0-0-0-0-1)"); + } -{ - console.log(" ▸ skipped tasks reduce queued count"); - const batch = freshBatchState({ totalTasks: 5, succeededTasks: 2, skippedTasks: 1 }); - const result = computeOrchSummaryCounts(batch); - assertEqual(result.queued, 2, "queued=2 (5-2-0-0-0-0-1)"); -} + // ═══════════════════════════════════════════════════════════════════════ + // 7.2: formatElapsedTime + // ═══════════════════════════════════════════════════════════════════════ -// ═══════════════════════════════════════════════════════════════════════ -// 7.2: formatElapsedTime -// ═══════════════════════════════════════════════════════════════════════ + console.log("\n─── 7.2: formatElapsedTime ───"); -console.log("\n─── 7.2: formatElapsedTime ───"); + { + console.log(" ▸ zero elapsed (startMs=0)"); + assertEqual(formatElapsedTime(0), "0s", "startMs=0 → '0s'"); + } -{ - console.log(" ▸ zero elapsed (startMs=0)"); - assertEqual(formatElapsedTime(0), "0s", "startMs=0 → '0s'"); -} + { + console.log(" ▸ negative elapsed (endMs < startMs)"); + assertEqual(formatElapsedTime(1000, 500), "0s", "negative → '0s'"); + } -{ - console.log(" ▸ negative elapsed (endMs < startMs)"); - assertEqual(formatElapsedTime(1000, 500), "0s", "negative → '0s'"); -} + { + console.log(" ▸ seconds only"); + assertEqual(formatElapsedTime(1000, 1000 + 45_000), "45s", "45s"); + } -{ - console.log(" ▸ seconds only"); - assertEqual(formatElapsedTime(1000, 1000 + 45_000), "45s", "45s"); -} + { + console.log(" ▸ minutes and seconds"); + assertEqual(formatElapsedTime(1000, 1000 + 134_000), "2m 14s", "2m 14s"); + } -{ - console.log(" ▸ minutes and seconds"); - assertEqual(formatElapsedTime(1000, 1000 + 134_000), "2m 14s", "2m 14s"); -} + { + console.log(" ▸ hours, minutes, seconds"); + assertEqual(formatElapsedTime(1000, 1000 + 3_930_000), "1h 5m 30s", "1h 5m 30s"); + } -{ - console.log(" ▸ hours, minutes, seconds"); - assertEqual(formatElapsedTime(1000, 1000 + 3_930_000), "1h 5m 30s", "1h 5m 30s"); -} + { + console.log(" ▸ exact minute boundary"); + assertEqual(formatElapsedTime(1000, 1000 + 60_000), "1m 0s", "1m 0s"); + } -{ - console.log(" ▸ exact minute boundary"); - assertEqual(formatElapsedTime(1000, 1000 + 60_000), "1m 0s", "1m 0s"); -} + { + console.log(" ▸ open-ended (no endMs) uses Date.now — returns string"); + const result = formatElapsedTime(Date.now() - 5000); + assert(result.endsWith("s"), `open-ended returns string ending in 's': got '${result}'`); + } -{ - console.log(" ▸ open-ended (no endMs) uses Date.now — returns string"); - const result = formatElapsedTime(Date.now() - 5000); - assert(result.endsWith("s"), `open-ended returns string ending in 's': got '${result}'`); -} + { + console.log(" ▸ exact zero seconds"); + assertEqual(formatElapsedTime(1000, 1000), "0s", "0ms elapsed → '0s'"); + } -{ - console.log(" ▸ exact zero seconds"); - assertEqual(formatElapsedTime(1000, 1000), "0s", "0ms elapsed → '0s'"); -} + // ═══════════════════════════════════════════════════════════════════════ + // 7.3: buildDashboardViewModel + // ═══════════════════════════════════════════════════════════════════════ -// ═══════════════════════════════════════════════════════════════════════ -// 7.3: buildDashboardViewModel -// ═══════════════════════════════════════════════════════════════════════ + console.log("\n─── 7.3: buildDashboardViewModel ───"); -console.log("\n─── 7.3: buildDashboardViewModel ───"); - -{ - console.log(" ▸ idle state — no batch"); - const vm = buildDashboardViewModel(freshBatchState()); - assertEqual(vm.phase, "idle", "phase=idle"); - assertEqual(vm.batchId, "", "batchId empty"); - assertEqual(vm.waveProgress, "0/0", "waveProgress=0/0"); - assertEqual(vm.laneCards.length, 0, "no lane cards"); - assertEqual(vm.attachHint, "", "no attach hint"); - assertEqual(vm.summary.total, 0, "total=0"); - assertEqual(vm.failurePolicy, null, "no failure policy"); -} + { + console.log(" ▸ idle state — no batch"); + const vm = buildDashboardViewModel(freshBatchState()); + assertEqual(vm.phase, "idle", "phase=idle"); + assertEqual(vm.batchId, "", "batchId empty"); + assertEqual(vm.waveProgress, "0/0", "waveProgress=0/0"); + assertEqual(vm.laneCards.length, 0, "no lane cards"); + assertEqual(vm.attachHint, "", "no attach hint"); + assertEqual(vm.summary.total, 0, "total=0"); + assertEqual(vm.failurePolicy, null, "no failure policy"); + } -{ - console.log(" ▸ planning state"); - const batch = freshBatchState({ - phase: "planning", - batchId: "20260309T120000", - totalWaves: 3, - totalTasks: 12, - currentWaveIndex: 0, - startedAt: Date.now() - 5000, - }); - const vm = buildDashboardViewModel(batch); - assertEqual(vm.phase, "planning", "phase=planning"); - assertEqual(vm.batchId, "20260309T120000", "batchId set"); - assertEqual(vm.waveProgress, "1/3", "waveProgress=1/3"); - assertEqual(vm.summary.total, 12, "total=12"); -} + { + console.log(" ▸ planning state"); + const batch = freshBatchState({ + phase: "planning", + batchId: "20260309T120000", + totalWaves: 3, + totalTasks: 12, + currentWaveIndex: 0, + startedAt: Date.now() - 5000, + }); + const vm = buildDashboardViewModel(batch); + assertEqual(vm.phase, "planning", "phase=planning"); + assertEqual(vm.batchId, "20260309T120000", "batchId set"); + assertEqual(vm.waveProgress, "1/3", "waveProgress=1/3"); + assertEqual(vm.summary.total, 12, "total=12"); + } -{ - console.log(" ▸ executing with monitor data — sorted lanes"); - const batch = freshBatchState({ - phase: "executing", - batchId: "20260309T120000", - totalWaves: 2, - totalTasks: 4, - succeededTasks: 1, - currentWaveIndex: 0, - startedAt: Date.now() - 120_000, - }); - const monitor = { - lanes: [ - { - laneNumber: 2, - laneId: "lane-2", - sessionName: "orch-lane-2", - sessionAlive: true, - currentTaskId: "TASK-002", - currentTaskSnapshot: { status: "running", currentStepName: "Write Tests", totalChecked: 3, totalItems: 8 }, - completedTasks: [], - failedTasks: [], - remainingTasks: ["TASK-003"], - }, - { - laneNumber: 1, - laneId: "lane-1", - sessionName: "orch-lane-1", - sessionAlive: true, - currentTaskId: "TASK-001", - currentTaskSnapshot: { status: "running", currentStepName: "Build Service", totalChecked: 5, totalItems: 10 }, - completedTasks: ["TASK-000"], - failedTasks: [], - remainingTasks: [], - }, - ], - }; - const vm = buildDashboardViewModel(batch, monitor); - assertEqual(vm.laneCards.length, 2, "2 lane cards"); - assertEqual(vm.laneCards[0].laneNumber, 1, "sorted: first=lane 1"); - assertEqual(vm.laneCards[1].laneNumber, 2, "sorted: second=lane 2"); - assertEqual(vm.laneCards[0].currentTaskId, "TASK-001", "lane 1 correct task"); - assertEqual(vm.laneCards[0].status, "running", "lane 1 running"); - assert(vm.attachHint.includes("orch-lane-"), "attach hint has session name"); -} + { + console.log(" ▸ executing with monitor data — sorted lanes"); + const batch = freshBatchState({ + phase: "executing", + batchId: "20260309T120000", + totalWaves: 2, + totalTasks: 4, + succeededTasks: 1, + currentWaveIndex: 0, + startedAt: Date.now() - 120_000, + }); + const monitor = { + lanes: [ + { + laneNumber: 2, + laneId: "lane-2", + sessionName: "orch-lane-2", + sessionAlive: true, + currentTaskId: "TASK-002", + currentTaskSnapshot: { + status: "running", + currentStepName: "Write Tests", + totalChecked: 3, + totalItems: 8, + }, + completedTasks: [], + failedTasks: [], + remainingTasks: ["TASK-003"], + }, + { + laneNumber: 1, + laneId: "lane-1", + sessionName: "orch-lane-1", + sessionAlive: true, + currentTaskId: "TASK-001", + currentTaskSnapshot: { + status: "running", + currentStepName: "Build Service", + totalChecked: 5, + totalItems: 10, + }, + completedTasks: ["TASK-000"], + failedTasks: [], + remainingTasks: [], + }, + ], + }; + const vm = buildDashboardViewModel(batch, monitor); + assertEqual(vm.laneCards.length, 2, "2 lane cards"); + assertEqual(vm.laneCards[0].laneNumber, 1, "sorted: first=lane 1"); + assertEqual(vm.laneCards[1].laneNumber, 2, "sorted: second=lane 2"); + assertEqual(vm.laneCards[0].currentTaskId, "TASK-001", "lane 1 correct task"); + assertEqual(vm.laneCards[0].status, "running", "lane 1 running"); + assert(vm.attachHint.includes("orch-lane-"), "attach hint has session name"); + } -{ - console.log(" ▸ executing without monitor — falls back to currentLanes"); - const batch = freshBatchState({ - phase: "executing", - batchId: "20260309T120000", - totalWaves: 1, - totalTasks: 2, - currentWaveIndex: 0, - startedAt: Date.now() - 10_000, - currentLanes: [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - tasks: [{ taskId: "T-001" }], - }, - ], - }); - const vm = buildDashboardViewModel(batch, null); - assertEqual(vm.laneCards.length, 1, "1 lane card from currentLanes"); - assertEqual(vm.laneCards[0].sessionName, "orch-lane-1", "session from allocation"); - assertEqual(vm.laneCards[0].status, "running", "assumed running"); -} + { + console.log(" ▸ executing without monitor — falls back to currentLanes"); + const batch = freshBatchState({ + phase: "executing", + batchId: "20260309T120000", + totalWaves: 1, + totalTasks: 2, + currentWaveIndex: 0, + startedAt: Date.now() - 10_000, + currentLanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + tasks: [{ taskId: "T-001" }], + }, + ], + }); + const vm = buildDashboardViewModel(batch, null); + assertEqual(vm.laneCards.length, 1, "1 lane card from currentLanes"); + assertEqual(vm.laneCards[0].sessionName, "orch-lane-1", "session from allocation"); + assertEqual(vm.laneCards[0].status, "running", "assumed running"); + } -{ - console.log(" ▸ stopped state with failure policy"); - const batch = freshBatchState({ - phase: "stopped", - batchId: "20260309T120000", - totalWaves: 3, - totalTasks: 10, - currentWaveIndex: 1, - startedAt: 1000, - endedAt: 61_000, - succeededTasks: 3, - failedTasks: 1, - waveResults: [ - { stoppedEarly: false, policyApplied: null }, - { stoppedEarly: true, policyApplied: "stop-wave" }, - ], - }); - const vm = buildDashboardViewModel(batch); - assertEqual(vm.phase, "stopped", "phase=stopped"); - assertEqual(vm.failurePolicy, "stop-wave", "failurePolicy=stop-wave"); - assertEqual(vm.elapsed, "1m 0s", "elapsed computed from start/end"); -} + { + console.log(" ▸ stopped state with failure policy"); + const batch = freshBatchState({ + phase: "stopped", + batchId: "20260309T120000", + totalWaves: 3, + totalTasks: 10, + currentWaveIndex: 1, + startedAt: 1000, + endedAt: 61_000, + succeededTasks: 3, + failedTasks: 1, + waveResults: [ + { stoppedEarly: false, policyApplied: null }, + { stoppedEarly: true, policyApplied: "stop-wave" }, + ], + }); + const vm = buildDashboardViewModel(batch); + assertEqual(vm.phase, "stopped", "phase=stopped"); + assertEqual(vm.failurePolicy, "stop-wave", "failurePolicy=stop-wave"); + assertEqual(vm.elapsed, "1m 0s", "elapsed computed from start/end"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 7.3b: TP-170 — Wave-Aware Lane Display -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.3b: TP-170 — Wave-Aware Lane Display + // ═══════════════════════════════════════════════════════════════════════ -console.log("\n─── 7.3b: TP-170 Wave-Aware Lane Display ───"); - -{ - console.log(" ▸ stale monitor from prior wave → falls back to currentLanes allocation"); - // Scenario: wave 1 completed (lanes 1,2), wave 2 started (lanes 3,4). - // monitorState still has wave 1 lanes, batchState.currentLanes has wave 2. - const batch = freshBatchState({ - phase: "executing", - batchId: "20260412T010000", - totalWaves: 2, - totalTasks: 4, - succeededTasks: 2, - currentWaveIndex: 1, - startedAt: Date.now() - 120_000, - currentLanes: [ - { - laneNumber: 3, - laneId: "lane-3", - laneSessionId: "orch-henry-lane-3", - tasks: [{ taskId: "T-003" }], - }, - { - laneNumber: 4, - laneId: "lane-4", - laneSessionId: "orch-henry-lane-4", - tasks: [{ taskId: "T-004" }], - }, - ], - }); - const staleMonitor = { - lanes: [ - { - laneNumber: 1, - laneId: "lane-1", - sessionName: "orch-henry-lane-1", - sessionAlive: false, - currentTaskId: null, - currentTaskSnapshot: null, - completedTasks: ["T-001"], - failedTasks: [], - remainingTasks: [], - }, - { - laneNumber: 2, - laneId: "lane-2", - sessionName: "orch-henry-lane-2", - sessionAlive: false, - currentTaskId: null, - currentTaskSnapshot: null, - completedTasks: ["T-002"], - failedTasks: [], - remainingTasks: [], - }, - ], - }; - const vm = buildDashboardViewModel(batch, staleMonitor); - // Should fall back to wave 2 allocation, NOT show stale wave 1 lanes - assertEqual(vm.laneCards.length, 2, "uses allocation lanes, not stale monitor"); - assertEqual(vm.laneCards[0].laneNumber, 3, "lane 3 from wave 2"); - assertEqual(vm.laneCards[1].laneNumber, 4, "lane 4 from wave 2"); - assertEqual(vm.laneCards[0].sessionName, "orch-henry-lane-3", "session from allocation"); - assertEqual(vm.laneCards[0].status, "running", "assumed running during allocation"); -} + console.log("\n─── 7.3b: TP-170 Wave-Aware Lane Display ───"); -{ - console.log(" ▸ TOCTOU guard: dead session + running snapshot → status=failed"); - // Scenario: task snapshot says running (from lane snapshot file lag) - // but lane-level sessionAlive is false (PID confirmed dead). - const batch = freshBatchState({ - phase: "executing", - batchId: "20260412T010000", - totalWaves: 1, - totalTasks: 1, - currentWaveIndex: 0, - startedAt: Date.now() - 60_000, - currentLanes: [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-henry-lane-1", - tasks: [{ taskId: "T-001" }], - }, - ], - }); - const monitor = { - lanes: [ - { - laneNumber: 1, - laneId: "lane-1", - sessionName: "orch-henry-lane-1", - sessionAlive: false, // PID dead - currentTaskId: "T-001", - currentTaskSnapshot: { status: "running", currentStepName: "Implement", totalChecked: 3, totalItems: 8 }, - completedTasks: [], - failedTasks: [], - remainingTasks: [], - }, - ], - }; - const vm = buildDashboardViewModel(batch, monitor); - assertEqual(vm.laneCards[0].status, "failed", "TOCTOU: dead session → failed, not running"); - assertEqual(vm.laneCards[0].sessionAlive, false, "sessionAlive=false propagated"); -} + { + console.log(" ▸ stale monitor from prior wave → falls back to currentLanes allocation"); + // Scenario: wave 1 completed (lanes 1,2), wave 2 started (lanes 3,4). + // monitorState still has wave 1 lanes, batchState.currentLanes has wave 2. + const batch = freshBatchState({ + phase: "executing", + batchId: "20260412T010000", + totalWaves: 2, + totalTasks: 4, + succeededTasks: 2, + currentWaveIndex: 1, + startedAt: Date.now() - 120_000, + currentLanes: [ + { + laneNumber: 3, + laneId: "lane-3", + laneSessionId: "orch-henry-lane-3", + tasks: [{ taskId: "T-003" }], + }, + { + laneNumber: 4, + laneId: "lane-4", + laneSessionId: "orch-henry-lane-4", + tasks: [{ taskId: "T-004" }], + }, + ], + }); + const staleMonitor = { + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + sessionName: "orch-henry-lane-1", + sessionAlive: false, + currentTaskId: null, + currentTaskSnapshot: null, + completedTasks: ["T-001"], + failedTasks: [], + remainingTasks: [], + }, + { + laneNumber: 2, + laneId: "lane-2", + sessionName: "orch-henry-lane-2", + sessionAlive: false, + currentTaskId: null, + currentTaskSnapshot: null, + completedTasks: ["T-002"], + failedTasks: [], + remainingTasks: [], + }, + ], + }; + const vm = buildDashboardViewModel(batch, staleMonitor); + // Should fall back to wave 2 allocation, NOT show stale wave 1 lanes + assertEqual(vm.laneCards.length, 2, "uses allocation lanes, not stale monitor"); + assertEqual(vm.laneCards[0].laneNumber, 3, "lane 3 from wave 2"); + assertEqual(vm.laneCards[1].laneNumber, 4, "lane 4 from wave 2"); + assertEqual(vm.laneCards[0].sessionName, "orch-henry-lane-3", "session from allocation"); + assertEqual(vm.laneCards[0].status, "running", "assumed running during allocation"); + } -{ - console.log(" ▸ workspace identity reconciliation: alloc session name overrides monitor"); - const batch = freshBatchState({ - phase: "executing", - batchId: "20260412T010000", - totalWaves: 1, - totalTasks: 1, - currentWaveIndex: 0, - startedAt: Date.now() - 30_000, - currentLanes: [ - { - laneNumber: 1, - laneId: "api-lane-1", - laneSessionId: "orch-henry-api-lane-1", - tasks: [{ taskId: "T-001" }], - }, - ], - }); - const monitor = { - lanes: [ - { - laneNumber: 1, - laneId: "lane-1", - sessionName: "orch-henry-lane-1-worker", // stale registry name - sessionAlive: true, - currentTaskId: "T-001", - currentTaskSnapshot: { status: "running", currentStepName: "Step 1", totalChecked: 2, totalItems: 5 }, - completedTasks: [], - failedTasks: [], - remainingTasks: [], - }, - ], - }; - const vm = buildDashboardViewModel(batch, monitor); - assertEqual(vm.laneCards[0].sessionName, "orch-henry-api-lane-1", "session name reconciled from allocation"); - assertEqual(vm.laneCards[0].laneId, "api-lane-1", "laneId reconciled from allocation"); - assertEqual(vm.laneCards[0].status, "running", "status=running (session alive)"); -} + { + console.log(" ▸ TOCTOU guard: dead session + running snapshot → status=failed"); + // Scenario: task snapshot says running (from lane snapshot file lag) + // but lane-level sessionAlive is false (PID confirmed dead). + const batch = freshBatchState({ + phase: "executing", + batchId: "20260412T010000", + totalWaves: 1, + totalTasks: 1, + currentWaveIndex: 0, + startedAt: Date.now() - 60_000, + currentLanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-henry-lane-1", + tasks: [{ taskId: "T-001" }], + }, + ], + }); + const monitor = { + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + sessionName: "orch-henry-lane-1", + sessionAlive: false, // PID dead + currentTaskId: "T-001", + currentTaskSnapshot: { + status: "running", + currentStepName: "Implement", + totalChecked: 3, + totalItems: 8, + }, + completedTasks: [], + failedTasks: [], + remainingTasks: [], + }, + ], + }; + const vm = buildDashboardViewModel(batch, monitor); + assertEqual(vm.laneCards[0].status, "failed", "TOCTOU: dead session → failed, not running"); + assertEqual(vm.laneCards[0].sessionAlive, false, "sessionAlive=false propagated"); + } -{ - console.log(" ▸ startup lane with no registry entry → not failed"); - // Lane just allocated, no monitor data yet (monitorState is null). - // Widget should show allocation fallback, not "failed". - const batch = freshBatchState({ - phase: "executing", - batchId: "20260412T010000", - totalWaves: 1, - totalTasks: 2, - currentWaveIndex: 0, - startedAt: Date.now() - 5_000, - currentLanes: [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-henry-lane-1", - tasks: [{ taskId: "T-001" }, { taskId: "T-002" }], - }, - ], - }); - const vm = buildDashboardViewModel(batch, null); - assertEqual(vm.laneCards.length, 1, "1 lane card from allocation"); - assertEqual(vm.laneCards[0].status, "running", "assumed running, not failed"); - assertEqual(vm.laneCards[0].sessionAlive, true, "assumed alive during allocation"); - assertEqual(vm.laneCards[0].totalLaneTasks, 2, "totalLaneTasks from allocation"); -} + { + console.log(" ▸ workspace identity reconciliation: alloc session name overrides monitor"); + const batch = freshBatchState({ + phase: "executing", + batchId: "20260412T010000", + totalWaves: 1, + totalTasks: 1, + currentWaveIndex: 0, + startedAt: Date.now() - 30_000, + currentLanes: [ + { + laneNumber: 1, + laneId: "api-lane-1", + laneSessionId: "orch-henry-api-lane-1", + tasks: [{ taskId: "T-001" }], + }, + ], + }); + const monitor = { + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + sessionName: "orch-henry-lane-1-worker", // stale registry name + sessionAlive: true, + currentTaskId: "T-001", + currentTaskSnapshot: { + status: "running", + currentStepName: "Step 1", + totalChecked: 2, + totalItems: 5, + }, + completedTasks: [], + failedTasks: [], + remainingTasks: [], + }, + ], + }; + const vm = buildDashboardViewModel(batch, monitor); + assertEqual( + vm.laneCards[0].sessionName, + "orch-henry-api-lane-1", + "session name reconciled from allocation", + ); + assertEqual(vm.laneCards[0].laneId, "api-lane-1", "laneId reconciled from allocation"); + assertEqual(vm.laneCards[0].status, "running", "status=running (session alive)"); + } -{ - console.log(" ▸ completed wave lanes with no currentLanes → still shows monitor data"); - // Terminal phase (completed/failed/stopped): no currentLanes, monitor has final state. - // Should use monitor data since currentLanes is empty (monitorIsFresh=true). - const batch = freshBatchState({ - phase: "completed", - batchId: "20260412T010000", - totalWaves: 1, - totalTasks: 2, - succeededTasks: 2, - currentWaveIndex: 0, - startedAt: Date.now() - 300_000, - endedAt: Date.now() - 10_000, - }); - const monitor = { - lanes: [ - { - laneNumber: 1, - laneId: "lane-1", - sessionName: "orch-henry-lane-1", - sessionAlive: false, - currentTaskId: null, - currentTaskSnapshot: null, - completedTasks: ["T-001", "T-002"], - failedTasks: [], - remainingTasks: [], - }, - ], - }; - const vm = buildDashboardViewModel(batch, monitor); - assertEqual(vm.laneCards.length, 1, "terminal phase: monitor lanes used"); - assertEqual(vm.laneCards[0].status, "succeeded", "completed lane shows succeeded"); - assertEqual(vm.laneCards[0].completedTasks, 2, "2 completed tasks"); -} + { + console.log(" ▸ startup lane with no registry entry → not failed"); + // Lane just allocated, no monitor data yet (monitorState is null). + // Widget should show allocation fallback, not "failed". + const batch = freshBatchState({ + phase: "executing", + batchId: "20260412T010000", + totalWaves: 1, + totalTasks: 2, + currentWaveIndex: 0, + startedAt: Date.now() - 5_000, + currentLanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-henry-lane-1", + tasks: [{ taskId: "T-001" }, { taskId: "T-002" }], + }, + ], + }); + const vm = buildDashboardViewModel(batch, null); + assertEqual(vm.laneCards.length, 1, "1 lane card from allocation"); + assertEqual(vm.laneCards[0].status, "running", "assumed running, not failed"); + assertEqual(vm.laneCards[0].sessionAlive, true, "assumed alive during allocation"); + assertEqual(vm.laneCards[0].totalLaneTasks, 2, "totalLaneTasks from allocation"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 7.4: computeTransitiveDependents -// ═══════════════════════════════════════════════════════════════════════ + { + console.log(" ▸ completed wave lanes with no currentLanes → still shows monitor data"); + // Terminal phase (completed/failed/stopped): no currentLanes, monitor has final state. + // Should use monitor data since currentLanes is empty (monitorIsFresh=true). + const batch = freshBatchState({ + phase: "completed", + batchId: "20260412T010000", + totalWaves: 1, + totalTasks: 2, + succeededTasks: 2, + currentWaveIndex: 0, + startedAt: Date.now() - 300_000, + endedAt: Date.now() - 10_000, + }); + const monitor = { + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + sessionName: "orch-henry-lane-1", + sessionAlive: false, + currentTaskId: null, + currentTaskSnapshot: null, + completedTasks: ["T-001", "T-002"], + failedTasks: [], + remainingTasks: [], + }, + ], + }; + const vm = buildDashboardViewModel(batch, monitor); + assertEqual(vm.laneCards.length, 1, "terminal phase: monitor lanes used"); + assertEqual(vm.laneCards[0].status, "succeeded", "completed lane shows succeeded"); + assertEqual(vm.laneCards[0].completedTasks, 2, "2 completed tasks"); + } -console.log("\n─── 7.4: computeTransitiveDependents ───"); + // ═══════════════════════════════════════════════════════════════════════ + // 7.4: computeTransitiveDependents + // ═══════════════════════════════════════════════════════════════════════ -{ - console.log(" ▸ no dependents — empty result"); - const graph = { dependents: new Map() }; - const result = computeTransitiveDependents(new Set(["A"]), graph); - assertEqual(result.size, 0, "no dependents of A"); -} + console.log("\n─── 7.4: computeTransitiveDependents ───"); -{ - console.log(" ▸ single chain: A→B→C (A fails → B, C blocked)"); - const graph = { dependents: new Map([["A", ["B"]], ["B", ["C"]]]) }; - const result = computeTransitiveDependents(new Set(["A"]), graph); - assertEqual(result.size, 2, "2 blocked tasks"); - assert(result.has("B"), "B is blocked"); - assert(result.has("C"), "C is blocked (transitive)"); - assert(!result.has("A"), "A is not in blocked set"); -} + { + console.log(" ▸ no dependents — empty result"); + const graph = { dependents: new Map() }; + const result = computeTransitiveDependents(new Set(["A"]), graph); + assertEqual(result.size, 0, "no dependents of A"); + } -{ - console.log(" ▸ diamond: A→B, A→C, B→D, C→D (A fails → B, C, D blocked)"); - const graph = { - dependents: new Map([["A", ["B", "C"]], ["B", ["D"]], ["C", ["D"]]]), - }; - const result = computeTransitiveDependents(new Set(["A"]), graph); - assertEqual(result.size, 3, "3 blocked: B, C, D"); - assert(result.has("B"), "B blocked"); - assert(result.has("C"), "C blocked"); - assert(result.has("D"), "D blocked (transitive)"); -} + { + console.log(" ▸ single chain: A→B→C (A fails → B, C blocked)"); + const graph = { + dependents: new Map([ + ["A", ["B"]], + ["B", ["C"]], + ]), + }; + const result = computeTransitiveDependents(new Set(["A"]), graph); + assertEqual(result.size, 2, "2 blocked tasks"); + assert(result.has("B"), "B is blocked"); + assert(result.has("C"), "C is blocked (transitive)"); + assert(!result.has("A"), "A is not in blocked set"); + } -{ - console.log(" ▸ multiple failures: A and X both fail"); - const graph = { dependents: new Map([["A", ["B"]], ["X", ["Y"]]]) }; - const result = computeTransitiveDependents(new Set(["A", "X"]), graph); - assertEqual(result.size, 2, "2 blocked: B, Y"); - assert(result.has("B"), "B blocked by A"); - assert(result.has("Y"), "Y blocked by X"); -} + { + console.log(" ▸ diamond: A→B, A→C, B→D, C→D (A fails → B, C, D blocked)"); + const graph = { + dependents: new Map([ + ["A", ["B", "C"]], + ["B", ["D"]], + ["C", ["D"]], + ]), + }; + const result = computeTransitiveDependents(new Set(["A"]), graph); + assertEqual(result.size, 3, "3 blocked: B, C, D"); + assert(result.has("B"), "B blocked"); + assert(result.has("C"), "C blocked"); + assert(result.has("D"), "D blocked (transitive)"); + } -{ - console.log(" ▸ no duplicates in convergent graph"); - const graph = { - dependents: new Map([["A", ["B", "C"]], ["B", ["D"]], ["C", ["D"]]]), - }; - const result = computeTransitiveDependents(new Set(["A"]), graph); - assertEqual(result.size, 3, "exactly 3 unique (D not duplicated)"); -} + { + console.log(" ▸ multiple failures: A and X both fail"); + const graph = { + dependents: new Map([ + ["A", ["B"]], + ["X", ["Y"]], + ]), + }; + const result = computeTransitiveDependents(new Set(["A", "X"]), graph); + assertEqual(result.size, 2, "2 blocked: B, Y"); + assert(result.has("B"), "B blocked by A"); + assert(result.has("Y"), "Y blocked by X"); + } -{ - console.log(" ▸ failed task has no entry in dependents map"); - const graph = { dependents: new Map([["B", ["A"]]]) }; - const result = computeTransitiveDependents(new Set(["A"]), graph); - // A's entry doesn't exist in dependents → no one depends on A - assertEqual(result.size, 0, "0 blocked (A has no dependents)"); -} + { + console.log(" ▸ no duplicates in convergent graph"); + const graph = { + dependents: new Map([ + ["A", ["B", "C"]], + ["B", ["D"]], + ["C", ["D"]], + ]), + }; + const result = computeTransitiveDependents(new Set(["A"]), graph); + assertEqual(result.size, 3, "exactly 3 unique (D not duplicated)"); + } -{ - console.log(" ▸ empty failed set → empty result"); - const graph = { dependents: new Map([["A", ["B"]]]) }; - const result = computeTransitiveDependents(new Set(), graph); - assertEqual(result.size, 0, "nothing failed → nothing blocked"); -} + { + console.log(" ▸ failed task has no entry in dependents map"); + const graph = { dependents: new Map([["B", ["A"]]]) }; + const result = computeTransitiveDependents(new Set(["A"]), graph); + // A's entry doesn't exist in dependents → no one depends on A + assertEqual(result.size, 0, "0 blocked (A has no dependents)"); + } -// ═══════════════════════════════════════════════════════════════════════ -// Shared helper: strip TS annotations for extracted source evaluation -// ═══════════════════════════════════════════════════════════════════════ + { + console.log(" ▸ empty failed set → empty result"); + const graph = { dependents: new Map([["A", ["B"]]]) }; + const result = computeTransitiveDependents(new Set(), graph); + assertEqual(result.size, 0, "nothing failed → nothing blocked"); + } -/** - * Strip TypeScript type annotations from a function body so it can be - * evaluated as JavaScript via `new Function()`. - * - * Handles: parameter types, optional params (?:), return types, const types. - */ -function stripTypeAnnotations(src: string): string { - return src - // Optional parameter type annotations: (name?: Type) → (name) - .replace(/(\w+)\?\s*:\s*\w+/g, "$1") - // Parameter type annotations: (name: Type) → (name) - .replace(/(\w+)\s*:\s*(?:string|number|boolean|any|void|OrchestratorConfig)/g, "$1") - // Return type annotations: ): Type { → ) { - // Handles both primitives and custom types like SavedBranchResolution - .replace(/\)\s*,?\s*\n?\s*\)\s*:\s*\w+\s*\{/g, ")) {") - .replace(/\)\s*:\s*\w+\s*\{/g, ") {") - // const declarations with types: const x: Type = → const x = - .replace(/const\s+(\w+)\s*:\s*[^=]+=\s*/g, "const $1 = "); -} + // ═══════════════════════════════════════════════════════════════════════ + // Shared helper: strip TS annotations for extracted source evaluation + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Strip TypeScript type annotations from a function body so it can be + * evaluated as JavaScript via `new Function()`. + * + * Handles: parameter types, optional params (?:), return types, const types. + */ + function stripTypeAnnotations(src: string): string { + return ( + src + // Optional parameter type annotations: (name?: Type) → (name) + .replace(/(\w+)\?\s*:\s*\w+/g, "$1") + // Parameter type annotations: (name: Type) → (name) + .replace(/(\w+)\s*:\s*(?:string|number|boolean|any|void|OrchestratorConfig)/g, "$1") + // Return type annotations: ): Type { → ) { + // Handles both primitives and custom types like SavedBranchResolution + .replace(/\)\s*,?\s*\n?\s*\)\s*:\s*\w+\s*\{/g, ")) {") + .replace(/\)\s*:\s*\w+\s*\{/g, ") {") + // const declarations with types: const x: Type = → const x = + .replace(/const\s+(\w+)\s*:\s*[^=]+=\s*/g, "const $1 = ") + ); + } -// ═══════════════════════════════════════════════════════════════════════ -// 7.5: resolveWorktreeBasePath — extracted from production source -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.5: resolveWorktreeBasePath — extracted from production source + // ═══════════════════════════════════════════════════════════════════════ -// Extract the real function from source and create a callable. -// The production function takes (repoRoot, config) where config has -// shape { orchestrator: { worktree_location: string } }. + // Extract the real function from source and create a callable. + // The production function takes (repoRoot, config) where config has + // shape { orchestrator: { worktree_location: string } }. -const resolveWorktreeBasePathSource = extractFunction(source, "resolveWorktreeBasePath"); + const resolveWorktreeBasePathSource = extractFunction(source, "resolveWorktreeBasePath"); -// Inject `resolve` dependency (same as production uses from path module). -// stripTypeAnnotations removes TS annotations for eval compatibility. -const resolveWorktreeBasePathFn = new Function( - "resolve", - `return (${stripTypeAnnotations(resolveWorktreeBasePathSource) - .replace(/^function resolveWorktreeBasePath/, "function") - })`, -)(resolve) as (repoRoot: string, config: any) => string; + // Inject `resolve` dependency (same as production uses from path module). + // stripTypeAnnotations removes TS annotations for eval compatibility. + const resolveWorktreeBasePathFn = new Function( + "resolve", + `return (${stripTypeAnnotations(resolveWorktreeBasePathSource).replace( + /^function resolveWorktreeBasePath/, + "function", + )})`, + )(resolve) as (repoRoot: string, config: any) => string; -console.log("\n7.6 — resolveWorktreeBasePath (extracted from source)"); + console.log("\n7.6 — resolveWorktreeBasePath (extracted from source)"); -{ - const repoRoot = "/home/user/project"; + { + const repoRoot = "/home/user/project"; - // Config fixtures matching OrchestratorConfig shape - const siblingConfig = { orchestrator: { worktree_location: "sibling" } }; - const subdirConfig = { orchestrator: { worktree_location: "subdirectory" } }; - const unknownConfig = { orchestrator: { worktree_location: "future-mode" } }; + // Config fixtures matching OrchestratorConfig shape + const siblingConfig = { orchestrator: { worktree_location: "sibling" } }; + const subdirConfig = { orchestrator: { worktree_location: "subdirectory" } }; + const unknownConfig = { orchestrator: { worktree_location: "future-mode" } }; - { - console.log(" ▸ sibling mode returns parent of repoRoot"); - const result = resolveWorktreeBasePathFn(repoRoot, siblingConfig); - const expected = resolve(repoRoot, ".."); - assertEqual(result, expected, "sibling base path"); - } + { + console.log(" ▸ sibling mode returns parent of repoRoot"); + const result = resolveWorktreeBasePathFn(repoRoot, siblingConfig); + const expected = resolve(repoRoot, ".."); + assertEqual(result, expected, "sibling base path"); + } - { - console.log(" ▸ subdirectory mode returns .worktrees under repoRoot"); - const result = resolveWorktreeBasePathFn(repoRoot, subdirConfig); - const expected = resolve(repoRoot, ".worktrees"); - assertEqual(result, expected, "subdirectory base path"); - } + { + console.log(" ▸ subdirectory mode returns .worktrees under repoRoot"); + const result = resolveWorktreeBasePathFn(repoRoot, subdirConfig); + const expected = resolve(repoRoot, ".worktrees"); + assertEqual(result, expected, "subdirectory base path"); + } - { - console.log(" ▸ unknown location defaults to subdirectory"); - const result = resolveWorktreeBasePathFn(repoRoot, unknownConfig); - const expected = resolve(repoRoot, ".worktrees"); - assertEqual(result, expected, "default base path for unknown location"); + { + console.log(" ▸ unknown location defaults to subdirectory"); + const result = resolveWorktreeBasePathFn(repoRoot, unknownConfig); + const expected = resolve(repoRoot, ".worktrees"); + assertEqual(result, expected, "default base path for unknown location"); + } } -} -// ═══════════════════════════════════════════════════════════════════════ -// 7.7: generateWorktreePath — table-driven end-to-end test -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.7: generateWorktreePath — table-driven end-to-end test + // ═══════════════════════════════════════════════════════════════════════ -// Extract and build the real generateWorktreePath. -// It depends on resolveWorktreeBasePath (extracted above) and resolve. + // Extract and build the real generateWorktreePath. + // It depends on resolveWorktreeBasePath (extracted above) and resolve. -const generateWorktreePathSource = extractFunction(source, "generateWorktreePath"); + const generateWorktreePathSource = extractFunction(source, "generateWorktreePath"); -// We also need the DEFAULT_ORCHESTRATOR_CONFIG. Extract its worktree_location value. -const defaultLocationMatch = source.match(/const DEFAULT_ORCHESTRATOR_CONFIG[\s\S]*?worktree_location:\s*"([^"]+)"/); -const defaultWorktreeLocation = defaultLocationMatch ? defaultLocationMatch[1] : "subdirectory"; + // We also need the DEFAULT_ORCHESTRATOR_CONFIG. Extract its worktree_location value. + const defaultLocationMatch = source.match( + /const DEFAULT_ORCHESTRATOR_CONFIG[\s\S]*?worktree_location:\s*"([^"]+)"/, + ); + const defaultWorktreeLocation = defaultLocationMatch ? defaultLocationMatch[1] : "subdirectory"; + + // Build the function with injected dependencies. + // resolveWorktreeBasePath is referenced by name inside generateWorktreePath, + // so we inject it as a named variable in the closure. + const generateWorktreePathFn = new Function( + "resolve", + "resolveWorktreeBasePath", + "DEFAULT_ORCHESTRATOR_CONFIG", + `return (${stripTypeAnnotations(generateWorktreePathSource).replace( + /^function generateWorktreePath/, + "function", + )})`, + )(resolve, resolveWorktreeBasePathFn, { + orchestrator: { worktree_location: defaultWorktreeLocation }, + }) as (prefix: string, laneNumber: number, repoRoot: string, opId: string, config?: any) => string; + + console.log("\n7.7 — generateWorktreePath (table-driven, extracted from source)"); -// Build the function with injected dependencies. -// resolveWorktreeBasePath is referenced by name inside generateWorktreePath, -// so we inject it as a named variable in the closure. -const generateWorktreePathFn = new Function( - "resolve", - "resolveWorktreeBasePath", - "DEFAULT_ORCHESTRATOR_CONFIG", - `return (${stripTypeAnnotations(generateWorktreePathSource) - .replace(/^function generateWorktreePath/, "function") - })`, -)(resolve, resolveWorktreeBasePathFn, { orchestrator: { worktree_location: defaultWorktreeLocation } }) as (prefix: string, laneNumber: number, repoRoot: string, opId: string, config?: any) => string; + { + // Verify the default config matches what we extracted + assertEqual( + defaultWorktreeLocation, + "subdirectory", + "DEFAULT_ORCHESTRATOR_CONFIG uses subdirectory", + ); + + // Table-driven test cases: { worktree_location, repoRoot, prefix, lane, opId, expectedPath } + // Naming rule: basename = {prefix}-{opId}-{N} + const testCases = [ + { + label: "subdirectory mode, lane 1", + config: { orchestrator: { worktree_location: "subdirectory" } }, + repoRoot: "/home/user/project", + prefix: "proj-wt", + opId: "testop", + lane: 1, + expected: resolve("/home/user/project", ".worktrees", "proj-wt-testop-1"), + }, + { + label: "subdirectory mode, lane 3", + config: { orchestrator: { worktree_location: "subdirectory" } }, + repoRoot: "/home/user/project", + prefix: "proj-wt", + opId: "testop", + lane: 3, + expected: resolve("/home/user/project", ".worktrees", "proj-wt-testop-3"), + }, + { + label: "sibling mode, lane 1", + config: { orchestrator: { worktree_location: "sibling" } }, + repoRoot: "/home/user/project", + prefix: "proj-wt", + opId: "testop", + lane: 1, + expected: resolve("/home/user/project", "..", "proj-wt-testop-1"), + }, + { + label: "sibling mode, lane 2", + config: { orchestrator: { worktree_location: "sibling" } }, + repoRoot: "/home/user/project", + prefix: "proj-wt", + opId: "testop", + lane: 2, + expected: resolve("/home/user/project", "..", "proj-wt-testop-2"), + }, + { + label: "default config (no config arg) → subdirectory", + config: undefined, + repoRoot: "/home/user/project", + prefix: "proj-wt", + opId: "testop", + lane: 1, + expected: resolve("/home/user/project", ".worktrees", "proj-wt-testop-1"), + }, + { + label: "Windows-style repoRoot in subdirectory mode", + config: { orchestrator: { worktree_location: "subdirectory" } }, + repoRoot: "C:\\dev\\taskplane", + prefix: "taskplane-wt", + opId: "testop", + lane: 2, + expected: resolve("C:\\dev\\taskplane", ".worktrees", "taskplane-wt-testop-2"), + }, + ]; -console.log("\n7.7 — generateWorktreePath (table-driven, extracted from source)"); + for (const tc of testCases) { + console.log(` ▸ ${tc.label}`); + const result = generateWorktreePathFn(tc.prefix, tc.lane, tc.repoRoot, tc.opId, tc.config); + assertEqual(result, tc.expected, tc.label); + } + } -{ + // ═══════════════════════════════════════════════════════════════════════ + // 7.8: listWorktrees regex pattern — naming invariant: {prefix}-{N} + // ═══════════════════════════════════════════════════════════════════════ - // Verify the default config matches what we extracted - assertEqual(defaultWorktreeLocation, "subdirectory", "DEFAULT_ORCHESTRATOR_CONFIG uses subdirectory"); + // Extract the escapeRegex helper and the regex pattern from listWorktrees. + // We test the regex directly against basename strings to verify matching. - // Table-driven test cases: { worktree_location, repoRoot, prefix, lane, opId, expectedPath } - // Naming rule: basename = {prefix}-{opId}-{N} - const testCases = [ - { - label: "subdirectory mode, lane 1", - config: { orchestrator: { worktree_location: "subdirectory" } }, - repoRoot: "/home/user/project", - prefix: "proj-wt", - opId: "testop", - lane: 1, - expected: resolve("/home/user/project", ".worktrees", "proj-wt-testop-1"), - }, - { - label: "subdirectory mode, lane 3", - config: { orchestrator: { worktree_location: "subdirectory" } }, - repoRoot: "/home/user/project", - prefix: "proj-wt", - opId: "testop", - lane: 3, - expected: resolve("/home/user/project", ".worktrees", "proj-wt-testop-3"), - }, - { - label: "sibling mode, lane 1", - config: { orchestrator: { worktree_location: "sibling" } }, - repoRoot: "/home/user/project", - prefix: "proj-wt", - opId: "testop", - lane: 1, - expected: resolve("/home/user/project", "..", "proj-wt-testop-1"), - }, - { - label: "sibling mode, lane 2", - config: { orchestrator: { worktree_location: "sibling" } }, - repoRoot: "/home/user/project", - prefix: "proj-wt", - opId: "testop", - lane: 2, - expected: resolve("/home/user/project", "..", "proj-wt-testop-2"), - }, - { - label: "default config (no config arg) → subdirectory", - config: undefined, - repoRoot: "/home/user/project", - prefix: "proj-wt", - opId: "testop", - lane: 1, - expected: resolve("/home/user/project", ".worktrees", "proj-wt-testop-1"), - }, - { - label: "Windows-style repoRoot in subdirectory mode", - config: { orchestrator: { worktree_location: "subdirectory" } }, - repoRoot: "C:\\dev\\taskplane", - prefix: "taskplane-wt", - opId: "testop", - lane: 2, - expected: resolve("C:\\dev\\taskplane", ".worktrees", "taskplane-wt-testop-2"), - }, - ]; - - for (const tc of testCases) { - console.log(` ▸ ${tc.label}`); - const result = generateWorktreePathFn(tc.prefix, tc.lane, tc.repoRoot, tc.opId, tc.config); - assertEqual(result, tc.expected, tc.label); + const escapeRegexSource = extractFunction(source, "escapeRegex"); + const escapeRegexFn = new Function( + `return (${stripTypeAnnotations(escapeRegexSource).replace(/^function escapeRegex/, "function")})`, + )() as (str: string) => string; + + /** Build the listWorktrees primary regex for a given prefix and opId (mirrors production code). */ + function buildListWorktreesPrimaryPattern(prefix: string, opId: string): RegExp { + return new RegExp(`^${escapeRegexFn(prefix)}-${escapeRegexFn(opId)}-(\\d+)$`); } -} -// ═══════════════════════════════════════════════════════════════════════ -// 7.8: listWorktrees regex pattern — naming invariant: {prefix}-{N} -// ═══════════════════════════════════════════════════════════════════════ + /** Build the legacy regex (opId="op" only) for backward compatibility. */ + function buildListWorktreesLegacyPattern(prefix: string): RegExp { + return new RegExp(`^${escapeRegexFn(prefix)}-(\\d+)$`); + } -// Extract the escapeRegex helper and the regex pattern from listWorktrees. -// We test the regex directly against basename strings to verify matching. + console.log("\n7.8 — listWorktrees regex pattern (naming invariant: {prefix}-{opId}-{N})"); + + { + // Table-driven: [prefix, opId, basename, shouldMatch, expectedLane] + const testCases: Array<{ + label: string; + prefix: string; + opId: string; + basename: string; + shouldMatch: boolean; + expectedLane?: number; + patternType: "primary" | "legacy"; + }> = [ + // Primary pattern: {prefix}-{opId}-{N} + { + label: "primary: taskplane-wt with op henrylach, lane 1", + prefix: "taskplane-wt", + opId: "henrylach", + basename: "taskplane-wt-henrylach-1", + shouldMatch: true, + expectedLane: 1, + patternType: "primary", + }, + { + label: "primary: taskplane-wt with op henrylach, lane 10", + prefix: "taskplane-wt", + opId: "henrylach", + basename: "taskplane-wt-henrylach-10", + shouldMatch: true, + expectedLane: 10, + patternType: "primary", + }, + { + label: "primary: different opId (no match)", + prefix: "taskplane-wt", + opId: "henrylach", + basename: "taskplane-wt-alice-1", + shouldMatch: false, + patternType: "primary", + }, + { + label: "primary: legacy format (no opId, no match)", + prefix: "taskplane-wt", + opId: "henrylach", + basename: "taskplane-wt-1", + shouldMatch: false, + patternType: "primary", + }, + { + label: "primary: no lane number", + prefix: "taskplane-wt", + opId: "henrylach", + basename: "taskplane-wt-henrylach-", + shouldMatch: false, + patternType: "primary", + }, + { + label: "primary: non-numeric lane", + prefix: "taskplane-wt", + opId: "henrylach", + basename: "taskplane-wt-henrylach-abc", + shouldMatch: false, + patternType: "primary", + }, -const escapeRegexSource = extractFunction(source, "escapeRegex"); -const escapeRegexFn = new Function( - `return (${stripTypeAnnotations(escapeRegexSource).replace(/^function escapeRegex/, "function")})`, -)() as (str: string) => string; + // Short prefix with opId + { + label: "primary: wt prefix with op, lane 1", + prefix: "wt", + opId: "ci-1", + basename: "wt-ci-1-1", + shouldMatch: true, + expectedLane: 1, + patternType: "primary", + }, + { + label: "primary: wt prefix with op, lane 3", + prefix: "wt", + opId: "ci-1", + basename: "wt-ci-1-3", + shouldMatch: true, + expectedLane: 3, + patternType: "primary", + }, -/** Build the listWorktrees primary regex for a given prefix and opId (mirrors production code). */ -function buildListWorktreesPrimaryPattern(prefix: string, opId: string): RegExp { - return new RegExp(`^${escapeRegexFn(prefix)}-${escapeRegexFn(opId)}-(\\d+)$`); -} + // Prefix with special regex chars (dots) + { + label: "primary: prefix with dots, lane 1", + prefix: "my.project", + opId: "op", + basename: "my.project-op-1", + shouldMatch: true, + expectedLane: 1, + patternType: "primary", + }, + { + label: "primary: prefix with dots, dot-as-wildcard rejected", + prefix: "my.project", + opId: "op", + basename: "myXproject-op-1", + shouldMatch: false, + patternType: "primary", + }, -/** Build the legacy regex (opId="op" only) for backward compatibility. */ -function buildListWorktreesLegacyPattern(prefix: string): RegExp { - return new RegExp(`^${escapeRegexFn(prefix)}-(\\d+)$`); -} + // Different prefix should not match + { + label: "primary: wrong prefix, no match", + prefix: "taskplane-wt", + opId: "op", + basename: "other-wt-op-1", + shouldMatch: false, + patternType: "primary", + }, -console.log("\n7.8 — listWorktrees regex pattern (naming invariant: {prefix}-{opId}-{N})"); - -{ - // Table-driven: [prefix, opId, basename, shouldMatch, expectedLane] - const testCases: Array<{ - label: string; - prefix: string; - opId: string; - basename: string; - shouldMatch: boolean; - expectedLane?: number; - patternType: "primary" | "legacy"; - }> = [ - // Primary pattern: {prefix}-{opId}-{N} - { label: "primary: taskplane-wt with op henrylach, lane 1", prefix: "taskplane-wt", opId: "henrylach", basename: "taskplane-wt-henrylach-1", shouldMatch: true, expectedLane: 1, patternType: "primary" }, - { label: "primary: taskplane-wt with op henrylach, lane 10", prefix: "taskplane-wt", opId: "henrylach", basename: "taskplane-wt-henrylach-10", shouldMatch: true, expectedLane: 10, patternType: "primary" }, - { label: "primary: different opId (no match)", prefix: "taskplane-wt", opId: "henrylach", basename: "taskplane-wt-alice-1", shouldMatch: false, patternType: "primary" }, - { label: "primary: legacy format (no opId, no match)", prefix: "taskplane-wt", opId: "henrylach", basename: "taskplane-wt-1", shouldMatch: false, patternType: "primary" }, - { label: "primary: no lane number", prefix: "taskplane-wt", opId: "henrylach", basename: "taskplane-wt-henrylach-", shouldMatch: false, patternType: "primary" }, - { label: "primary: non-numeric lane", prefix: "taskplane-wt", opId: "henrylach", basename: "taskplane-wt-henrylach-abc", shouldMatch: false, patternType: "primary" }, - - // Short prefix with opId - { label: "primary: wt prefix with op, lane 1", prefix: "wt", opId: "ci-1", basename: "wt-ci-1-1", shouldMatch: true, expectedLane: 1, patternType: "primary" }, - { label: "primary: wt prefix with op, lane 3", prefix: "wt", opId: "ci-1", basename: "wt-ci-1-3", shouldMatch: true, expectedLane: 3, patternType: "primary" }, - - // Prefix with special regex chars (dots) - { label: "primary: prefix with dots, lane 1", prefix: "my.project", opId: "op", basename: "my.project-op-1", shouldMatch: true, expectedLane: 1, patternType: "primary" }, - { label: "primary: prefix with dots, dot-as-wildcard rejected", prefix: "my.project", opId: "op", basename: "myXproject-op-1", shouldMatch: false, patternType: "primary" }, - - // Different prefix should not match - { label: "primary: wrong prefix, no match", prefix: "taskplane-wt", opId: "op", basename: "other-wt-op-1", shouldMatch: false, patternType: "primary" }, - - // Legacy pattern: {prefix}-{N} (only valid when opId="op") - { label: "legacy: taskplane-wt, lane 1", prefix: "taskplane-wt", opId: "op", basename: "taskplane-wt-1", shouldMatch: true, expectedLane: 1, patternType: "legacy" }, - { label: "legacy: taskplane-wt, lane 10", prefix: "taskplane-wt", opId: "op", basename: "taskplane-wt-10", shouldMatch: true, expectedLane: 10, patternType: "legacy" }, - { label: "legacy: lane 0 matches regex", prefix: "wt", opId: "op", basename: "wt-0", shouldMatch: true, expectedLane: 0, patternType: "legacy" }, - ]; - - for (const tc of testCases) { - console.log(` ▸ ${tc.label}`); - const pattern = tc.patternType === "primary" - ? buildListWorktreesPrimaryPattern(tc.prefix, tc.opId) - : buildListWorktreesLegacyPattern(tc.prefix); - const match = tc.basename.match(pattern); - - if (tc.shouldMatch) { - assert(match !== null, `${tc.label}: should match`); - if (match && tc.expectedLane !== undefined) { - assertEqual(parseInt(match[1], 10), tc.expectedLane, `${tc.label}: lane number`); + // Legacy pattern: {prefix}-{N} (only valid when opId="op") + { + label: "legacy: taskplane-wt, lane 1", + prefix: "taskplane-wt", + opId: "op", + basename: "taskplane-wt-1", + shouldMatch: true, + expectedLane: 1, + patternType: "legacy", + }, + { + label: "legacy: taskplane-wt, lane 10", + prefix: "taskplane-wt", + opId: "op", + basename: "taskplane-wt-10", + shouldMatch: true, + expectedLane: 10, + patternType: "legacy", + }, + { + label: "legacy: lane 0 matches regex", + prefix: "wt", + opId: "op", + basename: "wt-0", + shouldMatch: true, + expectedLane: 0, + patternType: "legacy", + }, + ]; + + for (const tc of testCases) { + console.log(` ▸ ${tc.label}`); + const pattern = + tc.patternType === "primary" + ? buildListWorktreesPrimaryPattern(tc.prefix, tc.opId) + : buildListWorktreesLegacyPattern(tc.prefix); + const match = tc.basename.match(pattern); + + if (tc.shouldMatch) { + assert(match !== null, `${tc.label}: should match`); + if (match && tc.expectedLane !== undefined) { + assertEqual(parseInt(match[1], 10), tc.expectedLane, `${tc.label}: lane number`); + } + } else { + assert(match === null, `${tc.label}: should NOT match`); } - } else { - assert(match === null, `${tc.label}: should NOT match`); } } -} -// ═══════════════════════════════════════════════════════════════════════ -// 7.9: computeSavedBranchName — pure mapping -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.9: computeSavedBranchName — pure mapping + // ═══════════════════════════════════════════════════════════════════════ -const computeSavedBranchNameSource = extractFunction(source, "computeSavedBranchName"); -const computeSavedBranchNameFn = new Function( - `return (${stripTypeAnnotations(computeSavedBranchNameSource) - .replace(/^function computeSavedBranchName/, "function") - })`, -)() as (originalBranch: string) => string; - -console.log("\n7.9 — computeSavedBranchName (extracted from source)"); - -{ - console.log(" ▸ standard lane branch"); - assertEqual( - computeSavedBranchNameFn("task/lane-1-20260308T111750"), - "saved/task/lane-1-20260308T111750", - "lane branch → saved/ prefix", - ); -} -{ - console.log(" ▸ feature branch"); - assertEqual( - computeSavedBranchNameFn("feature/my-branch"), - "saved/feature/my-branch", - "feature branch → saved/ prefix", - ); -} -{ - console.log(" ▸ simple branch name"); - assertEqual( - computeSavedBranchNameFn("main"), - "saved/main", - "simple name → saved/ prefix", - ); -} + const computeSavedBranchNameSource = extractFunction(source, "computeSavedBranchName"); + const computeSavedBranchNameFn = new Function( + `return (${stripTypeAnnotations(computeSavedBranchNameSource).replace( + /^function computeSavedBranchName/, + "function", + )})`, + )() as (originalBranch: string) => string; -// ═══════════════════════════════════════════════════════════════════════ -// 7.10: resolveSavedBranchCollision — decision table -// ═══════════════════════════════════════════════════════════════════════ + console.log("\n7.9 — computeSavedBranchName (extracted from source)"); -const resolveSavedBranchCollisionSource = extractFunction(source, "resolveSavedBranchCollision"); -const resolveSavedBranchCollisionFn = new Function( - `return (${stripTypeAnnotations(resolveSavedBranchCollisionSource) - .replace(/^function resolveSavedBranchCollision/, "function") - })`, -)() as (savedName: string, existingSHA: string, newSHA: string, timestamp?: string) => { action: string; savedName: string }; + { + console.log(" ▸ standard lane branch"); + assertEqual( + computeSavedBranchNameFn("task/lane-1-20260308T111750"), + "saved/task/lane-1-20260308T111750", + "lane branch → saved/ prefix", + ); + } + { + console.log(" ▸ feature branch"); + assertEqual( + computeSavedBranchNameFn("feature/my-branch"), + "saved/feature/my-branch", + "feature branch → saved/ prefix", + ); + } + { + console.log(" ▸ simple branch name"); + assertEqual(computeSavedBranchNameFn("main"), "saved/main", "simple name → saved/ prefix"); + } -console.log("\n7.10 — resolveSavedBranchCollision (extracted from source)"); + // ═══════════════════════════════════════════════════════════════════════ + // 7.10: resolveSavedBranchCollision — decision table + // ═══════════════════════════════════════════════════════════════════════ + + const resolveSavedBranchCollisionSource = extractFunction(source, "resolveSavedBranchCollision"); + const resolveSavedBranchCollisionFn = new Function( + `return (${stripTypeAnnotations(resolveSavedBranchCollisionSource).replace( + /^function resolveSavedBranchCollision/, + "function", + )})`, + )() as ( + savedName: string, + existingSHA: string, + newSHA: string, + timestamp?: string, + ) => { action: string; savedName: string }; + + console.log("\n7.10 — resolveSavedBranchCollision (extracted from source)"); -{ - console.log(" ▸ saved ref absent → create"); - const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "", "abc123"); - assertEqual(result.action, "create", "action is create"); - assertEqual(result.savedName, "saved/task/lane-1", "uses original savedName"); -} -{ - console.log(" ▸ saved ref exists, same SHA → keep-existing"); - const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "abc123", "abc123"); - assertEqual(result.action, "keep-existing", "action is keep-existing"); - assertEqual(result.savedName, "saved/task/lane-1", "uses existing savedName"); -} -{ - console.log(" ▸ saved ref exists, different SHA → create-suffixed"); - const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "abc123", "def456", "2026-03-09T120000"); - assertEqual(result.action, "create-suffixed", "action is create-suffixed"); - assertEqual(result.savedName, "saved/task/lane-1-2026-03-09T120000", "appended timestamp suffix"); -} -{ - console.log(" ▸ empty existingSHA treated as absent (falsy)"); - const result = resolveSavedBranchCollisionFn("saved/my-branch", "", "sha1"); - assertEqual(result.action, "create", "empty string existingSHA → create"); -} -{ - console.log(" ▸ auto-generates timestamp when not provided for collision"); - const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "sha-old", "sha-new"); - assertEqual(result.action, "create-suffixed", "action is create-suffixed"); - assert(result.savedName.startsWith("saved/task/lane-1-"), "auto-generated timestamp suffix"); - assert(result.savedName.length > "saved/task/lane-1-".length, "has timestamp content"); -} + { + console.log(" ▸ saved ref absent → create"); + const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "", "abc123"); + assertEqual(result.action, "create", "action is create"); + assertEqual(result.savedName, "saved/task/lane-1", "uses original savedName"); + } + { + console.log(" ▸ saved ref exists, same SHA → keep-existing"); + const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "abc123", "abc123"); + assertEqual(result.action, "keep-existing", "action is keep-existing"); + assertEqual(result.savedName, "saved/task/lane-1", "uses existing savedName"); + } + { + console.log(" ▸ saved ref exists, different SHA → create-suffixed"); + const result = resolveSavedBranchCollisionFn( + "saved/task/lane-1", + "abc123", + "def456", + "2026-03-09T120000", + ); + assertEqual(result.action, "create-suffixed", "action is create-suffixed"); + assertEqual(result.savedName, "saved/task/lane-1-2026-03-09T120000", "appended timestamp suffix"); + } + { + console.log(" ▸ empty existingSHA treated as absent (falsy)"); + const result = resolveSavedBranchCollisionFn("saved/my-branch", "", "sha1"); + assertEqual(result.action, "create", "empty string existingSHA → create"); + } + { + console.log(" ▸ auto-generates timestamp when not provided for collision"); + const result = resolveSavedBranchCollisionFn("saved/task/lane-1", "sha-old", "sha-new"); + assertEqual(result.action, "create-suffixed", "action is create-suffixed"); + assert(result.savedName.startsWith("saved/task/lane-1-"), "auto-generated timestamp suffix"); + assert(result.savedName.length > "saved/task/lane-1-".length, "has timestamp content"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 7.11: hasUnmergedCommits — source verification -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.11: hasUnmergedCommits — source verification + // ═══════════════════════════════════════════════════════════════════════ -console.log("\n7.11 — hasUnmergedCommits (source verification)"); - -{ - const fnSrc = extractFunction(source, "hasUnmergedCommits"); - console.log(" ▸ verifies branch exists"); - assert(fnSrc.includes(`refs/heads/\${branch}`), "checks refs/heads/{branch}"); - console.log(" ▸ verifies target branch exists"); - assert(fnSrc.includes(`refs/heads/\${targetBranch}`), "checks refs/heads/{targetBranch}"); - console.log(" ▸ uses rev-list --count (Windows-safe, no pipes)"); - assert(fnSrc.includes("rev-list"), "uses rev-list"); - assert(fnSrc.includes("--count"), "uses --count flag"); - console.log(" ▸ returns BRANCH_NOT_FOUND error code"); - assert(fnSrc.includes("BRANCH_NOT_FOUND"), "has BRANCH_NOT_FOUND code"); - console.log(" ▸ returns TARGET_BRANCH_MISSING error code"); - assert(fnSrc.includes("TARGET_BRANCH_MISSING"), "has TARGET_BRANCH_MISSING code"); - console.log(" ▸ returns UNMERGED_COUNT_FAILED error code"); - assert(fnSrc.includes("UNMERGED_COUNT_FAILED"), "has UNMERGED_COUNT_FAILED code"); - console.log(" ▸ returns UNMERGED_COUNT_PARSE_FAILED error code"); - assert(fnSrc.includes("UNMERGED_COUNT_PARSE_FAILED"), "has UNMERGED_COUNT_PARSE_FAILED code"); - console.log(" ▸ parses count with parseInt"); - assert(fnSrc.includes("parseInt"), "parses count with parseInt"); -} + console.log("\n7.11 — hasUnmergedCommits (source verification)"); -// ═══════════════════════════════════════════════════════════════════════ -// 7.12: preserveBranch — source verification -// ═══════════════════════════════════════════════════════════════════════ + { + const fnSrc = extractFunction(source, "hasUnmergedCommits"); + console.log(" ▸ verifies branch exists"); + assert(fnSrc.includes(`refs/heads/\${branch}`), "checks refs/heads/{branch}"); + console.log(" ▸ verifies target branch exists"); + assert(fnSrc.includes(`refs/heads/\${targetBranch}`), "checks refs/heads/{targetBranch}"); + console.log(" ▸ uses rev-list --count (Windows-safe, no pipes)"); + assert(fnSrc.includes("rev-list"), "uses rev-list"); + assert(fnSrc.includes("--count"), "uses --count flag"); + console.log(" ▸ returns BRANCH_NOT_FOUND error code"); + assert(fnSrc.includes("BRANCH_NOT_FOUND"), "has BRANCH_NOT_FOUND code"); + console.log(" ▸ returns TARGET_BRANCH_MISSING error code"); + assert(fnSrc.includes("TARGET_BRANCH_MISSING"), "has TARGET_BRANCH_MISSING code"); + console.log(" ▸ returns UNMERGED_COUNT_FAILED error code"); + assert(fnSrc.includes("UNMERGED_COUNT_FAILED"), "has UNMERGED_COUNT_FAILED code"); + console.log(" ▸ returns UNMERGED_COUNT_PARSE_FAILED error code"); + assert(fnSrc.includes("UNMERGED_COUNT_PARSE_FAILED"), "has UNMERGED_COUNT_PARSE_FAILED code"); + console.log(" ▸ parses count with parseInt"); + assert(fnSrc.includes("parseInt"), "parses count with parseInt"); + } -console.log("\n7.12 — preserveBranch (source verification)"); - -{ - const fnSrc = extractFunction(source, "preserveBranch"); - console.log(" ▸ checks branch existence before proceeding"); - assert(fnSrc.includes("rev-parse"), "uses git rev-parse for branch check"); - console.log(" ▸ returns no-branch when branch doesn't exist"); - assert(fnSrc.includes("no-branch"), "handles missing branch gracefully"); - console.log(" ▸ calls hasUnmergedCommits"); - assert(fnSrc.includes("hasUnmergedCommits"), "delegates to hasUnmergedCommits"); - console.log(" ▸ calls computeSavedBranchName"); - assert(fnSrc.includes("computeSavedBranchName"), "delegates to computeSavedBranchName"); - console.log(" ▸ calls resolveSavedBranchCollision"); - assert(fnSrc.includes("resolveSavedBranchCollision"), "delegates to resolveSavedBranchCollision"); - console.log(" ▸ handles TARGET_BRANCH_MISSING gracefully (no crash)"); - assert(fnSrc.includes("TARGET_BRANCH_MISSING"), "forwards TARGET_BRANCH_MISSING code"); - console.log(" ▸ handles UNMERGED_COUNT_FAILED"); - assert(fnSrc.includes("UNMERGED_COUNT_FAILED"), "forwards UNMERGED_COUNT_FAILED code"); - console.log(" ▸ returns SAVED_BRANCH_CREATE_FAILED on git branch failure"); - assert(fnSrc.includes("SAVED_BRANCH_CREATE_FAILED"), "has SAVED_BRANCH_CREATE_FAILED code"); - console.log(" ▸ returns fully-merged when count is 0"); - assert(fnSrc.includes("fully-merged"), "has fully-merged action"); - console.log(" ▸ returns preserved on successful save"); - assert(fnSrc.includes('"preserved"'), "has preserved action"); - console.log(" ▸ returns already-preserved when collision is keep-existing"); - assert(fnSrc.includes("already-preserved"), "has already-preserved action"); - console.log(" ▸ includes unmergedCount in result"); - assert(fnSrc.includes("unmergedCount"), "passes unmergedCount through result"); -} + // ═══════════════════════════════════════════════════════════════════════ + // 7.12: preserveBranch — source verification + // ═══════════════════════════════════════════════════════════════════════ -// ═══════════════════════════════════════════════════════════════════════ -// 7.13: ensureBranchDeleted — source verification (rename semantics) -// ═══════════════════════════════════════════════════════════════════════ + console.log("\n7.12 — preserveBranch (source verification)"); -console.log("\n7.13 — ensureBranchDeleted (source verification)"); - -{ - const fnSrc = extractFunction(source, "ensureBranchDeleted"); - console.log(" ▸ calls preserveBranch when targetBranch is provided"); - assert(fnSrc.includes("preserveBranch"), "delegates to preserveBranch"); - console.log(" ▸ deletes original branch after preservation (rename semantics)"); - assert(fnSrc.includes("deleteBranchBestEffort"), "calls deleteBranchBestEffort after preserve"); - console.log(" ▸ handles preserved and already-preserved actions"); - assert(fnSrc.includes('"preserved"'), "handles preserved action"); - assert(fnSrc.includes('"already-preserved"'), "handles already-preserved action"); - console.log(" ▸ passes through savedBranch and unmergedCount in result"); - assert(fnSrc.includes("savedBranch"), "forwards savedBranch"); - assert(fnSrc.includes("unmergedCount"), "forwards unmergedCount"); - console.log(" ▸ falls through to normal delete for fully-merged/no-branch"); - assert(fnSrc.includes('"fully-merged"'), "checks fully-merged action"); - assert(fnSrc.includes('"no-branch"'), "checks no-branch action"); - console.log(" ▸ skips deletion on error (safe default)"); - assert(fnSrc.includes('"error"'), "handles error action"); -} + { + const fnSrc = extractFunction(source, "preserveBranch"); + console.log(" ▸ checks branch existence before proceeding"); + assert(fnSrc.includes("rev-parse"), "uses git rev-parse for branch check"); + console.log(" ▸ returns no-branch when branch doesn't exist"); + assert(fnSrc.includes("no-branch"), "handles missing branch gracefully"); + console.log(" ▸ calls hasUnmergedCommits"); + assert(fnSrc.includes("hasUnmergedCommits"), "delegates to hasUnmergedCommits"); + console.log(" ▸ calls computeSavedBranchName"); + assert(fnSrc.includes("computeSavedBranchName"), "delegates to computeSavedBranchName"); + console.log(" ▸ calls resolveSavedBranchCollision"); + assert(fnSrc.includes("resolveSavedBranchCollision"), "delegates to resolveSavedBranchCollision"); + console.log(" ▸ handles TARGET_BRANCH_MISSING gracefully (no crash)"); + assert(fnSrc.includes("TARGET_BRANCH_MISSING"), "forwards TARGET_BRANCH_MISSING code"); + console.log(" ▸ handles UNMERGED_COUNT_FAILED"); + assert(fnSrc.includes("UNMERGED_COUNT_FAILED"), "forwards UNMERGED_COUNT_FAILED code"); + console.log(" ▸ returns SAVED_BRANCH_CREATE_FAILED on git branch failure"); + assert(fnSrc.includes("SAVED_BRANCH_CREATE_FAILED"), "has SAVED_BRANCH_CREATE_FAILED code"); + console.log(" ▸ returns fully-merged when count is 0"); + assert(fnSrc.includes("fully-merged"), "has fully-merged action"); + console.log(" ▸ returns preserved on successful save"); + assert(fnSrc.includes('"preserved"'), "has preserved action"); + console.log(" ▸ returns already-preserved when collision is keep-existing"); + assert(fnSrc.includes("already-preserved"), "has already-preserved action"); + console.log(" ▸ includes unmergedCount in result"); + assert(fnSrc.includes("unmergedCount"), "passes unmergedCount through result"); + } -// ═══════════════════════════════════════════════════════════════════════ -// Summary -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.13: ensureBranchDeleted — source verification (rename semantics) + // ═══════════════════════════════════════════════════════════════════════ -console.log("\n══════════════════════════════════════"); -console.log(` Results: ${passed} passed, ${failed} failed`); -if (failures.length > 0) { - console.log("\n Failed:"); - for (const f of failures) { - console.log(` • ${f}`); + console.log("\n7.13 — ensureBranchDeleted (source verification)"); + + { + const fnSrc = extractFunction(source, "ensureBranchDeleted"); + console.log(" ▸ calls preserveBranch when targetBranch is provided"); + assert(fnSrc.includes("preserveBranch"), "delegates to preserveBranch"); + console.log(" ▸ deletes original branch after preservation (rename semantics)"); + assert(fnSrc.includes("deleteBranchBestEffort"), "calls deleteBranchBestEffort after preserve"); + console.log(" ▸ handles preserved and already-preserved actions"); + assert(fnSrc.includes('"preserved"'), "handles preserved action"); + assert(fnSrc.includes('"already-preserved"'), "handles already-preserved action"); + console.log(" ▸ passes through savedBranch and unmergedCount in result"); + assert(fnSrc.includes("savedBranch"), "forwards savedBranch"); + assert(fnSrc.includes("unmergedCount"), "forwards unmergedCount"); + console.log(" ▸ falls through to normal delete for fully-merged/no-branch"); + assert(fnSrc.includes('"fully-merged"'), "checks fully-merged action"); + assert(fnSrc.includes('"no-branch"'), "checks no-branch action"); + console.log(" ▸ skips deletion on error (safe default)"); + assert(fnSrc.includes('"error"'), "handles error action"); } -} -console.log("══════════════════════════════════════\n"); -if (failed > 0) throw new Error(`${failed} test(s) failed`); + // ═══════════════════════════════════════════════════════════════════════ + // Summary + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n══════════════════════════════════════"); + console.log(` Results: ${passed} passed, ${failed} failed`); + if (failures.length > 0) { + console.log("\n Failed:"); + for (const f of failures) { + console.log(` • ${f}`); + } + } + console.log("══════════════════════════════════════\n"); + if (failed > 0) throw new Error(`${failed} test(s) failed`); } // end runAllTests // ── Dual-mode execution ────────────────────────────────────────────── diff --git a/extensions/tests/orch-rpc-telemetry.test.ts b/extensions/tests/orch-rpc-telemetry.test.ts index b2c185da..9a6f72c0 100644 --- a/extensions/tests/orch-rpc-telemetry.test.ts +++ b/extensions/tests/orch-rpc-telemetry.test.ts @@ -23,7 +23,10 @@ function readSource(file: string): string { } function readDashboardSource(): string { - return readFileSync(join(__dirname, "..", "..", "dashboard", "server.cjs"), "utf-8").replace(/\r\n/g, "\n"); + return readFileSync(join(__dirname, "..", "..", "dashboard", "server.cjs"), "utf-8").replace( + /\r\n/g, + "\n", + ); } /** @@ -72,7 +75,10 @@ describe("Runtime V2 lane wiring (source extraction)", () => { // After TP-157, resolveTaskplanePackageFile lives in path-resolver.ts, not execution.ts. // Verify execution.ts imports it from path-resolver.ts. const pathResolverSrc = readSource("path-resolver.ts"); - const funcBody = extractFunctionRegion(pathResolverSrc, "export function resolveTaskplanePackageFile("); + const funcBody = extractFunctionRegion( + pathResolverSrc, + "export function resolveTaskplanePackageFile(", + ); expect(funcBody).toContain("getNpmGlobalRoot"); expect(funcBody).toContain("npmRoot"); expect(execSrc).toContain('from "./path-resolver.ts"'); @@ -181,4 +187,3 @@ describe("dashboard parseTelemetryFilename (source extraction)", () => { }); // ── 5. Functional tests — generateTelemetryPaths ──────────────────── - diff --git a/extensions/tests/orch-state-persistence.test.ts b/extensions/tests/orch-state-persistence.test.ts index e24090c6..e42720c6 100644 --- a/extensions/tests/orch-state-persistence.test.ts +++ b/extensions/tests/orch-state-persistence.test.ts @@ -13,7 +13,15 @@ * 1.3 — saveBatchState / loadBatchState / deleteBatchState (file I/O) */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, renameSync, unlinkSync } from "fs"; +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + rmSync, + renameSync, + unlinkSync, +} from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { tmpdir } from "os"; @@ -87,7 +95,7 @@ const sourceFiles = [ join(__dirname, "..", "taskplane", "abort.ts"), join(__dirname, "..", "taskplane", "merge.ts"), ]; -const source = sourceFiles.map(f => readFileSync(f, "utf8")).join("\n"); +const source = sourceFiles.map((f) => readFileSync(f, "utf8")).join("\n"); // Since pi imports prevent direct import, we reimplement the pure functions // by testing with the same logic as the source. This approach is validated @@ -98,16 +106,27 @@ const BATCH_STATE_SCHEMA_VERSION = 2; // Valid enum sets (must match source) const VALID_BATCH_PHASES = new Set([ - "idle", "launching", "planning", "executing", "merging", "paused", "stopped", "completed", "failed", + "idle", + "launching", + "planning", + "executing", + "merging", + "paused", + "stopped", + "completed", + "failed", ]); const VALID_TASK_STATUSES = new Set([ - "pending", "running", "succeeded", "failed", "stalled", "skipped", + "pending", + "running", + "succeeded", + "failed", + "stalled", + "skipped", ]); -const VALID_PERSISTED_MERGE_STATUSES = new Set([ - "succeeded", "failed", "partial", -]); +const VALID_PERSISTED_MERGE_STATUSES = new Set(["succeeded", "failed", "partial"]); // StateFileError reimplementation class StateFileError extends Error { @@ -129,66 +148,100 @@ function validatePersistedState(data: unknown): any { // Schema version — accept v1 (auto-upconvert) and v2 (current) if (typeof obj.schemaVersion !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`, + ); } if (obj.schemaVersion !== 1 && obj.schemaVersion !== BATCH_STATE_SCHEMA_VERSION) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Unsupported schema version ${obj.schemaVersion} (expected ${BATCH_STATE_SCHEMA_VERSION}). Delete .pi/batch-state.json and re-run the batch.`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Unsupported schema version ${obj.schemaVersion} (expected ${BATCH_STATE_SCHEMA_VERSION}). Delete .pi/batch-state.json and re-run the batch.`, + ); } const isV1 = obj.schemaVersion === 1; // Required string fields for (const field of ["phase", "batchId"] as const) { if (typeof obj[field] !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Missing or invalid "${field}" field (expected string, got ${typeof obj[field]})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Missing or invalid "${field}" field (expected string, got ${typeof obj[field]})`, + ); } } // v2: mode field validation // mode is required in v2, absent in v1 (defaults to "repo" via upconvert). if (!isV1 && obj.mode === undefined) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Missing required "mode" field in schema v2 (expected "repo" or "workspace")`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Missing required "mode" field in schema v2 (expected "repo" or "workspace")`, + ); } if (obj.mode !== undefined && typeof obj.mode !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Invalid "mode" field (expected string, got ${typeof obj.mode})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Invalid "mode" field (expected string, got ${typeof obj.mode})`, + ); } if (obj.mode !== undefined && obj.mode !== "repo" && obj.mode !== "workspace") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Invalid "mode" value "${obj.mode}" (expected "repo" or "workspace")`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Invalid "mode" value "${obj.mode}" (expected "repo" or "workspace")`, + ); } // Phase enum if (!VALID_BATCH_PHASES.has(obj.phase as string)) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Invalid "phase" value "${obj.phase}" (expected one of: ${[...VALID_BATCH_PHASES].join(", ")})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Invalid "phase" value "${obj.phase}" (expected one of: ${[...VALID_BATCH_PHASES].join(", ")})`, + ); } // Required number fields for (const field of [ - "startedAt", "updatedAt", "currentWaveIndex", "totalWaves", - "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks", + "startedAt", + "updatedAt", + "currentWaveIndex", + "totalWaves", + "totalTasks", + "succeededTasks", + "failedTasks", + "skippedTasks", + "blockedTasks", ] as const) { if (typeof obj[field] !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Missing or invalid "${field}" field (expected number, got ${typeof obj[field]})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Missing or invalid "${field}" field (expected number, got ${typeof obj[field]})`, + ); } } // Nullable number: endedAt if (obj.endedAt !== null && typeof obj.endedAt !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Invalid "endedAt" field (expected number or null, got ${typeof obj.endedAt})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Invalid "endedAt" field (expected number or null, got ${typeof obj.endedAt})`, + ); } // Required arrays - for (const field of ["wavePlan", "lanes", "tasks", "mergeResults", "blockedTaskIds", "errors"] as const) { + for (const field of [ + "wavePlan", + "lanes", + "tasks", + "mergeResults", + "blockedTaskIds", + "errors", + ] as const) { if (!Array.isArray(obj[field])) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `Missing or invalid "${field}" field (expected array, got ${typeof obj[field]})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `Missing or invalid "${field}" field (expected array, got ${typeof obj[field]})`, + ); } } @@ -200,8 +253,10 @@ function validatePersistedState(data: unknown): any { } for (const taskId of wavePlan[i] as unknown[]) { if (typeof taskId !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `wavePlan[${i}] contains non-string value: ${typeof taskId}`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `wavePlan[${i}] contains non-string value: ${typeof taskId}`, + ); } } } @@ -215,38 +270,51 @@ function validatePersistedState(data: unknown): any { } for (const field of ["taskId", "sessionName", "taskFolder", "exitReason"] as const) { if (typeof t[field] !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].${field} is missing or not a string`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].${field} is missing or not a string`, + ); } } if (typeof t.laneNumber !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].laneNumber is missing or not a number`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].laneNumber is missing or not a number`, + ); } if (typeof t.status !== "string" || !VALID_TASK_STATUSES.has(t.status)) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].status is invalid: "${t.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].status is invalid: "${t.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`, + ); } if (t.startedAt !== null && typeof t.startedAt !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].startedAt is not a number or null`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].startedAt is not a number or null`, + ); } if (t.endedAt !== null && typeof t.endedAt !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].endedAt is not a number or null`); + throw new StateFileError("STATE_SCHEMA_INVALID", `tasks[${i}].endedAt is not a number or null`); } if (typeof t.doneFileFound !== "boolean") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].doneFileFound is missing or not a boolean`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].doneFileFound is missing or not a boolean`, + ); } // v2 optional fields if (t.repoId !== undefined && typeof t.repoId !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].repoId is not a string (got ${typeof t.repoId})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].repoId is not a string (got ${typeof t.repoId})`, + ); } if (t.resolvedRepoId !== undefined && typeof t.resolvedRepoId !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`, + ); } } @@ -259,23 +327,31 @@ function validatePersistedState(data: unknown): any { } for (const field of ["laneId", "worktreePath", "branch"] as const) { if (typeof l[field] !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}].${field} is missing or not a string`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].${field} is missing or not a string`, + ); } } const laneSessionId = l.laneSessionId; const legacySession = l.tmuxSessionName; if (laneSessionId !== undefined && typeof laneSessionId !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}].laneSessionId is not a string (got ${typeof laneSessionId})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].laneSessionId is not a string (got ${typeof laneSessionId})`, + ); } if (legacySession !== undefined && typeof legacySession !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}].tmuxSessionName is not a string (got ${typeof legacySession})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].tmuxSessionName is not a string (got ${typeof legacySession})`, + ); } if (typeof laneSessionId !== "string" && typeof legacySession !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}] must include either laneSessionId or tmuxSessionName as a string`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}] must include either laneSessionId or tmuxSessionName as a string`, + ); } if (typeof laneSessionId !== "string") { l.laneSessionId = legacySession; @@ -284,17 +360,23 @@ function validatePersistedState(data: unknown): any { delete (l as { tmuxSessionName?: unknown }).tmuxSessionName; } if (typeof l.laneNumber !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}].laneNumber is missing or not a number`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].laneNumber is missing or not a number`, + ); } if (!Array.isArray(l.taskIds)) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}].taskIds is missing or not an array`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].taskIds is missing or not an array`, + ); } // v2 optional field if (l.repoId !== undefined && typeof l.repoId !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lanes[${i}].repoId is not a string (got ${typeof l.repoId})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].repoId is not a string (got ${typeof l.repoId})`, + ); } } @@ -306,12 +388,16 @@ function validatePersistedState(data: unknown): any { throw new StateFileError("STATE_SCHEMA_INVALID", `mergeResults[${i}] is not an object`); } if (typeof m.waveIndex !== "number") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `mergeResults[${i}].waveIndex is missing or not a number`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `mergeResults[${i}].waveIndex is missing or not a number`, + ); } if (typeof m.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(m.status)) { - throw new StateFileError("STATE_SCHEMA_INVALID", - `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`, + ); } } @@ -322,24 +408,30 @@ function validatePersistedState(data: unknown): any { } const le = obj.lastError as Record; if (typeof le.code !== "string" || typeof le.message !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `lastError must have "code" (string) and "message" (string) fields`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lastError must have "code" (string) and "message" (string) fields`, + ); } } // Validate blockedTaskIds for (const id of obj.blockedTaskIds as unknown[]) { if (typeof id !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `blockedTaskIds contains non-string value: ${typeof id}`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `blockedTaskIds contains non-string value: ${typeof id}`, + ); } } // Validate errors for (const err of obj.errors as unknown[]) { if (typeof err !== "string") { - throw new StateFileError("STATE_SCHEMA_INVALID", - `errors array contains non-string value: ${typeof err}`); + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `errors array contains non-string value: ${typeof err}`, + ); } } @@ -373,9 +465,15 @@ function saveBatchState(json: string, repoRoot: string): void { try { renameSync(tmpPath, finalPath); } catch (err: unknown) { - try { unlinkSync(tmpPath); } catch { /* ignore */ } - throw new StateFileError("STATE_FILE_IO_ERROR", - `Failed to atomically save state file: ${(err as Error).message}`); + try { + unlinkSync(tmpPath); + } catch { + /* ignore */ + } + throw new StateFileError( + "STATE_FILE_IO_ERROR", + `Failed to atomically save state file: ${(err as Error).message}`, + ); } } @@ -391,16 +489,20 @@ function loadBatchState(repoRoot: string): any | null { try { raw = readFileSync(filePath, "utf-8"); } catch (err: unknown) { - throw new StateFileError("STATE_FILE_IO_ERROR", - `Failed to read state file: ${(err as Error).message}`); + throw new StateFileError( + "STATE_FILE_IO_ERROR", + `Failed to read state file: ${(err as Error).message}`, + ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err: unknown) { - throw new StateFileError("STATE_FILE_PARSE_ERROR", - `State file contains invalid JSON: ${(err as Error).message}`); + throw new StateFileError( + "STATE_FILE_PARSE_ERROR", + `State file contains invalid JSON: ${(err as Error).message}`, + ); } return validatePersistedState(parsed); @@ -418,8 +520,10 @@ function deleteBatchState(repoRoot: string): void { unlinkSync(filePath); } catch (err: unknown) { if (!existsSync(filePath)) return; - throw new StateFileError("STATE_FILE_IO_ERROR", - `Failed to delete state file: ${(err as Error).message}`); + throw new StateFileError( + "STATE_FILE_IO_ERROR", + `Failed to delete state file: ${(err as Error).message}`, + ); } } @@ -438,5444 +542,6318 @@ function loadFixtureJSON(name: string): unknown { // ── Test Runner ────────────────────────────────────────────────────── function runAllTests() { + // ═══════════════════════════════════════════════════════════════════════ + // 1.1: validatePersistedState + // ═══════════════════════════════════════════════════════════════════════ -// ═══════════════════════════════════════════════════════════════════════ -// 1.1: validatePersistedState -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 1.1: validatePersistedState ──"); - -{ - console.log(" ▸ validates a well-formed state file"); - const data = loadFixtureJSON("batch-state-valid.json"); - const result = validatePersistedState(data); - assertEqual(result.schemaVersion, 2, "schemaVersion is 2"); - assertEqual(result.phase, "executing", "phase is executing"); - assertEqual(result.batchId, "20260309T010000", "batchId matches"); - assertEqual(result.totalTasks, 3, "totalTasks is 3"); - assertEqual(result.tasks.length, 3, "3 task records"); - assertEqual(result.lanes.length, 2, "2 lane records"); - assertEqual(result.wavePlan.length, 2, "2 waves in plan"); -} - -{ - console.log(" ▸ rejects null input"); - assertThrows( - () => validatePersistedState(null), - "STATE_SCHEMA_INVALID", - "null input throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects non-object input"); - assertThrows( - () => validatePersistedState("not an object"), - "STATE_SCHEMA_INVALID", - "string input throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects wrong schema version"); - const data = loadFixtureJSON("batch-state-wrong-version.json"); - assertThrows( - () => validatePersistedState(data), - "STATE_SCHEMA_INVALID", - "wrong version throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects missing required fields"); - const data = loadFixtureJSON("batch-state-missing-fields.json"); - assertThrows( - () => validatePersistedState(data), - "STATE_SCHEMA_INVALID", - "missing fields throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects invalid phase enum"); - const data = loadFixtureJSON("batch-state-bad-enums.json"); - assertThrows( - () => validatePersistedState(data), - "STATE_SCHEMA_INVALID", - "bad phase enum throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects invalid task status enum"); - const data = loadFixtureJSON("batch-state-bad-task-status.json"); - assertThrows( - () => validatePersistedState(data), - "STATE_SCHEMA_INVALID", - "bad task status throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects missing schemaVersion"); - assertThrows( - () => validatePersistedState({ phase: "idle", batchId: "test" }), - "STATE_SCHEMA_INVALID", - "missing schemaVersion throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects non-number schemaVersion"); - assertThrows( - () => validatePersistedState({ schemaVersion: "one", phase: "idle", batchId: "test" }), - "STATE_SCHEMA_INVALID", - "string schemaVersion throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects v2 state missing required mode field"); - // A v2 file without mode should be rejected (mode is required in v2). - // v1 files are allowed to omit mode (backfilled to "repo" via upconvert). - const v2NoMode = { - schemaVersion: 2, - phase: "executing", - batchId: "20260309T010000", - startedAt: 1741478400000, - updatedAt: 1741478460000, - endedAt: null, - currentWaveIndex: 0, - totalWaves: 1, - wavePlan: [["TS-001"]], - lanes: [], - tasks: [], - mergeResults: [], - totalTasks: 1, - succeededTasks: 0, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: [], - lastError: null, - errors: [], - }; - assertThrows( - () => validatePersistedState(v2NoMode), - "STATE_SCHEMA_INVALID", - "v2 state without mode throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ accepts v1 state and upconverts mode to 'repo'"); - const v1Data = loadFixtureJSON("batch-state-v1-valid.json"); - const result = validatePersistedState(v1Data); - assertEqual(result.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v1 upconverted to v2 schemaVersion"); - assertEqual(result.mode, "repo", "v1 mode defaults to 'repo'"); - assertEqual(result.baseBranch, "", "v1 baseBranch defaults to ''"); - // Verify task/lane records survived upconversion intact - assertEqual(result.tasks.length, 3, "v1 upconvert: 3 task records preserved"); - assertEqual(result.lanes.length, 2, "v1 upconvert: 2 lane records preserved"); - assertEqual(result.tasks[0].taskId, "TS-001", "v1 upconvert: task TS-001 preserved"); - assertEqual(result.tasks[0].status, "succeeded", "v1 upconvert: task status preserved"); - // v1 tasks should not have repo fields - assertEqual(result.tasks[0].repoId, undefined, "v1 upconvert: task repoId is undefined"); - assertEqual(result.tasks[0].resolvedRepoId, undefined, "v1 upconvert: task resolvedRepoId is undefined"); - // v1 lanes should not have repoId - assertEqual(result.lanes[0].repoId, undefined, "v1 upconvert: lane repoId is undefined"); -} - -{ - console.log(" ▸ validates v2 workspace-mode state with repo-aware fields"); - const wsData = loadFixtureJSON("batch-state-v2-workspace.json"); - const result = validatePersistedState(wsData); - assertEqual(result.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2 workspace: schemaVersion is 2"); - assertEqual(result.mode, "workspace", "v2 workspace: mode is 'workspace'"); - assertEqual(result.baseBranch, "main", "v2 workspace: baseBranch preserved"); - // Task repo fields - assertEqual(result.tasks.length, 2, "v2 workspace: 2 task records"); - assertEqual(result.tasks[0].taskId, "WS-001", "v2 workspace: task WS-001"); - assertEqual(result.tasks[0].repoId, "api", "v2 workspace: task[0].repoId is 'api'"); - assertEqual(result.tasks[0].resolvedRepoId, "api", "v2 workspace: task[0].resolvedRepoId is 'api'"); - // WS-002 has no repoId but has resolvedRepoId (area/workspace default fallback) - assertEqual(result.tasks[1].repoId, undefined, "v2 workspace: task[1].repoId is undefined"); - assertEqual(result.tasks[1].resolvedRepoId, "frontend", "v2 workspace: task[1].resolvedRepoId is 'frontend'"); - // Lane repo fields - assertEqual(result.lanes.length, 2, "v2 workspace: 2 lane records"); - assertEqual(result.lanes[0].repoId, "api", "v2 workspace: lane[0].repoId is 'api'"); - assertEqual(result.lanes[1].repoId, "frontend", "v2 workspace: lane[1].repoId is 'frontend'"); -} - -{ - console.log(" ▸ rejects non-string repoId on task record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].repoId = 42; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "numeric task repoId throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects non-string resolvedRepoId on task record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].resolvedRepoId = true; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "boolean task resolvedRepoId throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects non-string repoId on lane record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.lanes[0].repoId = 99; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "numeric lane repoId throws STATE_SCHEMA_INVALID", - ); -} - -// ── Step 1: Additional malformed repo-aware record validation ──────── - -{ - console.log(" ▸ rejects null repoId on task record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].repoId = null; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "null task repoId throws STATE_SCHEMA_INVALID", - ); -} + console.log("\n── 1.1: validatePersistedState ──"); -{ - console.log(" ▸ rejects null resolvedRepoId on task record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].resolvedRepoId = null; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "null task resolvedRepoId throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ validates a well-formed state file"); + const data = loadFixtureJSON("batch-state-valid.json"); + const result = validatePersistedState(data); + assertEqual(result.schemaVersion, 2, "schemaVersion is 2"); + assertEqual(result.phase, "executing", "phase is executing"); + assertEqual(result.batchId, "20260309T010000", "batchId matches"); + assertEqual(result.totalTasks, 3, "totalTasks is 3"); + assertEqual(result.tasks.length, 3, "3 task records"); + assertEqual(result.lanes.length, 2, "2 lane records"); + assertEqual(result.wavePlan.length, 2, "2 waves in plan"); + } -{ - console.log(" ▸ rejects object repoId on task record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].repoId = { nested: "object" }; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "object task repoId throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects null input"); + assertThrows( + () => validatePersistedState(null), + "STATE_SCHEMA_INVALID", + "null input throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ rejects array resolvedRepoId on task record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].resolvedRepoId = ["api", "frontend"]; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "array task resolvedRepoId throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects non-object input"); + assertThrows( + () => validatePersistedState("not an object"), + "STATE_SCHEMA_INVALID", + "string input throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ rejects null repoId on lane record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.lanes[0].repoId = null; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "null lane repoId throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects wrong schema version"); + const data = loadFixtureJSON("batch-state-wrong-version.json"); + assertThrows( + () => validatePersistedState(data), + "STATE_SCHEMA_INVALID", + "wrong version throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ rejects object repoId on lane record"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.lanes[0].repoId = { repo: "api" }; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "object lane repoId throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects missing required fields"); + const data = loadFixtureJSON("batch-state-missing-fields.json"); + assertThrows( + () => validatePersistedState(data), + "STATE_SCHEMA_INVALID", + "missing fields throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ accepts empty-string repoId on task record (structurally valid)"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks[0].repoId = ""; - const result = validatePersistedState(validBase); - assertEqual(result.tasks[0].repoId, "", "empty-string repoId accepted"); -} + { + console.log(" ▸ rejects invalid phase enum"); + const data = loadFixtureJSON("batch-state-bad-enums.json"); + assertThrows( + () => validatePersistedState(data), + "STATE_SCHEMA_INVALID", + "bad phase enum throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ accepts empty-string repoId on lane record (structurally valid)"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.lanes[0].repoId = ""; - const result = validatePersistedState(validBase); - assertEqual(result.lanes[0].repoId, "", "empty-string lane repoId accepted"); -} + { + console.log(" ▸ rejects invalid task status enum"); + const data = loadFixtureJSON("batch-state-bad-task-status.json"); + assertThrows( + () => validatePersistedState(data), + "STATE_SCHEMA_INVALID", + "bad task status throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ rejects invalid mode value (not repo or workspace)"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.mode = "polyrepo"; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "invalid mode value throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects missing schemaVersion"); + assertThrows( + () => validatePersistedState({ phase: "idle", batchId: "test" }), + "STATE_SCHEMA_INVALID", + "missing schemaVersion throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ rejects numeric mode value"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.mode = 42; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "numeric mode throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects non-number schemaVersion"); + assertThrows( + () => validatePersistedState({ schemaVersion: "one", phase: "idle", batchId: "test" }), + "STATE_SCHEMA_INVALID", + "string schemaVersion throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ rejects boolean mode value"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.mode = true; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "boolean mode throws STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ rejects v2 state missing required mode field"); + // A v2 file without mode should be rejected (mode is required in v2). + // v1 files are allowed to omit mode (backfilled to "repo" via upconvert). + const v2NoMode = { + schemaVersion: 2, + phase: "executing", + batchId: "20260309T010000", + startedAt: 1741478400000, + updatedAt: 1741478460000, + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + wavePlan: [["TS-001"]], + lanes: [], + tasks: [], + mergeResults: [], + totalTasks: 1, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: [], + lastError: null, + errors: [], + }; + assertThrows( + () => validatePersistedState(v2NoMode), + "STATE_SCHEMA_INVALID", + "v2 state without mode throws STATE_SCHEMA_INVALID", + ); + } -{ - console.log(" ▸ validates fixture batch-state-v2-bad-repo-fields.json rejects at first bad field"); - const data = loadFixtureJSON("batch-state-v2-bad-repo-fields.json"); - assertThrows( - () => validatePersistedState(data), - "STATE_SCHEMA_INVALID", - "bad-repo-fields fixture rejected with STATE_SCHEMA_INVALID", - ); -} + { + console.log(" ▸ accepts v1 state and upconverts mode to 'repo'"); + const v1Data = loadFixtureJSON("batch-state-v1-valid.json"); + const result = validatePersistedState(v1Data); + assertEqual( + result.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "v1 upconverted to v2 schemaVersion", + ); + assertEqual(result.mode, "repo", "v1 mode defaults to 'repo'"); + assertEqual(result.baseBranch, "", "v1 baseBranch defaults to ''"); + // Verify task/lane records survived upconversion intact + assertEqual(result.tasks.length, 3, "v1 upconvert: 3 task records preserved"); + assertEqual(result.lanes.length, 2, "v1 upconvert: 2 lane records preserved"); + assertEqual(result.tasks[0].taskId, "TS-001", "v1 upconvert: task TS-001 preserved"); + assertEqual(result.tasks[0].status, "succeeded", "v1 upconvert: task status preserved"); + // v1 tasks should not have repo fields + assertEqual(result.tasks[0].repoId, undefined, "v1 upconvert: task repoId is undefined"); + assertEqual( + result.tasks[0].resolvedRepoId, + undefined, + "v1 upconvert: task resolvedRepoId is undefined", + ); + // v1 lanes should not have repoId + assertEqual(result.lanes[0].repoId, undefined, "v1 upconvert: lane repoId is undefined"); + } -{ - console.log(" ▸ accepts repo-mode state without any repo fields on tasks/lanes"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - // Confirm no repo fields present - assertEqual(validBase.tasks[0].repoId, undefined, "repo-mode task has no repoId"); - assertEqual(validBase.tasks[0].resolvedRepoId, undefined, "repo-mode task has no resolvedRepoId"); - assertEqual(validBase.lanes[0].repoId, undefined, "repo-mode lane has no repoId"); - const result = validatePersistedState(validBase); - assertEqual(result.mode, "repo", "repo mode validated"); - assertEqual(result.tasks.length, 3, "all tasks preserved"); -} + { + console.log(" ▸ validates v2 workspace-mode state with repo-aware fields"); + const wsData = loadFixtureJSON("batch-state-v2-workspace.json"); + const result = validatePersistedState(wsData); + assertEqual(result.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2 workspace: schemaVersion is 2"); + assertEqual(result.mode, "workspace", "v2 workspace: mode is 'workspace'"); + assertEqual(result.baseBranch, "main", "v2 workspace: baseBranch preserved"); + // Task repo fields + assertEqual(result.tasks.length, 2, "v2 workspace: 2 task records"); + assertEqual(result.tasks[0].taskId, "WS-001", "v2 workspace: task WS-001"); + assertEqual(result.tasks[0].repoId, "api", "v2 workspace: task[0].repoId is 'api'"); + assertEqual( + result.tasks[0].resolvedRepoId, + "api", + "v2 workspace: task[0].resolvedRepoId is 'api'", + ); + // WS-002 has no repoId but has resolvedRepoId (area/workspace default fallback) + assertEqual(result.tasks[1].repoId, undefined, "v2 workspace: task[1].repoId is undefined"); + assertEqual( + result.tasks[1].resolvedRepoId, + "frontend", + "v2 workspace: task[1].resolvedRepoId is 'frontend'", + ); + // Lane repo fields + assertEqual(result.lanes.length, 2, "v2 workspace: 2 lane records"); + assertEqual(result.lanes[0].repoId, "api", "v2 workspace: lane[0].repoId is 'api'"); + assertEqual(result.lanes[1].repoId, "frontend", "v2 workspace: lane[1].repoId is 'frontend'"); + } -{ - console.log(" ▸ validates all 9 batch phases"); - const phases = ["idle", "launching", "planning", "executing", "merging", "paused", "stopped", "completed", "failed"]; - let allValid = true; - for (const phase of phases) { + { + console.log(" ▸ rejects non-string repoId on task record"); const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.phase = phase; - try { - validatePersistedState(validBase); - } catch { - allValid = false; - } + validBase.tasks[0].repoId = 42; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "numeric task repoId throws STATE_SCHEMA_INVALID", + ); } - assert(allValid, "all 8 valid phases accepted"); -} -{ - console.log(" ▸ validates all 6 task statuses"); - const statuses = ["pending", "running", "succeeded", "failed", "stalled", "skipped"]; - let allValid = true; - for (const status of statuses) { + { + console.log(" ▸ rejects non-string resolvedRepoId on task record"); const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.tasks = [{ - taskId: "T-001", laneNumber: 1, sessionName: "s", status, - taskFolder: "/tmp", startedAt: null, endedAt: null, - doneFileFound: false, exitReason: "", - }]; - try { - validatePersistedState(validBase); - } catch { - allValid = false; - } + validBase.tasks[0].resolvedRepoId = true; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "boolean task resolvedRepoId throws STATE_SCHEMA_INVALID", + ); } - assert(allValid, "all 6 valid task statuses accepted"); -} - -{ - console.log(" ▸ rejects bad merge result status"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.mergeResults = [{ waveIndex: 0, status: "exploded", failedLane: null, failureReason: null }]; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "bad merge status throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects lastError with missing code"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.lastError = { message: "oops" }; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "lastError without code throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects non-string in blockedTaskIds"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.blockedTaskIds = [42]; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "non-string blockedTaskId throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ rejects non-string in errors array"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.errors = [123]; - assertThrows( - () => validatePersistedState(validBase), - "STATE_SCHEMA_INVALID", - "non-string error throws STATE_SCHEMA_INVALID", - ); -} - -{ - console.log(" ▸ accepts valid state with endedAt = number"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.phase = "completed"; - validBase.endedAt = 1741478500000; - const result = validatePersistedState(validBase); - assertEqual(result.endedAt, 1741478500000, "endedAt accepted as number"); -} - -{ - console.log(" ▸ accepts valid state with lastError present"); - const validBase = JSON.parse(loadFixture("batch-state-valid.json")); - validBase.lastError = { code: "BATCH_ERROR", message: "something went wrong" }; - const result = validatePersistedState(validBase); - assertEqual(result.lastError.code, "BATCH_ERROR", "lastError.code preserved"); -} - -// ═══════════════════════════════════════════════════════════════════════ -// 1.2: serializeBatchState round-trip -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 1.2: serializeBatchState round-trip ──"); - -{ - console.log(" ▸ serialize → parse → validate round-trip"); - - // Build a minimal runtime state to serialize - // (We simulate what serializeBatchState produces by building the expected JSON) - const runtimeLanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1-20260309T020000", - tasks: [{ taskId: "X-001", parsedTask: null, weight: 2, estimatedMinutes: 10 }], - strategy: "affinity-first" as const, - estimatedLoad: 2, - estimatedMinutes: 10, - }, - ]; - - const taskOutcomes = [ - { - taskId: "X-001", - status: "succeeded" as const, - startTime: 1000, - endTime: 2000, - exitReason: "done", - sessionName: "orch-lane-1", - doneFileFound: true, - }, - ]; - - // Build the expected serialized structure manually (mirroring serializeBatchState logic) - const persisted = { - schemaVersion: BATCH_STATE_SCHEMA_VERSION, - phase: "completed", - batchId: "20260309T020000", - mode: "repo", - startedAt: 900, - updatedAt: Date.now(), // Will be close to now - endedAt: 2500, - currentWaveIndex: 0, - totalWaves: 1, - wavePlan: [["X-001"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1-20260309T020000", - taskIds: ["X-001"], - }], - tasks: [{ - taskId: "X-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "succeeded", - taskFolder: "", - startedAt: 1000, - endedAt: 2000, - doneFileFound: true, - exitReason: "done", - }], - mergeResults: [], - totalTasks: 1, - succeededTasks: 1, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: [], - lastError: null, - errors: [], - }; - - const json = JSON.stringify(persisted, null, 2); - const parsed = JSON.parse(json); - - // Validate the round-tripped data - const validated = validatePersistedState(parsed); - assertEqual(validated.phase, "completed", "round-trip: phase preserved"); - assertEqual(validated.batchId, "20260309T020000", "round-trip: batchId preserved"); - assertEqual(validated.tasks.length, 1, "round-trip: 1 task record"); - assertEqual(validated.tasks[0].status, "succeeded", "round-trip: task status preserved"); - assertEqual(validated.lanes.length, 1, "round-trip: 1 lane record"); - assertEqual(validated.wavePlan[0][0], "X-001", "round-trip: wavePlan preserved"); -} - -// ═══════════════════════════════════════════════════════════════════════ -// 1.3: File I/O operations (save/load/delete) -// ═══════════════════════════════════════════════════════════════════════ -console.log("\n── 1.3: File I/O operations ──"); + { + console.log(" ▸ rejects non-string repoId on lane record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.lanes[0].repoId = 99; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "numeric lane repoId throws STATE_SCHEMA_INVALID", + ); + } -// Create a temp directory for file I/O tests -const testRoot = join(tmpdir(), `orch-state-test-${Date.now()}`); -mkdirSync(join(testRoot, ".pi"), { recursive: true }); + // ── Step 1: Additional malformed repo-aware record validation ──────── -try { { - console.log(" ▸ saveBatchState creates file"); - const validJson = loadFixture("batch-state-valid.json"); - saveBatchState(validJson, testRoot); - assert(existsSync(batchStatePath(testRoot)), "state file exists after save"); + console.log(" ▸ rejects null repoId on task record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.tasks[0].repoId = null; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "null task repoId throws STATE_SCHEMA_INVALID", + ); } { - console.log(" ▸ loadBatchState reads valid file"); - const result = loadBatchState(testRoot); - assert(result !== null, "loadBatchState returns non-null"); - assertEqual(result!.batchId, "20260309T010000", "loaded batchId matches"); - assertEqual(result!.phase, "executing", "loaded phase matches"); + console.log(" ▸ rejects null resolvedRepoId on task record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.tasks[0].resolvedRepoId = null; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "null task resolvedRepoId throws STATE_SCHEMA_INVALID", + ); } { - console.log(" ▸ loadBatchState returns null for missing file"); - const emptyRoot = join(tmpdir(), `orch-state-empty-${Date.now()}`); - mkdirSync(join(emptyRoot, ".pi"), { recursive: true }); - const result = loadBatchState(emptyRoot); - assertEqual(result, null, "returns null when file missing"); - rmSync(emptyRoot, { recursive: true, force: true }); + console.log(" ▸ rejects object repoId on task record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.tasks[0].repoId = { nested: "object" }; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "object task repoId throws STATE_SCHEMA_INVALID", + ); } { - console.log(" ▸ loadBatchState throws on malformed JSON"); - const malformedRoot = join(tmpdir(), `orch-state-malformed-${Date.now()}`); - mkdirSync(join(malformedRoot, ".pi"), { recursive: true }); - writeFileSync(batchStatePath(malformedRoot), "{ not json }", "utf-8"); + console.log(" ▸ rejects array resolvedRepoId on task record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.tasks[0].resolvedRepoId = ["api", "frontend"]; assertThrows( - () => loadBatchState(malformedRoot), - "STATE_FILE_PARSE_ERROR", - "malformed JSON throws STATE_FILE_PARSE_ERROR", + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "array task resolvedRepoId throws STATE_SCHEMA_INVALID", ); - rmSync(malformedRoot, { recursive: true, force: true }); } { - console.log(" ▸ loadBatchState throws on valid JSON with bad schema"); - const badSchemaRoot = join(tmpdir(), `orch-state-badschema-${Date.now()}`); - mkdirSync(join(badSchemaRoot, ".pi"), { recursive: true }); - writeFileSync(batchStatePath(badSchemaRoot), JSON.stringify({ schemaVersion: 99 }), "utf-8"); + console.log(" ▸ rejects null repoId on lane record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.lanes[0].repoId = null; assertThrows( - () => loadBatchState(badSchemaRoot), + () => validatePersistedState(validBase), "STATE_SCHEMA_INVALID", - "bad schema throws STATE_SCHEMA_INVALID", + "null lane repoId throws STATE_SCHEMA_INVALID", ); - rmSync(badSchemaRoot, { recursive: true, force: true }); } { - console.log(" ▸ deleteBatchState removes file"); - assert(existsSync(batchStatePath(testRoot)), "state file exists before delete"); - deleteBatchState(testRoot); - assert(!existsSync(batchStatePath(testRoot)), "state file removed after delete"); + console.log(" ▸ rejects object repoId on lane record"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.lanes[0].repoId = { repo: "api" }; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "object lane repoId throws STATE_SCHEMA_INVALID", + ); } { - console.log(" ▸ deleteBatchState is idempotent (no error on missing file)"); - deleteBatchState(testRoot); // Already deleted - passed++; // If we get here, no error was thrown + console.log(" ▸ accepts empty-string repoId on task record (structurally valid)"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.tasks[0].repoId = ""; + const result = validatePersistedState(validBase); + assertEqual(result.tasks[0].repoId, "", "empty-string repoId accepted"); } { - console.log(" ▸ saveBatchState creates .pi directory if missing"); - const freshRoot = join(tmpdir(), `orch-state-fresh-${Date.now()}`); - mkdirSync(freshRoot, { recursive: true }); - // .pi directory doesn't exist yet - const validJson = loadFixture("batch-state-valid.json"); - saveBatchState(validJson, freshRoot); - assert(existsSync(batchStatePath(freshRoot)), "state file created with .pi dir"); - rmSync(freshRoot, { recursive: true, force: true }); + console.log(" ▸ accepts empty-string repoId on lane record (structurally valid)"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.lanes[0].repoId = ""; + const result = validatePersistedState(validBase); + assertEqual(result.lanes[0].repoId, "", "empty-string lane repoId accepted"); } -} finally { - // Cleanup temp directory - try { - rmSync(testRoot, { recursive: true, force: true }); - } catch { /* best effort */ } -} - -// ═══════════════════════════════════════════════════════════════════════ -// 1.4: Schema v1 → v2 Compatibility (loadBatchState regression tests) -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 1.4: Schema v1 → v2 compatibility (loadBatchState regression) ──"); - -// Create a temp directory for v1 compat tests -const v1CompatRoot = join(tmpdir(), `orch-v1compat-test-${Date.now()}`); -mkdirSync(join(v1CompatRoot, ".pi"), { recursive: true }); - -try { - { - console.log(" ▸ loadBatchState with v1 fixture upconverts to v2 in-memory"); - const v1Json = loadFixture("batch-state-v1-valid.json"); - saveBatchState(v1Json, v1CompatRoot); - - const loaded = loadBatchState(v1CompatRoot); - assert(loaded !== null, "v1 state loaded successfully"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v1 upconverted: schemaVersion is 2"); - assertEqual(loaded!.mode, "repo", "v1 upconverted: mode defaults to 'repo'"); - assertEqual(loaded!.baseBranch, "", "v1 upconverted: baseBranch defaults to ''"); - // Verify records preserved - assertEqual(loaded!.tasks.length, 3, "v1 upconverted: 3 task records preserved"); - assertEqual(loaded!.lanes.length, 2, "v1 upconverted: 2 lane records preserved"); - assertEqual(loaded!.wavePlan.length, 2, "v1 upconverted: 2 waves preserved"); - // Verify task details - assertEqual(loaded!.tasks[0].taskId, "TS-001", "v1 upconverted: task TS-001 preserved"); - assertEqual(loaded!.tasks[0].status, "succeeded", "v1 upconverted: task status preserved"); - assertEqual(loaded!.tasks[0].taskFolder, "/tmp/tasks/TS-001", "v1 upconverted: taskFolder preserved"); - assertEqual(loaded!.tasks[0].doneFileFound, true, "v1 upconverted: doneFileFound preserved"); - // Verify v2 optional repo fields absent - assertEqual(loaded!.tasks[0].repoId, undefined, "v1 upconverted: task repoId is undefined"); - assertEqual(loaded!.tasks[0].resolvedRepoId, undefined, "v1 upconverted: task resolvedRepoId is undefined"); - assertEqual(loaded!.lanes[0].repoId, undefined, "v1 upconverted: lane repoId is undefined"); - // Verify lane details - assertEqual(loaded!.lanes[0].laneId, "lane-1", "v1 upconverted: lane-1 laneId preserved"); - assertEqual(loaded!.lanes[0].laneSessionId, "orch-lane-1", "v1 upconverted: lane-1 sessionName preserved"); - assertEqual(loaded!.lanes[0].taskIds.length, 1, "v1 upconverted: lane-1 taskIds preserved"); - // Verify top-level fields - assertEqual(loaded!.phase, "executing", "v1 upconverted: phase preserved"); - assertEqual(loaded!.batchId, "20260309T010000", "v1 upconverted: batchId preserved"); - assertEqual(loaded!.totalTasks, 3, "v1 upconverted: totalTasks preserved"); - assertEqual(loaded!.succeededTasks, 1, "v1 upconverted: succeededTasks preserved"); - } - - { - console.log(" ▸ loadBatchState with v1 fixture does NOT rewrite on-disk file"); - // Save a fresh v1 fixture to disk - const v1Json = loadFixture("batch-state-v1-valid.json"); - saveBatchState(v1Json, v1CompatRoot); - - // Read on-disk content before load - const onDiskBefore = readFileSync(batchStatePath(v1CompatRoot), "utf-8"); - const parsedBefore = JSON.parse(onDiskBefore); - assertEqual(parsedBefore.schemaVersion, 1, "on-disk before load: schemaVersion is 1"); - - // Load (which upconverts in-memory) - const loaded = loadBatchState(v1CompatRoot); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "in-memory: schemaVersion is 2"); - - // Read on-disk content after load — must remain v1 - const onDiskAfter = readFileSync(batchStatePath(v1CompatRoot), "utf-8"); - const parsedAfter = JSON.parse(onDiskAfter); - assertEqual(parsedAfter.schemaVersion, 1, "on-disk after load: schemaVersion is still 1 (no implicit rewrite)"); - assertEqual(parsedAfter.mode, undefined, "on-disk after load: mode field absent (v1 had no mode)"); - - // Verify byte-level equality — file content unchanged - assertEqual(onDiskBefore, onDiskAfter, "on-disk file content unchanged after loadBatchState"); - } - - { - console.log(" ▸ loadBatchState with v2 repo-mode fixture preserves all fields"); - const v2Json = loadFixture("batch-state-valid.json"); - saveBatchState(v2Json, v1CompatRoot); - - const loaded = loadBatchState(v1CompatRoot); - assert(loaded !== null, "v2 repo-mode state loaded successfully"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2: schemaVersion is 2"); - assertEqual(loaded!.mode, "repo", "v2: mode is 'repo'"); - assertEqual(loaded!.baseBranch, "main", "v2: baseBranch is 'main'"); - assertEqual(loaded!.phase, "executing", "v2: phase preserved"); - assertEqual(loaded!.batchId, "20260309T010000", "v2: batchId preserved"); - assertEqual(loaded!.tasks.length, 3, "v2: 3 task records"); - assertEqual(loaded!.lanes.length, 2, "v2: 2 lane records"); - assertEqual(loaded!.wavePlan.length, 2, "v2: 2 waves"); - // Confirm no repo fields on repo-mode fixture - assertEqual(loaded!.tasks[0].repoId, undefined, "v2 repo-mode: task has no repoId"); - assertEqual(loaded!.lanes[0].repoId, undefined, "v2 repo-mode: lane has no repoId"); - } - - { - console.log(" ▸ loadBatchState with v2 workspace-mode fixture preserves repo-aware fields"); - const wsJson = loadFixture("batch-state-v2-workspace.json"); - saveBatchState(wsJson, v1CompatRoot); - - const loaded = loadBatchState(v1CompatRoot); - assert(loaded !== null, "v2 workspace state loaded successfully"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2 workspace: schemaVersion is 2"); - assertEqual(loaded!.mode, "workspace", "v2 workspace: mode is 'workspace'"); - assertEqual(loaded!.baseBranch, "main", "v2 workspace: baseBranch preserved"); - // Task repo-aware fields - assertEqual(loaded!.tasks.length, 2, "v2 workspace: 2 task records"); - assertEqual(loaded!.tasks[0].taskId, "WS-001", "v2 workspace: task WS-001"); - assertEqual(loaded!.tasks[0].repoId, "api", "v2 workspace: task[0].repoId is 'api'"); - assertEqual(loaded!.tasks[0].resolvedRepoId, "api", "v2 workspace: task[0].resolvedRepoId is 'api'"); - assertEqual(loaded!.tasks[1].repoId, undefined, "v2 workspace: task[1].repoId is undefined"); - assertEqual(loaded!.tasks[1].resolvedRepoId, "frontend", "v2 workspace: task[1].resolvedRepoId is 'frontend'"); - // Lane repo-aware fields - assertEqual(loaded!.lanes[0].repoId, "api", "v2 workspace: lane[0].repoId is 'api'"); - assertEqual(loaded!.lanes[1].repoId, "frontend", "v2 workspace: lane[1].repoId is 'frontend'"); - } - - { - console.log(" ▸ loadBatchState rejects unsupported schema version (99)"); - const wrongVersionJson = loadFixture("batch-state-wrong-version.json"); - saveBatchState(wrongVersionJson, v1CompatRoot); - + { + console.log(" ▸ rejects invalid mode value (not repo or workspace)"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.mode = "polyrepo"; assertThrows( - () => loadBatchState(v1CompatRoot), + () => validatePersistedState(validBase), "STATE_SCHEMA_INVALID", - "unsupported schema version throws STATE_SCHEMA_INVALID via loadBatchState", + "invalid mode value throws STATE_SCHEMA_INVALID", ); } { - console.log(" ▸ loadBatchState rejects malformed JSON"); - const malformedRoot = join(tmpdir(), `orch-v1compat-malformed-${Date.now()}`); - mkdirSync(join(malformedRoot, ".pi"), { recursive: true }); - writeFileSync(batchStatePath(malformedRoot), "{ this is not valid json }", "utf-8"); - + console.log(" ▸ rejects numeric mode value"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.mode = 42; assertThrows( - () => loadBatchState(malformedRoot), - "STATE_FILE_PARSE_ERROR", - "malformed JSON throws STATE_FILE_PARSE_ERROR via loadBatchState", + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "numeric mode throws STATE_SCHEMA_INVALID", ); - rmSync(malformedRoot, { recursive: true, force: true }); } { - console.log(" ▸ loadBatchState rejects v2 state missing required mode field"); - // Build a v2 state that has all fields except mode - const v2NoMode = JSON.parse(loadFixture("batch-state-valid.json")); - delete v2NoMode.mode; // Remove the mode field — v2 requires it - const v2NoModeRoot = join(tmpdir(), `orch-v1compat-nomode-${Date.now()}`); - mkdirSync(join(v2NoModeRoot, ".pi"), { recursive: true }); - writeFileSync(batchStatePath(v2NoModeRoot), JSON.stringify(v2NoMode, null, 2), "utf-8"); - + console.log(" ▸ rejects boolean mode value"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.mode = true; assertThrows( - () => loadBatchState(v2NoModeRoot), + () => validatePersistedState(validBase), "STATE_SCHEMA_INVALID", - "v2 without mode throws STATE_SCHEMA_INVALID via loadBatchState", + "boolean mode throws STATE_SCHEMA_INVALID", ); - rmSync(v2NoModeRoot, { recursive: true, force: true }); } { - console.log(" ▸ v1 → save → load round-trip produces v2 on disk"); - // Load a v1 file (in-memory upconvert to v2), then save (writes v2 to disk) - const v1Json = loadFixture("batch-state-v1-valid.json"); - saveBatchState(v1Json, v1CompatRoot); - const loaded = loadBatchState(v1CompatRoot); - assert(loaded !== null, "v1 loaded for round-trip"); - - // Now save the in-memory v2 state back — this simulates what happens on - // resume: loadBatchState → modify → persistRuntimeState → saveBatchState - const v2Json = JSON.stringify(loaded, null, 2); - saveBatchState(v2Json, v1CompatRoot); + console.log( + " ▸ validates fixture batch-state-v2-bad-repo-fields.json rejects at first bad field", + ); + const data = loadFixtureJSON("batch-state-v2-bad-repo-fields.json"); + assertThrows( + () => validatePersistedState(data), + "STATE_SCHEMA_INVALID", + "bad-repo-fields fixture rejected with STATE_SCHEMA_INVALID", + ); + } - // Verify on-disk is now v2 - const onDisk = readFileSync(batchStatePath(v1CompatRoot), "utf-8"); - const parsed = JSON.parse(onDisk); - assertEqual(parsed.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "round-trip: on-disk schemaVersion is 2 after save"); - assertEqual(parsed.mode, "repo", "round-trip: on-disk mode is 'repo' after save"); - assertEqual(parsed.baseBranch, "", "round-trip: on-disk baseBranch is '' after save"); + { + console.log(" ▸ accepts repo-mode state without any repo fields on tasks/lanes"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + // Confirm no repo fields present + assertEqual(validBase.tasks[0].repoId, undefined, "repo-mode task has no repoId"); + assertEqual(validBase.tasks[0].resolvedRepoId, undefined, "repo-mode task has no resolvedRepoId"); + assertEqual(validBase.lanes[0].repoId, undefined, "repo-mode lane has no repoId"); + const result = validatePersistedState(validBase); + assertEqual(result.mode, "repo", "repo mode validated"); + assertEqual(result.tasks.length, 3, "all tasks preserved"); + } - // Reload and verify - const reloaded = loadBatchState(v1CompatRoot); - assertEqual(reloaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "round-trip: reloaded schemaVersion is 2"); - assertEqual(reloaded!.mode, "repo", "round-trip: reloaded mode is 'repo'"); - assertEqual(reloaded!.tasks.length, 3, "round-trip: reloaded task records preserved"); + { + console.log(" ▸ validates all 9 batch phases"); + const phases = [ + "idle", + "launching", + "planning", + "executing", + "merging", + "paused", + "stopped", + "completed", + "failed", + ]; + let allValid = true; + for (const phase of phases) { + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.phase = phase; + try { + validatePersistedState(validBase); + } catch { + allValid = false; + } + } + assert(allValid, "all 8 valid phases accepted"); } -} finally { - try { - rmSync(v1CompatRoot, { recursive: true, force: true }); - } catch { /* best effort */ } -} - -// ═══════════════════════════════════════════════════════════════════════ -// 2.1: persistRuntimeState — integration with state triggers -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 2.1: persistRuntimeState integration tests ──"); - -// Helper: build a minimal valid runtime batch state for persistence tests -interface MinimalBatchState { - phase: string; - batchId: string; - mode: string; - baseBranch: string; - pauseSignal: { paused: boolean }; - waveResults: any[]; - mergeResults: any[]; - currentWaveIndex: number; - totalWaves: number; - blockedTaskIds: Set; - startedAt: number; - endedAt: number | null; - totalTasks: number; - succeededTasks: number; - failedTasks: number; - skippedTasks: number; - blockedTasks: number; - errors: string[]; - currentLanes: any[]; - dependencyGraph: null; -} - -function freshMinimalBatchState(): MinimalBatchState { - return { - phase: "idle", - batchId: "", - mode: "repo", - baseBranch: "", - pauseSignal: { paused: false }, - waveResults: [], - mergeResults: [], - currentWaveIndex: -1, - totalWaves: 0, - blockedTaskIds: new Set(), - startedAt: 0, - endedAt: null, - totalTasks: 0, - succeededTasks: 0, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - errors: [], - currentLanes: [], - dependencyGraph: null, - }; -} - -// Helper: build minimal lane for serialization -function minimalLane(laneNum: number, taskIds: string[], repoId?: string): any { - return { - laneNumber: laneNum, - laneId: `lane-${laneNum}`, - laneSessionId: `orch-lane-${laneNum}`, - worktreePath: `/tmp/wt-${laneNum}`, - branch: `task/lane-${laneNum}-20260309T030000`, - tasks: taskIds.map(id => ({ taskId: id, task: null, order: 0, estimatedMinutes: 10 })), - strategy: "affinity-first", - estimatedLoad: 2, - estimatedMinutes: 10, - ...(repoId !== undefined ? { repoId } : {}), - }; -} - -// Helper: build minimal lane with ParsedTask objects containing repo fields -function minimalLaneWithRepoTasks(laneNum: number, tasks: Array<{ taskId: string; promptRepoId?: string; resolvedRepoId?: string }>, repoId?: string): any { - return { - laneNumber: laneNum, - laneId: `lane-${laneNum}`, - laneSessionId: `orch-lane-${laneNum}`, - worktreePath: `/tmp/wt-${laneNum}`, - branch: `task/lane-${laneNum}-20260309T030000`, - tasks: tasks.map((t, i) => ({ - taskId: t.taskId, - order: i, - estimatedMinutes: 10, - task: { - taskId: t.taskId, - promptRepoId: t.promptRepoId, - resolvedRepoId: t.resolvedRepoId, - }, - })), - strategy: "affinity-first", - estimatedLoad: 2, - estimatedMinutes: 10, - ...(repoId !== undefined ? { repoId } : {}), - }; -} - -// Helper: build minimal task outcome -function minimalOutcome(taskId: string, status: string): any { - return { - taskId, - status, - startTime: 1000, - endTime: 2000, - exitReason: status === "succeeded" ? "done" : "failed", - sessionName: "orch-lane-1", - doneFileFound: status === "succeeded", - }; -} - -// Reimplementation of serializeBatchState (mirrors source for test self-containment) -// v2: Includes repo-aware fields from AllocatedTask.task (ParsedTask) and AllocatedLane -function serializeBatchState( - state: MinimalBatchState, - wavePlan: string[][], - lanes: any[], - allTaskOutcomes: any[], -): string { - const now = Date.now(); - - // Build lookup maps for fast per-task enrichment (mirrors source exactly). - const laneByTaskId = new Map(); - for (const lane of lanes) { - for (const task of lane.tasks) { - laneByTaskId.set(task.taskId, lane); + { + console.log(" ▸ validates all 6 task statuses"); + const statuses = ["pending", "running", "succeeded", "failed", "stalled", "skipped"]; + let allValid = true; + for (const status of statuses) { + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.tasks = [ + { + taskId: "T-001", + laneNumber: 1, + sessionName: "s", + status, + taskFolder: "/tmp", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ]; + try { + validatePersistedState(validBase); + } catch { + allValid = false; + } } + assert(allValid, "all 6 valid task statuses accepted"); } - // Latest outcome wins. - const outcomeByTaskId = new Map(); - for (const outcome of allTaskOutcomes) { - outcomeByTaskId.set(outcome.taskId, outcome); + { + console.log(" ▸ rejects bad merge result status"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.mergeResults = [ + { waveIndex: 0, status: "exploded", failedLane: null, failureReason: null }, + ]; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "bad merge status throws STATE_SCHEMA_INVALID", + ); } - // Build full task registry from wave plan + any outcomes seen so far. - const taskIdSet = new Set(); - for (const wave of wavePlan) { - for (const taskId of wave) taskIdSet.add(taskId); - } - for (const outcome of allTaskOutcomes) { - taskIdSet.add(outcome.taskId); + { + console.log(" ▸ rejects lastError with missing code"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.lastError = { message: "oops" }; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "lastError without code throws STATE_SCHEMA_INVALID", + ); } - // Build allocatedTask lookup for repo field extraction (mirrors source) - const allocatedTaskByTaskId = new Map(); - for (const lane of lanes) { - for (const allocTask of lane.tasks) { - allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane }); - } + { + console.log(" ▸ rejects non-string in blockedTaskIds"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.blockedTaskIds = [42]; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "non-string blockedTaskId throws STATE_SCHEMA_INVALID", + ); } - const taskRecords = [...taskIdSet].sort().map((taskId: string) => { - const lane = laneByTaskId.get(taskId); - const outcome = outcomeByTaskId.get(taskId); - const allocated = allocatedTaskByTaskId.get(taskId); - - const record: any = { - taskId, - laneNumber: lane?.laneNumber ?? 0, - sessionName: outcome?.sessionName || lane?.laneSessionId || "", - status: outcome?.status ?? "pending", - taskFolder: "", - startedAt: outcome?.startTime ?? null, - endedAt: outcome?.endTime ?? null, - doneFileFound: outcome?.doneFileFound ?? false, - exitReason: outcome?.exitReason ?? "", - }; - // v2: Serialize repo-aware fields from the ParsedTask - if (allocated?.allocatedTask.task?.promptRepoId !== undefined) { - record.repoId = allocated.allocatedTask.task.promptRepoId; - } - if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { - record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; - } - return record; - }); - - const laneRecords = lanes.map((lane: any) => { - const record: any = { - laneNumber: lane.laneNumber, - laneId: lane.laneId, - laneSessionId: lane.laneSessionId, - worktreePath: lane.worktreePath, - branch: lane.branch, - taskIds: lane.tasks.map((t: any) => t.taskId), - }; - // v2: Serialize lane repoId - if (lane.repoId !== undefined) { - record.repoId = lane.repoId; - } - return record; - }); - - // Build merge results from actual merge outcomes (accumulated on batchState). - // MergeWaveResult.waveIndex is 1-based (from merge module); normalize to - // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1"). - // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1, - // which would produce -2 without clamping. - const mergeResults = (state.mergeResults || []) - .map((mr: any) => ({ - waveIndex: Math.max(0, mr.waveIndex - 1), - status: mr.status, - failedLane: mr.failedLane, - failureReason: mr.failureReason, - })); - - const persisted = { - schemaVersion: BATCH_STATE_SCHEMA_VERSION, - phase: state.phase, - batchId: state.batchId, - baseBranch: state.baseBranch ?? "", - mode: state.mode ?? "repo", - startedAt: state.startedAt, - updatedAt: now, - endedAt: state.endedAt, - currentWaveIndex: state.currentWaveIndex, - totalWaves: state.totalWaves, - wavePlan, - lanes: laneRecords, - tasks: taskRecords, - mergeResults, - totalTasks: state.totalTasks, - succeededTasks: state.succeededTasks, - failedTasks: state.failedTasks, - skippedTasks: state.skippedTasks, - blockedTasks: state.blockedTasks, - blockedTaskIds: [...state.blockedTaskIds], - lastError: state.errors.length > 0 - ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] } - : null, - errors: [...state.errors], - }; - - return JSON.stringify(persisted, null, 2); -} - -// Reimplementation of persistRuntimeState (mirrors source for test self-containment) -// v2: Includes discovery enrichment for repo-aware fields on unallocated tasks -function persistRuntimeState( - reason: string, - batchState: MinimalBatchState, - wavePlan: string[][], - lanes: any[], - allTaskOutcomes: any[], - discovery: { pending: Map } | null, - repoRoot: string, -): void { - try { - const json = serializeBatchState(batchState, wavePlan, lanes, allTaskOutcomes); - - if (discovery) { - const parsed = JSON.parse(json); - for (const taskRecord of parsed.tasks) { - const parsedTask = discovery.pending.get(taskRecord.taskId); - if (parsedTask) { - taskRecord.taskFolder = parsedTask.taskFolder; - // v2: Enrich repo fields for tasks not yet allocated (pending in future waves) - if (taskRecord.repoId === undefined && parsedTask.promptRepoId !== undefined) { - taskRecord.repoId = parsedTask.promptRepoId; - } - if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) { - taskRecord.resolvedRepoId = parsedTask.resolvedRepoId; - } - } - } - const enrichedJson = JSON.stringify(parsed, null, 2); - saveBatchState(enrichedJson, repoRoot); - } else { - saveBatchState(json, repoRoot); - } - } catch (err: unknown) { - const msg = err instanceof StateFileError - ? `[${(err as any).code}] ${(err as any).message}` - : (err instanceof Error ? err.message : String(err)); - batchState.errors.push(`State persistence failed (${reason}): ${msg}`); + { + console.log(" ▸ rejects non-string in errors array"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.errors = [123]; + assertThrows( + () => validatePersistedState(validBase), + "STATE_SCHEMA_INVALID", + "non-string error throws STATE_SCHEMA_INVALID", + ); } -} - -// Create temp root for persistence integration tests -const persistTestRoot = join(tmpdir(), `orch-persist-test-${Date.now()}`); -mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); -try { { - console.log(" ▸ state file created after batch start (phase=executing)"); - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now(); - state.totalWaves = 2; - state.totalTasks = 3; - state.currentWaveIndex = 0; - - const wavePlan = [["T-001", "T-002"], ["T-003"]]; - persistRuntimeState("batch-start", state, wavePlan, [], [], null, persistTestRoot); - - assert(existsSync(batchStatePath(persistTestRoot)), "state file exists after batch-start persist"); - const loaded = loadBatchState(persistTestRoot); - assert(loaded !== null, "loaded state is not null"); - assertEqual(loaded!.phase, "executing", "persisted phase is executing"); - assertEqual(loaded!.batchId, "20260309T030000", "persisted batchId matches"); - assertEqual(loaded!.totalTasks, 3, "persisted totalTasks is 3"); - assertEqual(loaded!.wavePlan.length, 2, "persisted wavePlan has 2 waves"); + console.log(" ▸ accepts valid state with endedAt = number"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.phase = "completed"; + validBase.endedAt = 1741478500000; + const result = validatePersistedState(validBase); + assertEqual(result.endedAt, 1741478500000, "endedAt accepted as number"); } { - console.log(" ▸ state file updated on wave index change"); - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now(); - state.totalWaves = 2; - state.totalTasks = 3; - state.currentWaveIndex = 1; + console.log(" ▸ accepts valid state with lastError present"); + const validBase = JSON.parse(loadFixture("batch-state-valid.json")); + validBase.lastError = { code: "BATCH_ERROR", message: "something went wrong" }; + const result = validatePersistedState(validBase); + assertEqual(result.lastError.code, "BATCH_ERROR", "lastError.code preserved"); + } - const wavePlan = [["T-001", "T-002"], ["T-003"]]; - persistRuntimeState("wave-index-change", state, wavePlan, [], [], null, persistTestRoot); + // ═══════════════════════════════════════════════════════════════════════ + // 1.2: serializeBatchState round-trip + // ═══════════════════════════════════════════════════════════════════════ - const loaded = loadBatchState(persistTestRoot); - assertEqual(loaded!.currentWaveIndex, 1, "waveIndex updated to 1"); - } + console.log("\n── 1.2: serializeBatchState round-trip ──"); { - console.log(" ▸ state file updated after task completion (waveResult accumulated)"); - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 2; - state.currentWaveIndex = 0; - state.succeededTasks = 1; - state.failedTasks = 1; + console.log(" ▸ serialize → parse → validate round-trip"); - const wavePlan = [["T-001", "T-002"]]; - const lanes = [minimalLane(1, ["T-001", "T-002"])]; - const outcomes = [ - minimalOutcome("T-001", "succeeded"), - minimalOutcome("T-002", "failed"), + // Build a minimal runtime state to serialize + // (We simulate what serializeBatchState produces by building the expected JSON) + const runtimeLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-20260309T020000", + tasks: [{ taskId: "X-001", parsedTask: null, weight: 2, estimatedMinutes: 10 }], + strategy: "affinity-first" as const, + estimatedLoad: 2, + estimatedMinutes: 10, + }, + ]; + + const taskOutcomes = [ + { + taskId: "X-001", + status: "succeeded" as const, + startTime: 1000, + endTime: 2000, + exitReason: "done", + sessionName: "orch-lane-1", + doneFileFound: true, + }, ]; - persistRuntimeState("wave-execution-complete", state, wavePlan, lanes, outcomes, null, persistTestRoot); + // Build the expected serialized structure manually (mirroring serializeBatchState logic) + const persisted = { + schemaVersion: BATCH_STATE_SCHEMA_VERSION, + phase: "completed", + batchId: "20260309T020000", + mode: "repo", + startedAt: 900, + updatedAt: Date.now(), // Will be close to now + endedAt: 2500, + currentWaveIndex: 0, + totalWaves: 1, + wavePlan: [["X-001"]], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-20260309T020000", + taskIds: ["X-001"], + }, + ], + tasks: [ + { + taskId: "X-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + taskFolder: "", + startedAt: 1000, + endedAt: 2000, + doneFileFound: true, + exitReason: "done", + }, + ], + mergeResults: [], + totalTasks: 1, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: [], + lastError: null, + errors: [], + }; + + const json = JSON.stringify(persisted, null, 2); + const parsed = JSON.parse(json); - const loaded = loadBatchState(persistTestRoot); - assertEqual(loaded!.succeededTasks, 1, "succeededTasks is 1"); - assertEqual(loaded!.failedTasks, 1, "failedTasks is 1"); - assertEqual(loaded!.tasks.length, 2, "2 task records persisted"); - assertEqual(loaded!.tasks[0].status, "succeeded", "first task succeeded"); - assertEqual(loaded!.tasks[1].status, "failed", "second task failed"); + // Validate the round-tripped data + const validated = validatePersistedState(parsed); + assertEqual(validated.phase, "completed", "round-trip: phase preserved"); + assertEqual(validated.batchId, "20260309T020000", "round-trip: batchId preserved"); + assertEqual(validated.tasks.length, 1, "round-trip: 1 task record"); + assertEqual(validated.tasks[0].status, "succeeded", "round-trip: task status preserved"); + assertEqual(validated.lanes.length, 1, "round-trip: 1 lane record"); + assertEqual(validated.wavePlan[0][0], "X-001", "round-trip: wavePlan preserved"); } - { - console.log(" ▸ state file updated on merge phase transitions"); - const state = freshMinimalBatchState(); - state.phase = "merging"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; - state.currentWaveIndex = 0; + // ═══════════════════════════════════════════════════════════════════════ + // 1.3: File I/O operations (save/load/delete) + // ═══════════════════════════════════════════════════════════════════════ - const wavePlan = [["T-001"]]; - persistRuntimeState("merge-start", state, wavePlan, [], [], null, persistTestRoot); + console.log("\n── 1.3: File I/O operations ──"); - let loaded = loadBatchState(persistTestRoot); - assertEqual(loaded!.phase, "merging", "phase is merging after merge-start"); + // Create a temp directory for file I/O tests + const testRoot = join(tmpdir(), `orch-state-test-${Date.now()}`); + mkdirSync(join(testRoot, ".pi"), { recursive: true }); - // Now simulate merge complete → executing - state.phase = "executing"; - persistRuntimeState("merge-complete", state, wavePlan, [], [], null, persistTestRoot); + try { + { + console.log(" ▸ saveBatchState creates file"); + const validJson = loadFixture("batch-state-valid.json"); + saveBatchState(validJson, testRoot); + assert(existsSync(batchStatePath(testRoot)), "state file exists after save"); + } - loaded = loadBatchState(persistTestRoot); - assertEqual(loaded!.phase, "executing", "phase is executing after merge-complete"); - } + { + console.log(" ▸ loadBatchState reads valid file"); + const result = loadBatchState(testRoot); + assert(result !== null, "loadBatchState returns non-null"); + assertEqual(result!.batchId, "20260309T010000", "loaded batchId matches"); + assertEqual(result!.phase, "executing", "loaded phase matches"); + } - { - console.log(" ▸ state file updated on pause/error with lastError populated"); - const state = freshMinimalBatchState(); - state.phase = "paused"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now(); - state.totalWaves = 2; - state.totalTasks = 3; - state.currentWaveIndex = 0; - state.errors.push("Merge failed at wave 1: conflict unresolved"); + { + console.log(" ▸ loadBatchState returns null for missing file"); + const emptyRoot = join(tmpdir(), `orch-state-empty-${Date.now()}`); + mkdirSync(join(emptyRoot, ".pi"), { recursive: true }); + const result = loadBatchState(emptyRoot); + assertEqual(result, null, "returns null when file missing"); + rmSync(emptyRoot, { recursive: true, force: true }); + } - const wavePlan = [["T-001"], ["T-002", "T-003"]]; - persistRuntimeState("merge-failure-pause", state, wavePlan, [], [], null, persistTestRoot); + { + console.log(" ▸ loadBatchState throws on malformed JSON"); + const malformedRoot = join(tmpdir(), `orch-state-malformed-${Date.now()}`); + mkdirSync(join(malformedRoot, ".pi"), { recursive: true }); + writeFileSync(batchStatePath(malformedRoot), "{ not json }", "utf-8"); + assertThrows( + () => loadBatchState(malformedRoot), + "STATE_FILE_PARSE_ERROR", + "malformed JSON throws STATE_FILE_PARSE_ERROR", + ); + rmSync(malformedRoot, { recursive: true, force: true }); + } - const loaded = loadBatchState(persistTestRoot); - assertEqual(loaded!.phase, "paused", "phase is paused"); - assert(loaded!.lastError !== null, "lastError is populated"); - assertEqual(loaded!.lastError!.code, "BATCH_ERROR", "lastError code is BATCH_ERROR"); - assert(loaded!.lastError!.message.includes("Merge failed"), "lastError message includes merge failure"); - assertEqual(loaded!.errors.length, 1, "1 error in errors array"); - } + { + console.log(" ▸ loadBatchState throws on valid JSON with bad schema"); + const badSchemaRoot = join(tmpdir(), `orch-state-badschema-${Date.now()}`); + mkdirSync(join(badSchemaRoot, ".pi"), { recursive: true }); + writeFileSync(batchStatePath(badSchemaRoot), JSON.stringify({ schemaVersion: 99 }), "utf-8"); + assertThrows( + () => loadBatchState(badSchemaRoot), + "STATE_SCHEMA_INVALID", + "bad schema throws STATE_SCHEMA_INVALID", + ); + rmSync(badSchemaRoot, { recursive: true, force: true }); + } - { - console.log(" ▸ state file deleted on clean batch completion"); - // First, create a state file - const state = freshMinimalBatchState(); - state.phase = "completed"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now() - 60000; - state.endedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; - state.succeededTasks = 1; - state.currentWaveIndex = 0; + { + console.log(" ▸ deleteBatchState removes file"); + assert(existsSync(batchStatePath(testRoot)), "state file exists before delete"); + deleteBatchState(testRoot); + assert(!existsSync(batchStatePath(testRoot)), "state file removed after delete"); + } - const wavePlan = [["T-001"]]; - persistRuntimeState("batch-terminal", state, wavePlan, [], [], null, persistTestRoot); - assert(existsSync(batchStatePath(persistTestRoot)), "state file exists before clean completion"); + { + console.log(" ▸ deleteBatchState is idempotent (no error on missing file)"); + deleteBatchState(testRoot); // Already deleted + passed++; // If we get here, no error was thrown + } - // Simulate clean completion delete - deleteBatchState(persistTestRoot); - assert(!existsSync(batchStatePath(persistTestRoot)), "state file deleted on clean completion"); + { + console.log(" ▸ saveBatchState creates .pi directory if missing"); + const freshRoot = join(tmpdir(), `orch-state-fresh-${Date.now()}`); + mkdirSync(freshRoot, { recursive: true }); + // .pi directory doesn't exist yet + const validJson = loadFixture("batch-state-valid.json"); + saveBatchState(validJson, freshRoot); + assert(existsSync(batchStatePath(freshRoot)), "state file created with .pi dir"); + rmSync(freshRoot, { recursive: true, force: true }); + } + } finally { + // Cleanup temp directory + try { + rmSync(testRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } } - { - console.log(" ▸ write failure does not crash batch (error logged, batch continues)"); - // Use an invalid root path that can't be written to - const invalidRoot = join(tmpdir(), `orch-persist-invalid-${Date.now()}`, "nonexistent", "deep", "path"); - // Don't create the directory — write should fail - - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260309T030000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; + // ═══════════════════════════════════════════════════════════════════════ + // 1.4: Schema v1 → v2 Compatibility (loadBatchState regression tests) + // ═══════════════════════════════════════════════════════════════════════ - // This should NOT throw — errors are caught and added to state.errors - persistRuntimeState("test-write-failure", state, [["T-001"]], [], [], null, invalidRoot); + console.log("\n── 1.4: Schema v1 → v2 compatibility (loadBatchState regression) ──"); - // But wait, saveBatchState creates .pi directory if missing. - // For a truly failing path, we need to use a path that's a file not a dir. - // Let's write a file where the .pi dir should be: - const blockingRoot = join(tmpdir(), `orch-persist-blocked-${Date.now()}`); - mkdirSync(blockingRoot, { recursive: true }); - writeFileSync(join(blockingRoot, ".pi"), "I am a file, not a directory", "utf-8"); + // Create a temp directory for v1 compat tests + const v1CompatRoot = join(tmpdir(), `orch-v1compat-test-${Date.now()}`); + mkdirSync(join(v1CompatRoot, ".pi"), { recursive: true }); - const state2 = freshMinimalBatchState(); - state2.phase = "executing"; - state2.batchId = "20260309T030001"; - state2.startedAt = Date.now(); - state2.totalWaves = 1; - state2.totalTasks = 1; - state2.errors = []; + try { + { + console.log(" ▸ loadBatchState with v1 fixture upconverts to v2 in-memory"); + const v1Json = loadFixture("batch-state-v1-valid.json"); + saveBatchState(v1Json, v1CompatRoot); + + const loaded = loadBatchState(v1CompatRoot); + assert(loaded !== null, "v1 state loaded successfully"); + assertEqual( + loaded!.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "v1 upconverted: schemaVersion is 2", + ); + assertEqual(loaded!.mode, "repo", "v1 upconverted: mode defaults to 'repo'"); + assertEqual(loaded!.baseBranch, "", "v1 upconverted: baseBranch defaults to ''"); + // Verify records preserved + assertEqual(loaded!.tasks.length, 3, "v1 upconverted: 3 task records preserved"); + assertEqual(loaded!.lanes.length, 2, "v1 upconverted: 2 lane records preserved"); + assertEqual(loaded!.wavePlan.length, 2, "v1 upconverted: 2 waves preserved"); + // Verify task details + assertEqual(loaded!.tasks[0].taskId, "TS-001", "v1 upconverted: task TS-001 preserved"); + assertEqual(loaded!.tasks[0].status, "succeeded", "v1 upconverted: task status preserved"); + assertEqual( + loaded!.tasks[0].taskFolder, + "/tmp/tasks/TS-001", + "v1 upconverted: taskFolder preserved", + ); + assertEqual(loaded!.tasks[0].doneFileFound, true, "v1 upconverted: doneFileFound preserved"); + // Verify v2 optional repo fields absent + assertEqual(loaded!.tasks[0].repoId, undefined, "v1 upconverted: task repoId is undefined"); + assertEqual( + loaded!.tasks[0].resolvedRepoId, + undefined, + "v1 upconverted: task resolvedRepoId is undefined", + ); + assertEqual(loaded!.lanes[0].repoId, undefined, "v1 upconverted: lane repoId is undefined"); + // Verify lane details + assertEqual(loaded!.lanes[0].laneId, "lane-1", "v1 upconverted: lane-1 laneId preserved"); + assertEqual( + loaded!.lanes[0].laneSessionId, + "orch-lane-1", + "v1 upconverted: lane-1 sessionName preserved", + ); + assertEqual(loaded!.lanes[0].taskIds.length, 1, "v1 upconverted: lane-1 taskIds preserved"); + // Verify top-level fields + assertEqual(loaded!.phase, "executing", "v1 upconverted: phase preserved"); + assertEqual(loaded!.batchId, "20260309T010000", "v1 upconverted: batchId preserved"); + assertEqual(loaded!.totalTasks, 3, "v1 upconverted: totalTasks preserved"); + assertEqual(loaded!.succeededTasks, 1, "v1 upconverted: succeededTasks preserved"); + } - persistRuntimeState("test-blocked-write", state2, [["T-001"]], [], [], null, blockingRoot); + { + console.log(" ▸ loadBatchState with v1 fixture does NOT rewrite on-disk file"); + // Save a fresh v1 fixture to disk + const v1Json = loadFixture("batch-state-v1-valid.json"); + saveBatchState(v1Json, v1CompatRoot); + + // Read on-disk content before load + const onDiskBefore = readFileSync(batchStatePath(v1CompatRoot), "utf-8"); + const parsedBefore = JSON.parse(onDiskBefore); + assertEqual(parsedBefore.schemaVersion, 1, "on-disk before load: schemaVersion is 1"); + + // Load (which upconverts in-memory) + const loaded = loadBatchState(v1CompatRoot); + assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "in-memory: schemaVersion is 2"); + + // Read on-disk content after load — must remain v1 + const onDiskAfter = readFileSync(batchStatePath(v1CompatRoot), "utf-8"); + const parsedAfter = JSON.parse(onDiskAfter); + assertEqual( + parsedAfter.schemaVersion, + 1, + "on-disk after load: schemaVersion is still 1 (no implicit rewrite)", + ); + assertEqual( + parsedAfter.mode, + undefined, + "on-disk after load: mode field absent (v1 had no mode)", + ); - // The function should not have thrown, but should have logged error - assert(state2.errors.length > 0, "error logged in batch state on write failure"); - assert(state2.errors[0].includes("State persistence failed"), "error message mentions persistence failure"); + // Verify byte-level equality — file content unchanged + assertEqual(onDiskBefore, onDiskAfter, "on-disk file content unchanged after loadBatchState"); + } - // Cleanup - try { rmSync(blockingRoot, { recursive: true, force: true }); } catch { /* best effort */ } - } + { + console.log(" ▸ loadBatchState with v2 repo-mode fixture preserves all fields"); + const v2Json = loadFixture("batch-state-valid.json"); + saveBatchState(v2Json, v1CompatRoot); + + const loaded = loadBatchState(v1CompatRoot); + assert(loaded !== null, "v2 repo-mode state loaded successfully"); + assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2: schemaVersion is 2"); + assertEqual(loaded!.mode, "repo", "v2: mode is 'repo'"); + assertEqual(loaded!.baseBranch, "main", "v2: baseBranch is 'main'"); + assertEqual(loaded!.phase, "executing", "v2: phase preserved"); + assertEqual(loaded!.batchId, "20260309T010000", "v2: batchId preserved"); + assertEqual(loaded!.tasks.length, 3, "v2: 3 task records"); + assertEqual(loaded!.lanes.length, 2, "v2: 2 lane records"); + assertEqual(loaded!.wavePlan.length, 2, "v2: 2 waves"); + // Confirm no repo fields on repo-mode fixture + assertEqual(loaded!.tasks[0].repoId, undefined, "v2 repo-mode: task has no repoId"); + assertEqual(loaded!.lanes[0].repoId, undefined, "v2 repo-mode: lane has no repoId"); + } - { - console.log(" ▸ monotonic updatedAt across successive writes"); - // Recreate .pi dir for the test root since we deleted the file earlier - if (!existsSync(join(persistTestRoot, ".pi"))) { - mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + { + console.log(" ▸ loadBatchState with v2 workspace-mode fixture preserves repo-aware fields"); + const wsJson = loadFixture("batch-state-v2-workspace.json"); + saveBatchState(wsJson, v1CompatRoot); + + const loaded = loadBatchState(v1CompatRoot); + assert(loaded !== null, "v2 workspace state loaded successfully"); + assertEqual( + loaded!.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "v2 workspace: schemaVersion is 2", + ); + assertEqual(loaded!.mode, "workspace", "v2 workspace: mode is 'workspace'"); + assertEqual(loaded!.baseBranch, "main", "v2 workspace: baseBranch preserved"); + // Task repo-aware fields + assertEqual(loaded!.tasks.length, 2, "v2 workspace: 2 task records"); + assertEqual(loaded!.tasks[0].taskId, "WS-001", "v2 workspace: task WS-001"); + assertEqual(loaded!.tasks[0].repoId, "api", "v2 workspace: task[0].repoId is 'api'"); + assertEqual( + loaded!.tasks[0].resolvedRepoId, + "api", + "v2 workspace: task[0].resolvedRepoId is 'api'", + ); + assertEqual(loaded!.tasks[1].repoId, undefined, "v2 workspace: task[1].repoId is undefined"); + assertEqual( + loaded!.tasks[1].resolvedRepoId, + "frontend", + "v2 workspace: task[1].resolvedRepoId is 'frontend'", + ); + // Lane repo-aware fields + assertEqual(loaded!.lanes[0].repoId, "api", "v2 workspace: lane[0].repoId is 'api'"); + assertEqual(loaded!.lanes[1].repoId, "frontend", "v2 workspace: lane[1].repoId is 'frontend'"); } - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260309T040000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; - state.currentWaveIndex = 0; + { + console.log(" ▸ loadBatchState rejects unsupported schema version (99)"); + const wrongVersionJson = loadFixture("batch-state-wrong-version.json"); + saveBatchState(wrongVersionJson, v1CompatRoot); + + assertThrows( + () => loadBatchState(v1CompatRoot), + "STATE_SCHEMA_INVALID", + "unsupported schema version throws STATE_SCHEMA_INVALID via loadBatchState", + ); + } - // First write - persistRuntimeState("write-1", state, [["T-001"]], [], [], null, persistTestRoot); - const loaded1 = loadBatchState(persistTestRoot); - assert(loaded1 !== null, "first write loaded"); - - // Small delay to ensure timestamp differs (on fast systems) - const busyWait = Date.now() + 2; - while (Date.now() < busyWait) { /* spin */ } + { + console.log(" ▸ loadBatchState rejects malformed JSON"); + const malformedRoot = join(tmpdir(), `orch-v1compat-malformed-${Date.now()}`); + mkdirSync(join(malformedRoot, ".pi"), { recursive: true }); + writeFileSync(batchStatePath(malformedRoot), "{ this is not valid json }", "utf-8"); + + assertThrows( + () => loadBatchState(malformedRoot), + "STATE_FILE_PARSE_ERROR", + "malformed JSON throws STATE_FILE_PARSE_ERROR via loadBatchState", + ); + rmSync(malformedRoot, { recursive: true, force: true }); + } - // Second write - state.currentWaveIndex = 0; // same index, but new write - persistRuntimeState("write-2", state, [["T-001"]], [], [], null, persistTestRoot); - const loaded2 = loadBatchState(persistTestRoot); - assert(loaded2 !== null, "second write loaded"); + { + console.log(" ▸ loadBatchState rejects v2 state missing required mode field"); + // Build a v2 state that has all fields except mode + const v2NoMode = JSON.parse(loadFixture("batch-state-valid.json")); + delete v2NoMode.mode; // Remove the mode field — v2 requires it + const v2NoModeRoot = join(tmpdir(), `orch-v1compat-nomode-${Date.now()}`); + mkdirSync(join(v2NoModeRoot, ".pi"), { recursive: true }); + writeFileSync(batchStatePath(v2NoModeRoot), JSON.stringify(v2NoMode, null, 2), "utf-8"); + + assertThrows( + () => loadBatchState(v2NoModeRoot), + "STATE_SCHEMA_INVALID", + "v2 without mode throws STATE_SCHEMA_INVALID via loadBatchState", + ); + rmSync(v2NoModeRoot, { recursive: true, force: true }); + } - assert(loaded2!.updatedAt >= loaded1!.updatedAt, "updatedAt is monotonically non-decreasing"); + { + console.log(" ▸ v1 → save → load round-trip produces v2 on disk"); + // Load a v1 file (in-memory upconvert to v2), then save (writes v2 to disk) + const v1Json = loadFixture("batch-state-v1-valid.json"); + saveBatchState(v1Json, v1CompatRoot); + const loaded = loadBatchState(v1CompatRoot); + assert(loaded !== null, "v1 loaded for round-trip"); + + // Now save the in-memory v2 state back — this simulates what happens on + // resume: loadBatchState → modify → persistRuntimeState → saveBatchState + const v2Json = JSON.stringify(loaded, null, 2); + saveBatchState(v2Json, v1CompatRoot); + + // Verify on-disk is now v2 + const onDisk = readFileSync(batchStatePath(v1CompatRoot), "utf-8"); + const parsed = JSON.parse(onDisk); + assertEqual( + parsed.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "round-trip: on-disk schemaVersion is 2 after save", + ); + assertEqual(parsed.mode, "repo", "round-trip: on-disk mode is 'repo' after save"); + assertEqual(parsed.baseBranch, "", "round-trip: on-disk baseBranch is '' after save"); + + // Reload and verify + const reloaded = loadBatchState(v1CompatRoot); + assertEqual( + reloaded!.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "round-trip: reloaded schemaVersion is 2", + ); + assertEqual(reloaded!.mode, "repo", "round-trip: reloaded mode is 'repo'"); + assertEqual(reloaded!.tasks.length, 3, "round-trip: reloaded task records preserved"); + } + } finally { + try { + rmSync(v1CompatRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } } - { - console.log(" ▸ taskFolder enriched from discovery.pending"); - if (!existsSync(join(persistTestRoot, ".pi"))) { - mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); - } - - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260309T050000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; - state.currentWaveIndex = 0; + // ═══════════════════════════════════════════════════════════════════════ + // 2.1: persistRuntimeState — integration with state triggers + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 2.1: persistRuntimeState integration tests ──"); + + // Helper: build a minimal valid runtime batch state for persistence tests + interface MinimalBatchState { + phase: string; + batchId: string; + mode: string; + baseBranch: string; + pauseSignal: { paused: boolean }; + waveResults: any[]; + mergeResults: any[]; + currentWaveIndex: number; + totalWaves: number; + blockedTaskIds: Set; + startedAt: number; + endedAt: number | null; + totalTasks: number; + succeededTasks: number; + failedTasks: number; + skippedTasks: number; + blockedTasks: number; + errors: string[]; + currentLanes: any[]; + dependencyGraph: null; + } + + function freshMinimalBatchState(): MinimalBatchState { + return { + phase: "idle", + batchId: "", + mode: "repo", + baseBranch: "", + pauseSignal: { paused: false }, + waveResults: [], + mergeResults: [], + currentWaveIndex: -1, + totalWaves: 0, + blockedTaskIds: new Set(), + startedAt: 0, + endedAt: null, + totalTasks: 0, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + errors: [], + currentLanes: [], + dependencyGraph: null, + }; + } - const lanes = [minimalLane(1, ["ENRICH-001"])]; - const outcomes = [minimalOutcome("ENRICH-001", "succeeded")]; - const discovery = { - pending: new Map([ - ["ENRICH-001", { taskFolder: "/my/tasks/ENRICH-001-enrichment" }], - ]), + // Helper: build minimal lane for serialization + function minimalLane(laneNum: number, taskIds: string[], repoId?: string): any { + return { + laneNumber: laneNum, + laneId: `lane-${laneNum}`, + laneSessionId: `orch-lane-${laneNum}`, + worktreePath: `/tmp/wt-${laneNum}`, + branch: `task/lane-${laneNum}-20260309T030000`, + tasks: taskIds.map((id) => ({ taskId: id, task: null, order: 0, estimatedMinutes: 10 })), + strategy: "affinity-first", + estimatedLoad: 2, + estimatedMinutes: 10, + ...(repoId !== undefined ? { repoId } : {}), }; + } - persistRuntimeState("enrichment-test", state, [["ENRICH-001"]], lanes, outcomes, discovery, persistTestRoot); + // Helper: build minimal lane with ParsedTask objects containing repo fields + function minimalLaneWithRepoTasks( + laneNum: number, + tasks: Array<{ taskId: string; promptRepoId?: string; resolvedRepoId?: string }>, + repoId?: string, + ): any { + return { + laneNumber: laneNum, + laneId: `lane-${laneNum}`, + laneSessionId: `orch-lane-${laneNum}`, + worktreePath: `/tmp/wt-${laneNum}`, + branch: `task/lane-${laneNum}-20260309T030000`, + tasks: tasks.map((t, i) => ({ + taskId: t.taskId, + order: i, + estimatedMinutes: 10, + task: { + taskId: t.taskId, + promptRepoId: t.promptRepoId, + resolvedRepoId: t.resolvedRepoId, + }, + })), + strategy: "affinity-first", + estimatedLoad: 2, + estimatedMinutes: 10, + ...(repoId !== undefined ? { repoId } : {}), + }; + } - const loaded = loadBatchState(persistTestRoot); - assert(loaded !== null, "enrichment state loaded"); - assertEqual(loaded!.tasks[0].taskFolder, "/my/tasks/ENRICH-001-enrichment", "taskFolder enriched from discovery"); + // Helper: build minimal task outcome + function minimalOutcome(taskId: string, status: string): any { + return { + taskId, + status, + startTime: 1000, + endTime: 2000, + exitReason: status === "succeeded" ? "done" : "failed", + sessionName: "orch-lane-1", + doneFileFound: status === "succeeded", + }; } - // ── Step 1: Serialization checkpoint tests for repo-aware fields ── + // Reimplementation of serializeBatchState (mirrors source for test self-containment) + // v2: Includes repo-aware fields from AllocatedTask.task (ParsedTask) and AllocatedLane + function serializeBatchState( + state: MinimalBatchState, + wavePlan: string[][], + lanes: any[], + allTaskOutcomes: any[], + ): string { + const now = Date.now(); - { - console.log(" ▸ serialization includes repo-aware fields for allocated tasks (workspace mode)"); - if (!existsSync(join(persistTestRoot, ".pi"))) { - mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + // Build lookup maps for fast per-task enrichment (mirrors source exactly). + const laneByTaskId = new Map(); + for (const lane of lanes) { + for (const task of lane.tasks) { + laneByTaskId.set(task.taskId, lane); + } } - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260315T060000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 2; - state.currentWaveIndex = 0; + // Latest outcome wins. + const outcomeByTaskId = new Map(); + for (const outcome of allTaskOutcomes) { + outcomeByTaskId.set(outcome.taskId, outcome); + } - const lanes = [ - minimalLaneWithRepoTasks(1, [ - { taskId: "WS-001", promptRepoId: "api", resolvedRepoId: "api" }, - ], "api"), - minimalLaneWithRepoTasks(2, [ - { taskId: "WS-002", promptRepoId: undefined, resolvedRepoId: "frontend" }, - ], "frontend"), - ]; - const outcomes = [ - minimalOutcome("WS-001", "succeeded"), - minimalOutcome("WS-002", "running"), - ]; + // Build full task registry from wave plan + any outcomes seen so far. + const taskIdSet = new Set(); + for (const wave of wavePlan) { + for (const taskId of wave) taskIdSet.add(taskId); + } + for (const outcome of allTaskOutcomes) { + taskIdSet.add(outcome.taskId); + } - // Serialize directly (not through persistRuntimeState) to test serializeBatchState - const json = serializeBatchState(state, [["WS-001", "WS-002"]], lanes, outcomes); - const parsed = JSON.parse(json); + // Build allocatedTask lookup for repo field extraction (mirrors source) + const allocatedTaskByTaskId = new Map(); + for (const lane of lanes) { + for (const allocTask of lane.tasks) { + allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane }); + } + } - // Verify task repo fields - const ws001 = parsed.tasks.find((t: any) => t.taskId === "WS-001"); - const ws002 = parsed.tasks.find((t: any) => t.taskId === "WS-002"); - assertEqual(ws001.repoId, "api", "WS-001 repoId serialized from ParsedTask"); - assertEqual(ws001.resolvedRepoId, "api", "WS-001 resolvedRepoId serialized from ParsedTask"); - assertEqual(ws002.repoId, undefined, "WS-002 repoId undefined (not declared in prompt)"); - assertEqual(ws002.resolvedRepoId, "frontend", "WS-002 resolvedRepoId serialized from area/default fallback"); + const taskRecords = [...taskIdSet].sort().map((taskId: string) => { + const lane = laneByTaskId.get(taskId); + const outcome = outcomeByTaskId.get(taskId); + const allocated = allocatedTaskByTaskId.get(taskId); - // Verify lane repo fields - assertEqual(parsed.lanes[0].repoId, "api", "lane-1 repoId serialized"); - assertEqual(parsed.lanes[1].repoId, "frontend", "lane-2 repoId serialized"); + const record: any = { + taskId, + laneNumber: lane?.laneNumber ?? 0, + sessionName: outcome?.sessionName || lane?.laneSessionId || "", + status: outcome?.status ?? "pending", + taskFolder: "", + startedAt: outcome?.startTime ?? null, + endedAt: outcome?.endTime ?? null, + doneFileFound: outcome?.doneFileFound ?? false, + exitReason: outcome?.exitReason ?? "", + }; + // v2: Serialize repo-aware fields from the ParsedTask + if (allocated?.allocatedTask.task?.promptRepoId !== undefined) { + record.repoId = allocated.allocatedTask.task.promptRepoId; + } + if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { + record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; + } + return record; + }); - // Validate round-trip: re-parse the JSON through validatePersistedState - const validated = validatePersistedState(parsed); - assertEqual(validated.tasks.length, 2, "round-trip: 2 task records"); - assertEqual(validated.lanes.length, 2, "round-trip: 2 lane records"); - } + const laneRecords = lanes.map((lane: any) => { + const record: any = { + laneNumber: lane.laneNumber, + laneId: lane.laneId, + laneSessionId: lane.laneSessionId, + worktreePath: lane.worktreePath, + branch: lane.branch, + taskIds: lane.tasks.map((t: any) => t.taskId), + }; + // v2: Serialize lane repoId + if (lane.repoId !== undefined) { + record.repoId = lane.repoId; + } + return record; + }); - { - console.log(" ▸ serialization omits repo fields for repo-mode state (no repo fields on lanes/tasks)"); - if (!existsSync(join(persistTestRoot, ".pi"))) { - mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); - } + // Build merge results from actual merge outcomes (accumulated on batchState). + // MergeWaveResult.waveIndex is 1-based (from merge module); normalize to + // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1"). + // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1, + // which would produce -2 without clamping. + const mergeResults = (state.mergeResults || []).map((mr: any) => ({ + waveIndex: Math.max(0, mr.waveIndex - 1), + status: mr.status, + failedLane: mr.failedLane, + failureReason: mr.failureReason, + })); - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260315T070000"; - state.startedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; - state.currentWaveIndex = 0; + const persisted = { + schemaVersion: BATCH_STATE_SCHEMA_VERSION, + phase: state.phase, + batchId: state.batchId, + baseBranch: state.baseBranch ?? "", + mode: state.mode ?? "repo", + startedAt: state.startedAt, + updatedAt: now, + endedAt: state.endedAt, + currentWaveIndex: state.currentWaveIndex, + totalWaves: state.totalWaves, + wavePlan, + lanes: laneRecords, + tasks: taskRecords, + mergeResults, + totalTasks: state.totalTasks, + succeededTasks: state.succeededTasks, + failedTasks: state.failedTasks, + skippedTasks: state.skippedTasks, + blockedTasks: state.blockedTasks, + blockedTaskIds: [...state.blockedTaskIds], + lastError: + state.errors.length > 0 + ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] } + : null, + errors: [...state.errors], + }; - // Lanes WITHOUT repoId (repo mode) - const lanes = [minimalLane(1, ["RP-001"])]; - const outcomes = [minimalOutcome("RP-001", "succeeded")]; + return JSON.stringify(persisted, null, 2); + } + + // Reimplementation of persistRuntimeState (mirrors source for test self-containment) + // v2: Includes discovery enrichment for repo-aware fields on unallocated tasks + function persistRuntimeState( + reason: string, + batchState: MinimalBatchState, + wavePlan: string[][], + lanes: any[], + allTaskOutcomes: any[], + discovery: { + pending: Map; + } | null, + repoRoot: string, + ): void { + try { + const json = serializeBatchState(batchState, wavePlan, lanes, allTaskOutcomes); + + if (discovery) { + const parsed = JSON.parse(json); + for (const taskRecord of parsed.tasks) { + const parsedTask = discovery.pending.get(taskRecord.taskId); + if (parsedTask) { + taskRecord.taskFolder = parsedTask.taskFolder; + // v2: Enrich repo fields for tasks not yet allocated (pending in future waves) + if (taskRecord.repoId === undefined && parsedTask.promptRepoId !== undefined) { + taskRecord.repoId = parsedTask.promptRepoId; + } + if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) { + taskRecord.resolvedRepoId = parsedTask.resolvedRepoId; + } + } + } + const enrichedJson = JSON.stringify(parsed, null, 2); + saveBatchState(enrichedJson, repoRoot); + } else { + saveBatchState(json, repoRoot); + } + } catch (err: unknown) { + const msg = + err instanceof StateFileError + ? `[${(err as any).code}] ${(err as any).message}` + : err instanceof Error + ? err.message + : String(err); + batchState.errors.push(`State persistence failed (${reason}): ${msg}`); + } + } - const json = serializeBatchState(state, [["RP-001"]], lanes, outcomes); - const parsed = JSON.parse(json); + // Create temp root for persistence integration tests + const persistTestRoot = join(tmpdir(), `orch-persist-test-${Date.now()}`); + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); - // Verify no repo fields present - assertEqual(parsed.tasks[0].repoId, undefined, "repo-mode task has no repoId"); - assertEqual(parsed.tasks[0].resolvedRepoId, undefined, "repo-mode task has no resolvedRepoId"); - assertEqual(parsed.lanes[0].repoId, undefined, "repo-mode lane has no repoId"); - } + try { + { + console.log(" ▸ state file created after batch start (phase=executing)"); + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now(); + state.totalWaves = 2; + state.totalTasks = 3; + state.currentWaveIndex = 0; + + const wavePlan = [["T-001", "T-002"], ["T-003"]]; + persistRuntimeState("batch-start", state, wavePlan, [], [], null, persistTestRoot); - { - console.log(" ▸ discovery enrichment writes repo fields for unallocated tasks"); - if (!existsSync(join(persistTestRoot, ".pi"))) { - mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + assert( + existsSync(batchStatePath(persistTestRoot)), + "state file exists after batch-start persist", + ); + const loaded = loadBatchState(persistTestRoot); + assert(loaded !== null, "loaded state is not null"); + assertEqual(loaded!.phase, "executing", "persisted phase is executing"); + assertEqual(loaded!.batchId, "20260309T030000", "persisted batchId matches"); + assertEqual(loaded!.totalTasks, 3, "persisted totalTasks is 3"); + assertEqual(loaded!.wavePlan.length, 2, "persisted wavePlan has 2 waves"); } - const state = freshMinimalBatchState(); - state.phase = "executing"; - state.batchId = "20260315T080000"; - state.startedAt = Date.now(); - state.totalWaves = 2; - state.totalTasks = 2; - state.currentWaveIndex = 0; + { + console.log(" ▸ state file updated on wave index change"); + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now(); + state.totalWaves = 2; + state.totalTasks = 3; + state.currentWaveIndex = 1; + + const wavePlan = [["T-001", "T-002"], ["T-003"]]; + persistRuntimeState("wave-index-change", state, wavePlan, [], [], null, persistTestRoot); + + const loaded = loadBatchState(persistTestRoot); + assertEqual(loaded!.currentWaveIndex, 1, "waveIndex updated to 1"); + } - // Wave 1 has WS-010 (allocated), Wave 2 has WS-020 (not yet allocated) - const lanes = [minimalLaneWithRepoTasks(1, [ - { taskId: "WS-010", promptRepoId: "api", resolvedRepoId: "api" }, - ], "api")]; - const outcomes = [minimalOutcome("WS-010", "running")]; + { + console.log(" ▸ state file updated after task completion (waveResult accumulated)"); + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 2; + state.currentWaveIndex = 0; + state.succeededTasks = 1; + state.failedTasks = 1; + + const wavePlan = [["T-001", "T-002"]]; + const lanes = [minimalLane(1, ["T-001", "T-002"])]; + const outcomes = [minimalOutcome("T-001", "succeeded"), minimalOutcome("T-002", "failed")]; + + persistRuntimeState( + "wave-execution-complete", + state, + wavePlan, + lanes, + outcomes, + null, + persistTestRoot, + ); - // Discovery includes WS-020 (future wave, unallocated) - const discovery = { - pending: new Map([ - ["WS-010", { taskFolder: "/tasks/WS-010", promptRepoId: "api", resolvedRepoId: "api" }], - ["WS-020", { taskFolder: "/tasks/WS-020", promptRepoId: "frontend", resolvedRepoId: "frontend" }], - ]), - }; + const loaded = loadBatchState(persistTestRoot); + assertEqual(loaded!.succeededTasks, 1, "succeededTasks is 1"); + assertEqual(loaded!.failedTasks, 1, "failedTasks is 1"); + assertEqual(loaded!.tasks.length, 2, "2 task records persisted"); + assertEqual(loaded!.tasks[0].status, "succeeded", "first task succeeded"); + assertEqual(loaded!.tasks[1].status, "failed", "second task failed"); + } - persistRuntimeState("wave-index-change", state, [["WS-010"], ["WS-020"]], lanes, outcomes, discovery, persistTestRoot); - - const loaded = loadBatchState(persistTestRoot); - assert(loaded !== null, "discovery-enriched state loaded"); - - // WS-010: repo fields come from allocated lane's ParsedTask via serializeBatchState - const ws010 = loaded!.tasks.find((t: any) => t.taskId === "WS-010"); - assert(ws010 !== undefined, "WS-010 task record found"); - assertEqual(ws010!.repoId, "api", "WS-010 repoId from serialization (allocated)"); - assertEqual(ws010!.resolvedRepoId, "api", "WS-010 resolvedRepoId from serialization (allocated)"); - assertEqual(ws010!.taskFolder, "/tasks/WS-010", "WS-010 taskFolder enriched from discovery"); - - // WS-020: repo fields come from discovery enrichment (not yet allocated) - // WS-020 is in wavePlan but not in current lanes — it gets a skeleton record - // from the wave plan in serializeBatchState, then discovery enrichment adds repo fields. - // However, WS-020 has no outcome yet, so it appears in the taskIdSet from wavePlan - // but with default values (laneNumber=0, status=pending). - const ws020 = loaded!.tasks.find((t: any) => t.taskId === "WS-020"); - assert(ws020 !== undefined, "WS-020 task record found (from wavePlan)"); - assertEqual(ws020!.repoId, "frontend", "WS-020 repoId enriched from discovery (unallocated)"); - assertEqual(ws020!.resolvedRepoId, "frontend", "WS-020 resolvedRepoId enriched from discovery (unallocated)"); - assertEqual(ws020!.taskFolder, "/tasks/WS-020", "WS-020 taskFolder enriched from discovery"); - } - - { - console.log(" ▸ serialized state validates as v2 through full round-trip (workspace mode)"); - if (!existsSync(join(persistTestRoot, ".pi"))) { - mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); - } - - const state = freshMinimalBatchState(); - state.phase = "completed"; - state.batchId = "20260315T090000"; - state.startedAt = Date.now() - 60000; - state.endedAt = Date.now(); - state.totalWaves = 1; - state.totalTasks = 1; - state.succeededTasks = 1; - state.currentWaveIndex = 0; - - const lanes = [minimalLaneWithRepoTasks(1, [ - { taskId: "RT-001", promptRepoId: "api", resolvedRepoId: "api" }, - ], "api")]; - const outcomes = [minimalOutcome("RT-001", "succeeded")]; - - // Serialize → save → load → validate → check fields - const json = serializeBatchState(state, [["RT-001"]], lanes, outcomes); - saveBatchState(json, persistTestRoot); - const loaded = loadBatchState(persistTestRoot); - - assert(loaded !== null, "round-trip loaded"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "round-trip: schemaVersion is 2"); - assertEqual(loaded!.mode, "repo", "round-trip: mode preserved"); - assertEqual(loaded!.tasks[0].repoId, "api", "round-trip: task repoId preserved"); - assertEqual(loaded!.tasks[0].resolvedRepoId, "api", "round-trip: task resolvedRepoId preserved"); - assertEqual(loaded!.lanes[0].repoId, "api", "round-trip: lane repoId preserved"); - } - -} finally { - // Cleanup temp directory - try { - rmSync(persistTestRoot, { recursive: true, force: true }); - } catch { /* best effort */ } -} + { + console.log(" ▸ state file updated on merge phase transitions"); + const state = freshMinimalBatchState(); + state.phase = "merging"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + state.currentWaveIndex = 0; + + const wavePlan = [["T-001"]]; + persistRuntimeState("merge-start", state, wavePlan, [], [], null, persistTestRoot); + + let loaded = loadBatchState(persistTestRoot); + assertEqual(loaded!.phase, "merging", "phase is merging after merge-start"); + + // Now simulate merge complete → executing + state.phase = "executing"; + persistRuntimeState("merge-complete", state, wavePlan, [], [], null, persistTestRoot); + + loaded = loadBatchState(persistTestRoot); + assertEqual(loaded!.phase, "executing", "phase is executing after merge-complete"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 3.1: parseOrchSessionNames -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 3.1: parseOrchSessionNames ──"); - -// Reimplementation (matches source in task-orchestrator.ts) -function parseOrchSessionNames(stdout: string, prefix: string): string[] { - if (!stdout || !stdout.trim()) return []; - const filterPrefix = `${prefix}-`; - return stdout - .split("\n") - .map(line => line.trim()) - .filter(name => name.length > 0 && name.startsWith(filterPrefix)) - .sort(); -} + { + console.log(" ▸ state file updated on pause/error with lastError populated"); + const state = freshMinimalBatchState(); + state.phase = "paused"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now(); + state.totalWaves = 2; + state.totalTasks = 3; + state.currentWaveIndex = 0; + state.errors.push("Merge failed at wave 1: conflict unresolved"); + + const wavePlan = [["T-001"], ["T-002", "T-003"]]; + persistRuntimeState("merge-failure-pause", state, wavePlan, [], [], null, persistTestRoot); + + const loaded = loadBatchState(persistTestRoot); + assertEqual(loaded!.phase, "paused", "phase is paused"); + assert(loaded!.lastError !== null, "lastError is populated"); + assertEqual(loaded!.lastError!.code, "BATCH_ERROR", "lastError code is BATCH_ERROR"); + assert( + loaded!.lastError!.message.includes("Merge failed"), + "lastError message includes merge failure", + ); + assertEqual(loaded!.errors.length, 1, "1 error in errors array"); + } -{ - console.log(" ▸ empty stdout returns []"); - assertEqual(parseOrchSessionNames("", "orch").length, 0, "empty string → empty array"); - assertEqual(parseOrchSessionNames(" \n ", "orch").length, 0, "whitespace-only → empty array"); - assertEqual(parseOrchSessionNames("\n\n\n", "orch").length, 0, "blank lines → empty array"); -} + { + console.log(" ▸ state file deleted on clean batch completion"); + // First, create a state file + const state = freshMinimalBatchState(); + state.phase = "completed"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now() - 60000; + state.endedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + state.succeededTasks = 1; + state.currentWaveIndex = 0; + + const wavePlan = [["T-001"]]; + persistRuntimeState("batch-terminal", state, wavePlan, [], [], null, persistTestRoot); + assert(existsSync(batchStatePath(persistTestRoot)), "state file exists before clean completion"); + + // Simulate clean completion delete + deleteBatchState(persistTestRoot); + assert(!existsSync(batchStatePath(persistTestRoot)), "state file deleted on clean completion"); + } -{ - console.log(" ▸ filters by prefix, ignores non-matching sessions"); - const stdout = "orch-lane-1\norch-lane-2\nmy-session\nother-thing\norch-lane-3\n"; - const result = parseOrchSessionNames(stdout, "orch"); - assertEqual(result.length, 3, "3 orch sessions found"); - assertEqual(result[0], "orch-lane-1", "first session"); - assertEqual(result[1], "orch-lane-2", "second session"); - assertEqual(result[2], "orch-lane-3", "third session"); -} + { + console.log(" ▸ write failure does not crash batch (error logged, batch continues)"); + // Use an invalid root path that can't be written to + const invalidRoot = join( + tmpdir(), + `orch-persist-invalid-${Date.now()}`, + "nonexistent", + "deep", + "path", + ); + // Don't create the directory — write should fail + + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260309T030000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + + // This should NOT throw — errors are caught and added to state.errors + persistRuntimeState("test-write-failure", state, [["T-001"]], [], [], null, invalidRoot); + + // But wait, saveBatchState creates .pi directory if missing. + // For a truly failing path, we need to use a path that's a file not a dir. + // Let's write a file where the .pi dir should be: + const blockingRoot = join(tmpdir(), `orch-persist-blocked-${Date.now()}`); + mkdirSync(blockingRoot, { recursive: true }); + writeFileSync(join(blockingRoot, ".pi"), "I am a file, not a directory", "utf-8"); + + const state2 = freshMinimalBatchState(); + state2.phase = "executing"; + state2.batchId = "20260309T030001"; + state2.startedAt = Date.now(); + state2.totalWaves = 1; + state2.totalTasks = 1; + state2.errors = []; + + persistRuntimeState("test-blocked-write", state2, [["T-001"]], [], [], null, blockingRoot); + + // The function should not have thrown, but should have logged error + assert(state2.errors.length > 0, "error logged in batch state on write failure"); + assert( + state2.errors[0].includes("State persistence failed"), + "error message mentions persistence failure", + ); -{ - console.log(" ▸ handles malformed lines gracefully"); - const stdout = " orch-lane-1 \n\n\n not-orch \n orch-lane-2\n \n"; - const result = parseOrchSessionNames(stdout, "orch"); - assertEqual(result.length, 2, "2 orch sessions with trimming"); - assertEqual(result[0], "orch-lane-1", "trimmed first"); - assertEqual(result[1], "orch-lane-2", "trimmed second"); -} + // Cleanup + try { + rmSync(blockingRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } -{ - console.log(" ▸ prefix must match with dash separator"); - const stdout = "orch-lane-1\norchestra-session\norch\n"; - const result = parseOrchSessionNames(stdout, "orch"); - assertEqual(result.length, 1, "only orch-lane-1 matches orch-"); - assertEqual(result[0], "orch-lane-1", "orchestra-session and bare orch excluded"); -} + { + console.log(" ▸ monotonic updatedAt across successive writes"); + // Recreate .pi dir for the test root since we deleted the file earlier + if (!existsSync(join(persistTestRoot, ".pi"))) { + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + } -{ - console.log(" ▸ results are sorted alphabetically"); - const stdout = "orch-lane-3\norch-lane-1\norch-lane-2\n"; - const result = parseOrchSessionNames(stdout, "orch"); - assertEqual(result[0], "orch-lane-1", "sorted first"); - assertEqual(result[1], "orch-lane-2", "sorted second"); - assertEqual(result[2], "orch-lane-3", "sorted third"); -} + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260309T040000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + state.currentWaveIndex = 0; + + // First write + persistRuntimeState("write-1", state, [["T-001"]], [], [], null, persistTestRoot); + const loaded1 = loadBatchState(persistTestRoot); + assert(loaded1 !== null, "first write loaded"); + + // Small delay to ensure timestamp differs (on fast systems) + const busyWait = Date.now() + 2; + while (Date.now() < busyWait) { + /* spin */ + } -// ═══════════════════════════════════════════════════════════════════════ -// 3.2: analyzeOrchestratorStartupState -// ═══════════════════════════════════════════════════════════════════════ + // Second write + state.currentWaveIndex = 0; // same index, but new write + persistRuntimeState("write-2", state, [["T-001"]], [], [], null, persistTestRoot); + const loaded2 = loadBatchState(persistTestRoot); + assert(loaded2 !== null, "second write loaded"); -console.log("\n── 3.2: analyzeOrchestratorStartupState ──"); + assert(loaded2!.updatedAt >= loaded1!.updatedAt, "updatedAt is monotonically non-decreasing"); + } -// Reimplementation of type aliases and function (matches source) -type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error"; -type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "paused-corrupt" | "start-fresh"; + { + console.log(" ▸ taskFolder enriched from discovery.pending"); + if (!existsSync(join(persistTestRoot, ".pi"))) { + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + } -interface OrphanDetectionResult { - orphanSessions: string[]; - stateStatus: OrphanStateStatus; - loadedState: any | null; - stateError: string | null; - recommendedAction: OrphanRecommendedAction; - userMessage: string; -} + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260309T050000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + state.currentWaveIndex = 0; + + const lanes = [minimalLane(1, ["ENRICH-001"])]; + const outcomes = [minimalOutcome("ENRICH-001", "succeeded")]; + const discovery = { + pending: new Map([["ENRICH-001", { taskFolder: "/my/tasks/ENRICH-001-enrichment" }]]), + }; -interface PersistedBatchStateForTest { - schemaVersion: number; - phase: string; - batchId: string; - baseBranch?: string; - mode?: string; - startedAt: number; - updatedAt: number; - endedAt: number | null; - currentWaveIndex: number; - totalWaves: number; - wavePlan: string[][]; - lanes: any[]; - tasks: Array<{ taskId: string; taskFolder: string; [k: string]: any }>; - mergeResults: any[]; - totalTasks: number; - succeededTasks: number; - failedTasks: number; - skippedTasks: number; - blockedTasks: number; - blockedTaskIds: string[]; - lastError: { code: string; message: string } | null; - errors: string[]; -} + persistRuntimeState( + "enrichment-test", + state, + [["ENRICH-001"]], + lanes, + outcomes, + discovery, + persistTestRoot, + ); -function analyzeOrchestratorStartupState( - orphanSessions: string[], - stateStatus: OrphanStateStatus, - loadedState: PersistedBatchStateForTest | null, - stateError: string | null, - doneTaskIds: ReadonlySet, -): OrphanDetectionResult { - const hasOrphans = orphanSessions.length > 0; + const loaded = loadBatchState(persistTestRoot); + assert(loaded !== null, "enrichment state loaded"); + assertEqual( + loaded!.tasks[0].taskFolder, + "/my/tasks/ENRICH-001-enrichment", + "taskFolder enriched from discovery", + ); + } - if (hasOrphans) { - if (stateStatus === "valid" && loadedState) { - return { - orphanSessions, - stateStatus, - loadedState, - stateError, - recommendedAction: "resume", - userMessage: - `🔄 Found ${orphanSessions.length} running orchestrator session(s): ${orphanSessions.join(", ")}\n` + - ` Batch ${loadedState.batchId} (${loadedState.phase}) has persisted state.\n` + - ` Use /orch-resume to continue, or /orch-abort to clean up.`, + // ── Step 1: Serialization checkpoint tests for repo-aware fields ── + + { + console.log(" ▸ serialization includes repo-aware fields for allocated tasks (workspace mode)"); + if (!existsSync(join(persistTestRoot, ".pi"))) { + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + } + + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260315T060000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 2; + state.currentWaveIndex = 0; + + const lanes = [ + minimalLaneWithRepoTasks( + 1, + [{ taskId: "WS-001", promptRepoId: "api", resolvedRepoId: "api" }], + "api", + ), + minimalLaneWithRepoTasks( + 2, + [{ taskId: "WS-002", promptRepoId: undefined, resolvedRepoId: "frontend" }], + "frontend", + ), + ]; + const outcomes = [minimalOutcome("WS-001", "succeeded"), minimalOutcome("WS-002", "running")]; + + // Serialize directly (not through persistRuntimeState) to test serializeBatchState + const json = serializeBatchState(state, [["WS-001", "WS-002"]], lanes, outcomes); + const parsed = JSON.parse(json); + + // Verify task repo fields + const ws001 = parsed.tasks.find((t: any) => t.taskId === "WS-001"); + const ws002 = parsed.tasks.find((t: any) => t.taskId === "WS-002"); + assertEqual(ws001.repoId, "api", "WS-001 repoId serialized from ParsedTask"); + assertEqual(ws001.resolvedRepoId, "api", "WS-001 resolvedRepoId serialized from ParsedTask"); + assertEqual(ws002.repoId, undefined, "WS-002 repoId undefined (not declared in prompt)"); + assertEqual( + ws002.resolvedRepoId, + "frontend", + "WS-002 resolvedRepoId serialized from area/default fallback", + ); + + // Verify lane repo fields + assertEqual(parsed.lanes[0].repoId, "api", "lane-1 repoId serialized"); + assertEqual(parsed.lanes[1].repoId, "frontend", "lane-2 repoId serialized"); + + // Validate round-trip: re-parse the JSON through validatePersistedState + const validated = validatePersistedState(parsed); + assertEqual(validated.tasks.length, 2, "round-trip: 2 task records"); + assertEqual(validated.lanes.length, 2, "round-trip: 2 lane records"); + } + + { + console.log( + " ▸ serialization omits repo fields for repo-mode state (no repo fields on lanes/tasks)", + ); + if (!existsSync(join(persistTestRoot, ".pi"))) { + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + } + + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260315T070000"; + state.startedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + state.currentWaveIndex = 0; + + // Lanes WITHOUT repoId (repo mode) + const lanes = [minimalLane(1, ["RP-001"])]; + const outcomes = [minimalOutcome("RP-001", "succeeded")]; + + const json = serializeBatchState(state, [["RP-001"]], lanes, outcomes); + const parsed = JSON.parse(json); + + // Verify no repo fields present + assertEqual(parsed.tasks[0].repoId, undefined, "repo-mode task has no repoId"); + assertEqual(parsed.tasks[0].resolvedRepoId, undefined, "repo-mode task has no resolvedRepoId"); + assertEqual(parsed.lanes[0].repoId, undefined, "repo-mode lane has no repoId"); + } + + { + console.log(" ▸ discovery enrichment writes repo fields for unallocated tasks"); + if (!existsSync(join(persistTestRoot, ".pi"))) { + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + } + + const state = freshMinimalBatchState(); + state.phase = "executing"; + state.batchId = "20260315T080000"; + state.startedAt = Date.now(); + state.totalWaves = 2; + state.totalTasks = 2; + state.currentWaveIndex = 0; + + // Wave 1 has WS-010 (allocated), Wave 2 has WS-020 (not yet allocated) + const lanes = [ + minimalLaneWithRepoTasks( + 1, + [{ taskId: "WS-010", promptRepoId: "api", resolvedRepoId: "api" }], + "api", + ), + ]; + const outcomes = [minimalOutcome("WS-010", "running")]; + + // Discovery includes WS-020 (future wave, unallocated) + const discovery = { + pending: new Map([ + ["WS-010", { taskFolder: "/tasks/WS-010", promptRepoId: "api", resolvedRepoId: "api" }], + [ + "WS-020", + { taskFolder: "/tasks/WS-020", promptRepoId: "frontend", resolvedRepoId: "frontend" }, + ], + ]), }; + + persistRuntimeState( + "wave-index-change", + state, + [["WS-010"], ["WS-020"]], + lanes, + outcomes, + discovery, + persistTestRoot, + ); + + const loaded = loadBatchState(persistTestRoot); + assert(loaded !== null, "discovery-enriched state loaded"); + + // WS-010: repo fields come from allocated lane's ParsedTask via serializeBatchState + const ws010 = loaded!.tasks.find((t: any) => t.taskId === "WS-010"); + assert(ws010 !== undefined, "WS-010 task record found"); + assertEqual(ws010!.repoId, "api", "WS-010 repoId from serialization (allocated)"); + assertEqual( + ws010!.resolvedRepoId, + "api", + "WS-010 resolvedRepoId from serialization (allocated)", + ); + assertEqual(ws010!.taskFolder, "/tasks/WS-010", "WS-010 taskFolder enriched from discovery"); + + // WS-020: repo fields come from discovery enrichment (not yet allocated) + // WS-020 is in wavePlan but not in current lanes — it gets a skeleton record + // from the wave plan in serializeBatchState, then discovery enrichment adds repo fields. + // However, WS-020 has no outcome yet, so it appears in the taskIdSet from wavePlan + // but with default values (laneNumber=0, status=pending). + const ws020 = loaded!.tasks.find((t: any) => t.taskId === "WS-020"); + assert(ws020 !== undefined, "WS-020 task record found (from wavePlan)"); + assertEqual(ws020!.repoId, "frontend", "WS-020 repoId enriched from discovery (unallocated)"); + assertEqual( + ws020!.resolvedRepoId, + "frontend", + "WS-020 resolvedRepoId enriched from discovery (unallocated)", + ); + assertEqual(ws020!.taskFolder, "/tasks/WS-020", "WS-020 taskFolder enriched from discovery"); } - const errorCtx = stateError ? `\n State error: ${stateError}` : ""; - return { - orphanSessions, - stateStatus, - loadedState: null, - stateError, - recommendedAction: "abort-orphans", - userMessage: - `⚠️ Found ${orphanSessions.length} orphan orchestrator session(s): ${orphanSessions.join(", ")}\n` + - ` No usable batch state file (status: ${stateStatus}).${errorCtx}\n` + - ` Use /orch-abort to clean up before starting a new batch.`, - }; + { + console.log(" ▸ serialized state validates as v2 through full round-trip (workspace mode)"); + if (!existsSync(join(persistTestRoot, ".pi"))) { + mkdirSync(join(persistTestRoot, ".pi"), { recursive: true }); + } + + const state = freshMinimalBatchState(); + state.phase = "completed"; + state.batchId = "20260315T090000"; + state.startedAt = Date.now() - 60000; + state.endedAt = Date.now(); + state.totalWaves = 1; + state.totalTasks = 1; + state.succeededTasks = 1; + state.currentWaveIndex = 0; + + const lanes = [ + minimalLaneWithRepoTasks( + 1, + [{ taskId: "RT-001", promptRepoId: "api", resolvedRepoId: "api" }], + "api", + ), + ]; + const outcomes = [minimalOutcome("RT-001", "succeeded")]; + + // Serialize → save → load → validate → check fields + const json = serializeBatchState(state, [["RT-001"]], lanes, outcomes); + saveBatchState(json, persistTestRoot); + const loaded = loadBatchState(persistTestRoot); + + assert(loaded !== null, "round-trip loaded"); + assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "round-trip: schemaVersion is 2"); + assertEqual(loaded!.mode, "repo", "round-trip: mode preserved"); + assertEqual(loaded!.tasks[0].repoId, "api", "round-trip: task repoId preserved"); + assertEqual(loaded!.tasks[0].resolvedRepoId, "api", "round-trip: task resolvedRepoId preserved"); + assertEqual(loaded!.lanes[0].repoId, "api", "round-trip: lane repoId preserved"); + } + } finally { + // Cleanup temp directory + try { + rmSync(persistTestRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } } - if (stateStatus === "missing") { - return { - orphanSessions: [], - stateStatus, - loadedState: null, - stateError, - recommendedAction: "start-fresh", - userMessage: "", - }; + // ═══════════════════════════════════════════════════════════════════════ + // 3.1: parseOrchSessionNames + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 3.1: parseOrchSessionNames ──"); + + // Reimplementation (matches source in task-orchestrator.ts) + function parseOrchSessionNames(stdout: string, prefix: string): string[] { + if (!stdout || !stdout.trim()) return []; + const filterPrefix = `${prefix}-`; + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((name) => name.length > 0 && name.startsWith(filterPrefix)) + .sort(); + } + + { + console.log(" ▸ empty stdout returns []"); + assertEqual(parseOrchSessionNames("", "orch").length, 0, "empty string → empty array"); + assertEqual(parseOrchSessionNames(" \n ", "orch").length, 0, "whitespace-only → empty array"); + assertEqual(parseOrchSessionNames("\n\n\n", "orch").length, 0, "blank lines → empty array"); + } + + { + console.log(" ▸ filters by prefix, ignores non-matching sessions"); + const stdout = "orch-lane-1\norch-lane-2\nmy-session\nother-thing\norch-lane-3\n"; + const result = parseOrchSessionNames(stdout, "orch"); + assertEqual(result.length, 3, "3 orch sessions found"); + assertEqual(result[0], "orch-lane-1", "first session"); + assertEqual(result[1], "orch-lane-2", "second session"); + assertEqual(result[2], "orch-lane-3", "third session"); + } + + { + console.log(" ▸ handles malformed lines gracefully"); + const stdout = " orch-lane-1 \n\n\n not-orch \n orch-lane-2\n \n"; + const result = parseOrchSessionNames(stdout, "orch"); + assertEqual(result.length, 2, "2 orch sessions with trimming"); + assertEqual(result[0], "orch-lane-1", "trimmed first"); + assertEqual(result[1], "orch-lane-2", "trimmed second"); + } + + { + console.log(" ▸ prefix must match with dash separator"); + const stdout = "orch-lane-1\norchestra-session\norch\n"; + const result = parseOrchSessionNames(stdout, "orch"); + assertEqual(result.length, 1, "only orch-lane-1 matches orch-"); + assertEqual(result[0], "orch-lane-1", "orchestra-session and bare orch excluded"); } - if (stateStatus === "valid" && loadedState) { - const allTaskIds = loadedState.tasks.map((t: any) => t.taskId); - const allDone = allTaskIds.length > 0 && allTaskIds.every((id: string) => doneTaskIds.has(id)); + { + console.log(" ▸ results are sorted alphabetically"); + const stdout = "orch-lane-3\norch-lane-1\norch-lane-2\n"; + const result = parseOrchSessionNames(stdout, "orch"); + assertEqual(result[0], "orch-lane-1", "sorted first"); + assertEqual(result[1], "orch-lane-2", "sorted second"); + assertEqual(result[2], "orch-lane-3", "sorted third"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 3.2: analyzeOrchestratorStartupState + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 3.2: analyzeOrchestratorStartupState ──"); + + // Reimplementation of type aliases and function (matches source) + type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error"; + type OrphanRecommendedAction = + | "resume" + | "abort-orphans" + | "cleanup-stale" + | "paused-corrupt" + | "start-fresh"; + + interface OrphanDetectionResult { + orphanSessions: string[]; + stateStatus: OrphanStateStatus; + loadedState: any | null; + stateError: string | null; + recommendedAction: OrphanRecommendedAction; + userMessage: string; + } + + interface PersistedBatchStateForTest { + schemaVersion: number; + phase: string; + batchId: string; + baseBranch?: string; + mode?: string; + startedAt: number; + updatedAt: number; + endedAt: number | null; + currentWaveIndex: number; + totalWaves: number; + wavePlan: string[][]; + lanes: any[]; + tasks: Array<{ taskId: string; taskFolder: string; [k: string]: any }>; + mergeResults: any[]; + totalTasks: number; + succeededTasks: number; + failedTasks: number; + skippedTasks: number; + blockedTasks: number; + blockedTaskIds: string[]; + lastError: { code: string; message: string } | null; + errors: string[]; + } + + function analyzeOrchestratorStartupState( + orphanSessions: string[], + stateStatus: OrphanStateStatus, + loadedState: PersistedBatchStateForTest | null, + stateError: string | null, + doneTaskIds: ReadonlySet, + ): OrphanDetectionResult { + const hasOrphans = orphanSessions.length > 0; + + if (hasOrphans) { + if (stateStatus === "valid" && loadedState) { + return { + orphanSessions, + stateStatus, + loadedState, + stateError, + recommendedAction: "resume", + userMessage: + `🔄 Found ${orphanSessions.length} running orchestrator session(s): ${orphanSessions.join(", ")}\n` + + ` Batch ${loadedState.batchId} (${loadedState.phase}) has persisted state.\n` + + ` Use /orch-resume to continue, or /orch-abort to clean up.`, + }; + } - if (allDone) { + const errorCtx = stateError ? `\n State error: ${stateError}` : ""; return { - orphanSessions: [], + orphanSessions, stateStatus, - loadedState, + loadedState: null, stateError, - recommendedAction: "cleanup-stale", + recommendedAction: "abort-orphans", userMessage: - `🧹 Found stale batch state file from batch ${loadedState.batchId}.\n` + - ` All ${allTaskIds.length} task(s) have .DONE files. Cleaning up state file.`, + `⚠️ Found ${orphanSessions.length} orphan orchestrator session(s): ${orphanSessions.join(", ")}\n` + + ` No usable batch state file (status: ${stateStatus}).${errorCtx}\n` + + ` Use /orch-abort to clean up before starting a new batch.`, + }; + } + + if (stateStatus === "missing") { + return { + orphanSessions: [], + stateStatus, + loadedState: null, + stateError, + recommendedAction: "start-fresh", + userMessage: "", }; } - const completedCount = allTaskIds.filter((id: string) => doneTaskIds.has(id)).length; + if (stateStatus === "valid" && loadedState) { + const allTaskIds = loadedState.tasks.map((t: any) => t.taskId); + const allDone = allTaskIds.length > 0 && allTaskIds.every((id: string) => doneTaskIds.has(id)); + + if (allDone) { + return { + orphanSessions: [], + stateStatus, + loadedState, + stateError, + recommendedAction: "cleanup-stale", + userMessage: + `🧹 Found stale batch state file from batch ${loadedState.batchId}.\n` + + ` All ${allTaskIds.length} task(s) have .DONE files. Cleaning up state file.`, + }; + } - // Only phases that resumeOrchBatch can actually handle should get "resume". - // "failed" / "stopped" / "idle" / "planning" are non-resumable — if nothing - // ran yet (completedCount === 0) the state file is pure noise; auto-clean it - // so /orch can start fresh without forcing the user through /orch-abort first. - const resumablePhases = ["paused", "executing", "merging"]; - const isResumable = resumablePhases.includes(loadedState.phase); + const completedCount = allTaskIds.filter((id: string) => doneTaskIds.has(id)).length; + + // Only phases that resumeOrchBatch can actually handle should get "resume". + // "failed" / "stopped" / "idle" / "planning" are non-resumable — if nothing + // ran yet (completedCount === 0) the state file is pure noise; auto-clean it + // so /orch can start fresh without forcing the user through /orch-abort first. + const resumablePhases = ["paused", "executing", "merging"]; + const isResumable = resumablePhases.includes(loadedState.phase); + + if (!isResumable && completedCount === 0) { + return { + orphanSessions: [], + stateStatus, + loadedState, + stateError, + recommendedAction: "cleanup-stale", + userMessage: + `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}, 0 tasks ran).\n` + + ` Cleaning up stale state file so a fresh batch can start.`, + }; + } - if (!isResumable && completedCount === 0) { return { orphanSessions: [], stateStatus, loadedState, stateError, - recommendedAction: "cleanup-stale", - userMessage: - `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}, 0 tasks ran).\n` + - ` Cleaning up stale state file so a fresh batch can start.`, + recommendedAction: isResumable ? "resume" : "cleanup-stale", + userMessage: isResumable + ? `🔄 Found interrupted batch ${loadedState.batchId} (${loadedState.phase}).\n` + + ` ${completedCount}/${allTaskIds.length} task(s) completed.\n` + + ` Use /orch-resume to continue, or /orch-abort to clean up.` + : `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}).\n` + + ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`, }; } + // Invalid or io-error state with no orphans — corrupt state. + // Never auto-delete: enter paused-corrupt so the user can inspect the file. return { orphanSessions: [], stateStatus, - loadedState, + loadedState: null, stateError, - recommendedAction: isResumable ? "resume" : "cleanup-stale", - userMessage: isResumable - ? `🔄 Found interrupted batch ${loadedState.batchId} (${loadedState.phase}).\n` + - ` ${completedCount}/${allTaskIds.length} task(s) completed.\n` + - ` Use /orch-resume to continue, or /orch-abort to clean up.` - : `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}).\n` + - ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`, + recommendedAction: "paused-corrupt", + userMessage: + `⚠️ Batch state file is corrupt or unreadable (${stateStatus}).\n` + + (stateError ? ` Error: ${stateError}\n` : "") + + ` The file has NOT been deleted. Inspect .pi/batch-state.json manually,\n` + + ` then either fix it or delete it and run /orch again.`, }; } - // Invalid or io-error state with no orphans — corrupt state. - // Never auto-delete: enter paused-corrupt so the user can inspect the file. - return { - orphanSessions: [], - stateStatus, - loadedState: null, - stateError, - recommendedAction: "paused-corrupt", - userMessage: - `⚠️ Batch state file is corrupt or unreadable (${stateStatus}).\n` + - (stateError ? ` Error: ${stateError}\n` : "") + - ` The file has NOT been deleted. Inspect .pi/batch-state.json manually,\n` + - ` then either fix it or delete it and run /orch again.`, - }; -} - -// Helper: create a minimal valid persisted batch state for testing -function minimalPersistedState(overrides?: Partial): PersistedBatchStateForTest { - return { - schemaVersion: 2, - phase: "executing", - batchId: "20260309T050000", - startedAt: Date.now() - 60000, - updatedAt: Date.now(), - endedAt: null, - currentWaveIndex: 0, - totalWaves: 1, - wavePlan: [["TS-001", "TS-002"]], - lanes: [], - tasks: [ - { taskId: "TS-001", taskFolder: "/tmp/tasks/TS-001", laneNumber: 1, sessionName: "orch-lane-1", status: "succeeded", startedAt: Date.now() - 60000, endedAt: Date.now() - 30000, doneFileFound: true, exitReason: "" }, - { taskId: "TS-002", taskFolder: "/tmp/tasks/TS-002", laneNumber: 2, sessionName: "orch-lane-2", status: "running", startedAt: Date.now() - 60000, endedAt: null, doneFileFound: false, exitReason: "" }, - ], - mergeResults: [], - totalTasks: 2, - succeededTasks: 1, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: [], - lastError: null, - errors: [], - ...overrides, - }; -} - -{ - console.log(" ▸ orphans + valid state → recommend 'resume'"); - const state = minimalPersistedState(); - const result = analyzeOrchestratorStartupState( - ["orch-lane-1", "orch-lane-2"], - "valid", - state, - null, - new Set(), - ); - assertEqual(result.recommendedAction, "resume", "recommend resume"); - assertEqual(result.orphanSessions.length, 2, "2 orphan sessions"); - assertEqual(result.stateStatus, "valid", "state is valid"); - assert(result.loadedState !== null, "loaded state preserved"); - assert(result.userMessage.includes("/orch-resume"), "message mentions /orch-resume"); - assert(result.userMessage.includes(state.batchId), "message includes batchId"); -} + // Helper: create a minimal valid persisted batch state for testing + function minimalPersistedState( + overrides?: Partial, + ): PersistedBatchStateForTest { + return { + schemaVersion: 2, + phase: "executing", + batchId: "20260309T050000", + startedAt: Date.now() - 60000, + updatedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + wavePlan: [["TS-001", "TS-002"]], + lanes: [], + tasks: [ + { + taskId: "TS-001", + taskFolder: "/tmp/tasks/TS-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + startedAt: Date.now() - 60000, + endedAt: Date.now() - 30000, + doneFileFound: true, + exitReason: "", + }, + { + taskId: "TS-002", + taskFolder: "/tmp/tasks/TS-002", + laneNumber: 2, + sessionName: "orch-lane-2", + status: "running", + startedAt: Date.now() - 60000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], + mergeResults: [], + totalTasks: 2, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: [], + lastError: null, + errors: [], + ...overrides, + }; + } -{ - console.log(" ▸ orphans + missing state → recommend 'abort-orphans'"); - const result = analyzeOrchestratorStartupState( - ["orch-lane-1"], - "missing", - null, - null, - new Set(), - ); - assertEqual(result.recommendedAction, "abort-orphans", "recommend abort"); - assertEqual(result.stateStatus, "missing", "state is missing"); - assert(result.loadedState === null, "no loaded state"); - assert(result.userMessage.includes("/orch-abort"), "message mentions /orch-abort"); -} + { + console.log(" ▸ orphans + valid state → recommend 'resume'"); + const state = minimalPersistedState(); + const result = analyzeOrchestratorStartupState( + ["orch-lane-1", "orch-lane-2"], + "valid", + state, + null, + new Set(), + ); + assertEqual(result.recommendedAction, "resume", "recommend resume"); + assertEqual(result.orphanSessions.length, 2, "2 orphan sessions"); + assertEqual(result.stateStatus, "valid", "state is valid"); + assert(result.loadedState !== null, "loaded state preserved"); + assert(result.userMessage.includes("/orch-resume"), "message mentions /orch-resume"); + assert(result.userMessage.includes(state.batchId), "message includes batchId"); + } -{ - console.log(" ▸ orphans + invalid state → recommend 'abort-orphans' with error context"); - const result = analyzeOrchestratorStartupState( - ["orch-lane-1"], - "invalid", - null, - "[STATE_FILE_PARSE_ERROR] Invalid JSON at position 42", - new Set(), - ); - assertEqual(result.recommendedAction, "abort-orphans", "recommend abort"); - assertEqual(result.stateStatus, "invalid", "state is invalid"); - assert(result.stateError !== null, "error preserved"); - assert(result.userMessage.includes("STATE_FILE_PARSE_ERROR"), "error context in message"); - assert(result.userMessage.includes("/orch-abort"), "message mentions /orch-abort"); -} + { + console.log(" ▸ orphans + missing state → recommend 'abort-orphans'"); + const result = analyzeOrchestratorStartupState(["orch-lane-1"], "missing", null, null, new Set()); + assertEqual(result.recommendedAction, "abort-orphans", "recommend abort"); + assertEqual(result.stateStatus, "missing", "state is missing"); + assert(result.loadedState === null, "no loaded state"); + assert(result.userMessage.includes("/orch-abort"), "message mentions /orch-abort"); + } -{ - console.log(" ▸ orphans + io-error state → recommend 'abort-orphans' with error context"); - const result = analyzeOrchestratorStartupState( - ["orch-lane-1"], - "io-error", - null, - "[STATE_FILE_IO_ERROR] Permission denied", - new Set(), - ); - assertEqual(result.recommendedAction, "abort-orphans", "recommend abort"); - assertEqual(result.stateStatus, "io-error", "state is io-error"); - assert(result.stateError !== null, "error preserved"); - assert(result.userMessage.includes("Permission denied"), "error context in message"); -} + { + console.log(" ▸ orphans + invalid state → recommend 'abort-orphans' with error context"); + const result = analyzeOrchestratorStartupState( + ["orch-lane-1"], + "invalid", + null, + "[STATE_FILE_PARSE_ERROR] Invalid JSON at position 42", + new Set(), + ); + assertEqual(result.recommendedAction, "abort-orphans", "recommend abort"); + assertEqual(result.stateStatus, "invalid", "state is invalid"); + assert(result.stateError !== null, "error preserved"); + assert(result.userMessage.includes("STATE_FILE_PARSE_ERROR"), "error context in message"); + assert(result.userMessage.includes("/orch-abort"), "message mentions /orch-abort"); + } -{ - console.log(" ▸ no orphans + valid state + all done → recommend 'cleanup-stale'"); - const state = minimalPersistedState(); - const result = analyzeOrchestratorStartupState( - [], - "valid", - state, - null, - new Set(["TS-001", "TS-002"]), // All tasks done - ); - assertEqual(result.recommendedAction, "cleanup-stale", "recommend cleanup"); - assertEqual(result.orphanSessions.length, 0, "no orphans"); - assert(result.userMessage.includes("stale"), "message mentions stale"); - assert(result.userMessage.includes(".DONE"), "message mentions .DONE files"); -} + { + console.log(" ▸ orphans + io-error state → recommend 'abort-orphans' with error context"); + const result = analyzeOrchestratorStartupState( + ["orch-lane-1"], + "io-error", + null, + "[STATE_FILE_IO_ERROR] Permission denied", + new Set(), + ); + assertEqual(result.recommendedAction, "abort-orphans", "recommend abort"); + assertEqual(result.stateStatus, "io-error", "state is io-error"); + assert(result.stateError !== null, "error preserved"); + assert(result.userMessage.includes("Permission denied"), "error context in message"); + } -{ - console.log(" ▸ no orphans + valid state + not all done → recommend 'resume' (crashed batch)"); - const state = minimalPersistedState(); - const result = analyzeOrchestratorStartupState( - [], - "valid", - state, - null, - new Set(["TS-001"]), // Only TS-001 done, TS-002 not - ); - assertEqual(result.recommendedAction, "resume", "recommend resume for crashed batch"); - assert(result.userMessage.includes("interrupted"), "message mentions interrupted"); - assert(result.userMessage.includes("1/2"), "shows completion ratio"); - assert(result.userMessage.includes("/orch-resume"), "message mentions /orch-resume"); -} + { + console.log(" ▸ no orphans + valid state + all done → recommend 'cleanup-stale'"); + const state = minimalPersistedState(); + const result = analyzeOrchestratorStartupState( + [], + "valid", + state, + null, + new Set(["TS-001", "TS-002"]), // All tasks done + ); + assertEqual(result.recommendedAction, "cleanup-stale", "recommend cleanup"); + assertEqual(result.orphanSessions.length, 0, "no orphans"); + assert(result.userMessage.includes("stale"), "message mentions stale"); + assert(result.userMessage.includes(".DONE"), "message mentions .DONE files"); + } -{ - console.log(" ▸ no orphans + missing state → recommend 'start-fresh'"); - const result = analyzeOrchestratorStartupState( - [], - "missing", - null, - null, - new Set(), - ); - assertEqual(result.recommendedAction, "start-fresh", "recommend start-fresh"); - assertEqual(result.userMessage, "", "no message for clean start"); -} + { + console.log(" ▸ no orphans + valid state + not all done → recommend 'resume' (crashed batch)"); + const state = minimalPersistedState(); + const result = analyzeOrchestratorStartupState( + [], + "valid", + state, + null, + new Set(["TS-001"]), // Only TS-001 done, TS-002 not + ); + assertEqual(result.recommendedAction, "resume", "recommend resume for crashed batch"); + assert(result.userMessage.includes("interrupted"), "message mentions interrupted"); + assert(result.userMessage.includes("1/2"), "shows completion ratio"); + assert(result.userMessage.includes("/orch-resume"), "message mentions /orch-resume"); + } -{ - console.log(" ▸ no orphans + invalid state → recommend 'paused-corrupt' (never auto-delete)"); - const result = analyzeOrchestratorStartupState( - [], - "invalid", - null, - "[STATE_SCHEMA_INVALID] Unsupported schema version 99", - new Set(), - ); - assertEqual(result.recommendedAction, "paused-corrupt", "recommend paused-corrupt for invalid state"); - assert(result.userMessage.includes("corrupt"), "message mentions corrupt"); - assert(result.userMessage.includes("NOT been deleted"), "message says file was NOT deleted"); - assert(result.userMessage.includes("schema version"), "error context in message"); -} + { + console.log(" ▸ no orphans + missing state → recommend 'start-fresh'"); + const result = analyzeOrchestratorStartupState([], "missing", null, null, new Set()); + assertEqual(result.recommendedAction, "start-fresh", "recommend start-fresh"); + assertEqual(result.userMessage, "", "no message for clean start"); + } -{ - console.log(" ▸ no orphans + io-error state → recommend 'paused-corrupt' (never auto-delete)"); - const result = analyzeOrchestratorStartupState( - [], - "io-error", - null, - "[STATE_FILE_IO_ERROR] EACCES: permission denied", - new Set(), - ); - assertEqual(result.recommendedAction, "paused-corrupt", "recommend paused-corrupt for io-error"); - assert(result.userMessage.includes("corrupt"), "message mentions corrupt"); - assert(result.userMessage.includes("NOT been deleted"), "message says file was NOT deleted"); -} + { + console.log(" ▸ no orphans + invalid state → recommend 'paused-corrupt' (never auto-delete)"); + const result = analyzeOrchestratorStartupState( + [], + "invalid", + null, + "[STATE_SCHEMA_INVALID] Unsupported schema version 99", + new Set(), + ); + assertEqual( + result.recommendedAction, + "paused-corrupt", + "recommend paused-corrupt for invalid state", + ); + assert(result.userMessage.includes("corrupt"), "message mentions corrupt"); + assert(result.userMessage.includes("NOT been deleted"), "message says file was NOT deleted"); + assert(result.userMessage.includes("schema version"), "error context in message"); + } -{ - console.log(" ▸ no orphans + valid state + zero tasks → recommend 'resume' (edge case)"); - // Edge case: state with empty tasks array — allDone is false since allTaskIds.length === 0 - const state = minimalPersistedState({ tasks: [], totalTasks: 0 }); - const result = analyzeOrchestratorStartupState( - [], - "valid", - state, - null, - new Set(), - ); - // With zero tasks, allTaskIds.length > 0 check fails, so allDone = false - // Falls through to "not all done" → resume recommendation - assertEqual(result.recommendedAction, "resume", "zero-task state recommends resume"); -} + { + console.log(" ▸ no orphans + io-error state → recommend 'paused-corrupt' (never auto-delete)"); + const result = analyzeOrchestratorStartupState( + [], + "io-error", + null, + "[STATE_FILE_IO_ERROR] EACCES: permission denied", + new Set(), + ); + assertEqual(result.recommendedAction, "paused-corrupt", "recommend paused-corrupt for io-error"); + assert(result.userMessage.includes("corrupt"), "message mentions corrupt"); + assert(result.userMessage.includes("NOT been deleted"), "message says file was NOT deleted"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 4.1: checkResumeEligibility -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 4.1: checkResumeEligibility ──"); - -// Reimplement checkResumeEligibility (mirrors source exactly) -function checkResumeEligibility(state: any): any { - const { phase, batchId } = state; - - switch (phase) { - case "paused": - return { eligible: true, reason: `Batch ${batchId} is paused and can be resumed.`, phase, batchId }; - case "executing": - return { eligible: true, reason: `Batch ${batchId} was executing when the orchestrator disconnected. Can be resumed.`, phase, batchId }; - case "merging": - return { eligible: true, reason: `Batch ${batchId} was merging when the orchestrator disconnected. Can be resumed.`, phase, batchId }; - case "stopped": - return { eligible: false, reason: `Batch ${batchId} was stopped by failure policy. Use /orch-abort to clean up, then start a new batch.`, phase, batchId }; - case "failed": - return { eligible: false, reason: `Batch ${batchId} has a terminal failure. Use /orch-abort to clean up, then start a new batch.`, phase, batchId }; - case "completed": - return { eligible: false, reason: `Batch ${batchId} already completed. Delete the state file or start a new batch.`, phase, batchId }; - case "idle": - return { eligible: false, reason: `Batch ${batchId} never started execution. Start a new batch with /orch.`, phase, batchId }; - case "planning": - return { eligible: false, reason: `Batch ${batchId} was still in planning phase. Start a new batch with /orch.`, phase, batchId }; - default: - return { eligible: false, reason: `Batch ${batchId} has unknown phase "${phase}". Delete the state file and start a new batch.`, phase, batchId }; + { + console.log(" ▸ no orphans + valid state + zero tasks → recommend 'resume' (edge case)"); + // Edge case: state with empty tasks array — allDone is false since allTaskIds.length === 0 + const state = minimalPersistedState({ tasks: [], totalTasks: 0 }); + const result = analyzeOrchestratorStartupState([], "valid", state, null, new Set()); + // With zero tasks, allTaskIds.length > 0 check fails, so allDone = false + // Falls through to "not all done" → resume recommendation + assertEqual(result.recommendedAction, "resume", "zero-task state recommends resume"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 4.1: checkResumeEligibility + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 4.1: checkResumeEligibility ──"); + + // Reimplement checkResumeEligibility (mirrors source exactly) + function checkResumeEligibility(state: any): any { + const { phase, batchId } = state; + + switch (phase) { + case "paused": + return { + eligible: true, + reason: `Batch ${batchId} is paused and can be resumed.`, + phase, + batchId, + }; + case "executing": + return { + eligible: true, + reason: `Batch ${batchId} was executing when the orchestrator disconnected. Can be resumed.`, + phase, + batchId, + }; + case "merging": + return { + eligible: true, + reason: `Batch ${batchId} was merging when the orchestrator disconnected. Can be resumed.`, + phase, + batchId, + }; + case "stopped": + return { + eligible: false, + reason: `Batch ${batchId} was stopped by failure policy. Use /orch-abort to clean up, then start a new batch.`, + phase, + batchId, + }; + case "failed": + return { + eligible: false, + reason: `Batch ${batchId} has a terminal failure. Use /orch-abort to clean up, then start a new batch.`, + phase, + batchId, + }; + case "completed": + return { + eligible: false, + reason: `Batch ${batchId} already completed. Delete the state file or start a new batch.`, + phase, + batchId, + }; + case "idle": + return { + eligible: false, + reason: `Batch ${batchId} never started execution. Start a new batch with /orch.`, + phase, + batchId, + }; + case "planning": + return { + eligible: false, + reason: `Batch ${batchId} was still in planning phase. Start a new batch with /orch.`, + phase, + batchId, + }; + default: + return { + eligible: false, + reason: `Batch ${batchId} has unknown phase "${phase}". Delete the state file and start a new batch.`, + phase, + batchId, + }; + } } -} -{ - console.log(" ▸ paused → eligible"); - const state = minimalPersistedState({ phase: "paused" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, true, "paused is eligible"); - assertEqual(result.phase, "paused", "phase preserved"); -} + { + console.log(" ▸ paused → eligible"); + const state = minimalPersistedState({ phase: "paused" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, true, "paused is eligible"); + assertEqual(result.phase, "paused", "phase preserved"); + } -{ - console.log(" ▸ executing → eligible (crashed batch)"); - const state = minimalPersistedState({ phase: "executing" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, true, "executing is eligible"); -} + { + console.log(" ▸ executing → eligible (crashed batch)"); + const state = minimalPersistedState({ phase: "executing" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, true, "executing is eligible"); + } -{ - console.log(" ▸ merging → eligible (crashed during merge)"); - const state = minimalPersistedState({ phase: "merging" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, true, "merging is eligible"); -} + { + console.log(" ▸ merging → eligible (crashed during merge)"); + const state = minimalPersistedState({ phase: "merging" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, true, "merging is eligible"); + } -{ - console.log(" ▸ stopped → not eligible"); - const state = minimalPersistedState({ phase: "stopped" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, false, "stopped is not eligible"); - assert(result.reason.includes("stopped"), "reason mentions stopped"); -} + { + console.log(" ▸ stopped → not eligible"); + const state = minimalPersistedState({ phase: "stopped" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, false, "stopped is not eligible"); + assert(result.reason.includes("stopped"), "reason mentions stopped"); + } -{ - console.log(" ▸ failed → not eligible"); - const state = minimalPersistedState({ phase: "failed" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, false, "failed is not eligible"); -} + { + console.log(" ▸ failed → not eligible"); + const state = minimalPersistedState({ phase: "failed" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, false, "failed is not eligible"); + } -{ - console.log(" ▸ completed → not eligible"); - const state = minimalPersistedState({ phase: "completed" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, false, "completed is not eligible"); -} + { + console.log(" ▸ completed → not eligible"); + const state = minimalPersistedState({ phase: "completed" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, false, "completed is not eligible"); + } -{ - console.log(" ▸ idle → not eligible"); - const state = minimalPersistedState({ phase: "idle" }); - const result = checkResumeEligibility(state); - assertEqual(result.eligible, false, "idle is not eligible"); -} + { + console.log(" ▸ idle → not eligible"); + const state = minimalPersistedState({ phase: "idle" }); + const result = checkResumeEligibility(state); + assertEqual(result.eligible, false, "idle is not eligible"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 4.2: reconcileTaskStates + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 4.2: reconcileTaskStates ──"); + + // Reimplement reconcileTaskStates (mirrors source exactly) + function reconcileTaskStates( + persistedState: any, + aliveSessions: ReadonlySet, + doneTaskIds: ReadonlySet, + existingWorktrees: ReadonlySet = new Set(), + ): any[] { + return persistedState.tasks.map((task: any) => { + const sessionAlive = aliveSessions.has(task.sessionName); + const doneFileFound = doneTaskIds.has(task.taskId); + const worktreeExists = existingWorktrees.has(task.taskId); + + // Precedence 1: .DONE file found → task completed + if (doneFileFound) { + return { + taskId: task.taskId, + persistedStatus: task.status, + liveStatus: "succeeded", + sessionAlive, + doneFileFound: true, + worktreeExists, + action: "mark-complete", + }; + } -// ═══════════════════════════════════════════════════════════════════════ -// 4.2: reconcileTaskStates -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 4.2: reconcileTaskStates ──"); - -// Reimplement reconcileTaskStates (mirrors source exactly) -function reconcileTaskStates( - persistedState: any, - aliveSessions: ReadonlySet, - doneTaskIds: ReadonlySet, - existingWorktrees: ReadonlySet = new Set(), -): any[] { - return persistedState.tasks.map((task: any) => { - const sessionAlive = aliveSessions.has(task.sessionName); - const doneFileFound = doneTaskIds.has(task.taskId); - const worktreeExists = existingWorktrees.has(task.taskId); - - // Precedence 1: .DONE file found → task completed - if (doneFileFound) { - return { - taskId: task.taskId, - persistedStatus: task.status, - liveStatus: "succeeded", - sessionAlive, - doneFileFound: true, - worktreeExists, - action: "mark-complete", - }; - } + // Precedence 2: Session alive → reconnect + if (sessionAlive) { + return { + taskId: task.taskId, + persistedStatus: task.status, + liveStatus: "running", + sessionAlive: true, + doneFileFound: false, + worktreeExists, + action: "reconnect", + }; + } - // Precedence 2: Session alive → reconnect - if (sessionAlive) { - return { - taskId: task.taskId, - persistedStatus: task.status, - liveStatus: "running", - sessionAlive: true, - doneFileFound: false, - worktreeExists, - action: "reconnect", - }; - } + // Precedence 3: Already terminal in persisted state → skip + const terminalStatuses = ["succeeded", "failed", "stalled", "skipped"]; + if (terminalStatuses.includes(task.status)) { + return { + taskId: task.taskId, + persistedStatus: task.status, + liveStatus: task.status, + sessionAlive: false, + doneFileFound: false, + worktreeExists, + action: "skip", + }; + } - // Precedence 3: Already terminal in persisted state → skip - const terminalStatuses = ["succeeded", "failed", "stalled", "skipped"]; - if (terminalStatuses.includes(task.status)) { - return { - taskId: task.taskId, - persistedStatus: task.status, - liveStatus: task.status, - sessionAlive: false, - doneFileFound: false, - worktreeExists, - action: "skip", - }; - } + // Precedence 4: Session dead + no .DONE + worktree exists → re-execute + if (worktreeExists) { + return { + taskId: task.taskId, + persistedStatus: task.status, + liveStatus: "pending", + sessionAlive: false, + doneFileFound: false, + worktreeExists: true, + action: "re-execute", + }; + } - // Precedence 4: Session dead + no .DONE + worktree exists → re-execute - if (worktreeExists) { - return { - taskId: task.taskId, - persistedStatus: task.status, - liveStatus: "pending", - sessionAlive: false, - doneFileFound: false, - worktreeExists: true, - action: "re-execute", - }; - } + // Precedence 5: Never-started task (pending + no session assigned) → remain pending + if (task.status === "pending" && !task.sessionName) { + return { + taskId: task.taskId, + persistedStatus: task.status, + liveStatus: "pending", + sessionAlive: false, + doneFileFound: false, + worktreeExists: false, + action: "pending", + }; + } - // Precedence 5: Never-started task (pending + no session assigned) → remain pending - if (task.status === "pending" && !task.sessionName) { + // Precedence 6: Dead session + not terminal + no .DONE + no worktree → failed return { taskId: task.taskId, persistedStatus: task.status, - liveStatus: "pending", + liveStatus: "failed", sessionAlive: false, doneFileFound: false, worktreeExists: false, - action: "pending", + action: "mark-failed", }; - } + }); + } - // Precedence 6: Dead session + not terminal + no .DONE + no worktree → failed + function makeTaskRecord(overrides: Partial = {}): any { return { - taskId: task.taskId, - persistedStatus: task.status, - liveStatus: "failed", - sessionAlive: false, + taskId: "TASK-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/path/to/task", + startedAt: 1000, + endedAt: null, doneFileFound: false, - worktreeExists: false, - action: "mark-failed", + exitReason: "", + ...overrides, }; - }); -} + } -function makeTaskRecord(overrides: Partial = {}): any { - return { - taskId: "TASK-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/path/to/task", - startedAt: 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - ...overrides, - }; -} + { + console.log(" ▸ alive session + no .DONE → action 'reconnect'"); + const state = minimalPersistedState({ + tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], + }); + const result = reconcileTaskStates(state, new Set(["orch-lane-1"]), new Set()); + assertEqual(result.length, 1, "one task reconciled"); + assertEqual(result[0].action, "reconnect", "action is reconnect"); + assertEqual(result[0].sessionAlive, true, "session alive"); + assertEqual(result[0].liveStatus, "running", "live status is running"); + } -{ - console.log(" ▸ alive session + no .DONE → action 'reconnect'"); - const state = minimalPersistedState({ - tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], - }); - const result = reconcileTaskStates(state, new Set(["orch-lane-1"]), new Set()); - assertEqual(result.length, 1, "one task reconciled"); - assertEqual(result[0].action, "reconnect", "action is reconnect"); - assertEqual(result[0].sessionAlive, true, "session alive"); - assertEqual(result[0].liveStatus, "running", "live status is running"); -} + { + console.log(" ▸ dead session + .DONE exists → action 'mark-complete'"); + const state = minimalPersistedState({ + tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], + }); + const result = reconcileTaskStates(state, new Set(), new Set(["T1"])); + assertEqual(result[0].action, "mark-complete", "action is mark-complete"); + assertEqual(result[0].doneFileFound, true, "done file found"); + assertEqual(result[0].liveStatus, "succeeded", "live status is succeeded"); + } -{ - console.log(" ▸ dead session + .DONE exists → action 'mark-complete'"); - const state = minimalPersistedState({ - tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], - }); - const result = reconcileTaskStates(state, new Set(), new Set(["T1"])); - assertEqual(result[0].action, "mark-complete", "action is mark-complete"); - assertEqual(result[0].doneFileFound, true, "done file found"); - assertEqual(result[0].liveStatus, "succeeded", "live status is succeeded"); -} + { + console.log(" ▸ dead session + no .DONE → action 'mark-failed'"); + const state = minimalPersistedState({ + tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], + }); + const result = reconcileTaskStates(state, new Set(), new Set()); + assertEqual(result[0].action, "mark-failed", "action is mark-failed"); + assertEqual(result[0].liveStatus, "failed", "live status is failed"); + } -{ - console.log(" ▸ dead session + no .DONE → action 'mark-failed'"); - const state = minimalPersistedState({ - tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], - }); - const result = reconcileTaskStates(state, new Set(), new Set()); - assertEqual(result[0].action, "mark-failed", "action is mark-failed"); - assertEqual(result[0].liveStatus, "failed", "live status is failed"); -} - -{ - console.log(" ▸ alive session + .DONE exists → action 'mark-complete' (DONE takes precedence)"); - const state = minimalPersistedState({ - tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], - }); - const result = reconcileTaskStates(state, new Set(["orch-lane-1"]), new Set(["T1"])); - assertEqual(result[0].action, "mark-complete", "DONE takes precedence over alive session"); - assertEqual(result[0].doneFileFound, true, "done file found"); - assertEqual(result[0].sessionAlive, true, "session is alive (but DONE overrides)"); -} + { + console.log(" ▸ alive session + .DONE exists → action 'mark-complete' (DONE takes precedence)"); + const state = minimalPersistedState({ + tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" })], + }); + const result = reconcileTaskStates(state, new Set(["orch-lane-1"]), new Set(["T1"])); + assertEqual(result[0].action, "mark-complete", "DONE takes precedence over alive session"); + assertEqual(result[0].doneFileFound, true, "done file found"); + assertEqual(result[0].sessionAlive, true, "session is alive (but DONE overrides)"); + } -{ - console.log(" ▸ persisted succeeded + no session → action 'skip' (already done)"); - const state = minimalPersistedState({ - tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "succeeded" })], - }); - const result = reconcileTaskStates(state, new Set(), new Set()); - assertEqual(result[0].action, "skip", "already succeeded → skip"); - assertEqual(result[0].liveStatus, "succeeded", "live status preserved"); -} + { + console.log(" ▸ persisted succeeded + no session → action 'skip' (already done)"); + const state = minimalPersistedState({ + tasks: [makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "succeeded" })], + }); + const result = reconcileTaskStates(state, new Set(), new Set()); + assertEqual(result[0].action, "skip", "already succeeded → skip"); + assertEqual(result[0].liveStatus, "succeeded", "live status preserved"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 4.3: computeResumePoint -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 4.3: computeResumePoint + // ═══════════════════════════════════════════════════════════════════════ -console.log("\n── 4.3: computeResumePoint ──"); + console.log("\n── 4.3: computeResumePoint ──"); -// Reimplement computeResumePoint (mirrors source exactly) -function computeResumePoint( - persistedState: any, - reconciledTasks: any[], -): any { - const reconciledMap = new Map(); - for (const task of reconciledTasks) { - reconciledMap.set(task.taskId, task); - } + // Reimplement computeResumePoint (mirrors source exactly) + function computeResumePoint(persistedState: any, reconciledTasks: any[]): any { + const reconciledMap = new Map(); + for (const task of reconciledTasks) { + reconciledMap.set(task.taskId, task); + } - const completedTaskIds: string[] = []; - const pendingTaskIds: string[] = []; - const failedTaskIds: string[] = []; - const reconnectTaskIds: string[] = []; - const reExecuteTaskIds: string[] = []; + const completedTaskIds: string[] = []; + const pendingTaskIds: string[] = []; + const failedTaskIds: string[] = []; + const reconnectTaskIds: string[] = []; + const reExecuteTaskIds: string[] = []; - for (const task of reconciledTasks) { - switch (task.action) { - case "mark-complete": - completedTaskIds.push(task.taskId); - break; - case "skip": - if (task.liveStatus === "succeeded" || task.persistedStatus === "succeeded") { + for (const task of reconciledTasks) { + switch (task.action) { + case "mark-complete": completedTaskIds.push(task.taskId); - } else if (task.liveStatus === "failed" || task.liveStatus === "stalled" || task.persistedStatus === "failed" || task.persistedStatus === "stalled") { + break; + case "skip": + if (task.liveStatus === "succeeded" || task.persistedStatus === "succeeded") { + completedTaskIds.push(task.taskId); + } else if ( + task.liveStatus === "failed" || + task.liveStatus === "stalled" || + task.persistedStatus === "failed" || + task.persistedStatus === "stalled" + ) { + failedTaskIds.push(task.taskId); + } + // persistedStatus === "skipped" → terminal but neither completed nor failed. + // Not re-queued. Counted separately via batchState.skippedTasks (carried from persisted state). + break; + case "reconnect": + reconnectTaskIds.push(task.taskId); + break; + case "re-execute": + reExecuteTaskIds.push(task.taskId); + break; + case "mark-failed": failedTaskIds.push(task.taskId); - } - // persistedStatus === "skipped" → terminal but neither completed nor failed. - // Not re-queued. Counted separately via batchState.skippedTasks (carried from persisted state). - break; - case "reconnect": - reconnectTaskIds.push(task.taskId); - break; - case "re-execute": - reExecuteTaskIds.push(task.taskId); - break; - case "mark-failed": - failedTaskIds.push(task.taskId); - break; - case "pending": - // Never-started tasks remain pending for execution — not failed. - pendingTaskIds.push(task.taskId); + break; + case "pending": + // Never-started tasks remain pending for execution — not failed. + pendingTaskIds.push(task.taskId); + break; + } + } + + let resumeWaveIndex = persistedState.wavePlan.length; + for (let i = 0; i < persistedState.wavePlan.length; i++) { + const waveTasks = persistedState.wavePlan[i]; + const allDone = waveTasks.every((taskId: string) => { + const reconciled = reconciledMap.get(taskId); + if (!reconciled) return false; + // A task is "done" for wave-skip purposes if it completed or is otherwise terminal. + // mark-failed is intentionally NOT included here. + return ( + reconciled.action === "mark-complete" || + (reconciled.action === "skip" && + (reconciled.liveStatus === "succeeded" || + reconciled.liveStatus === "failed" || + reconciled.liveStatus === "stalled" || + reconciled.liveStatus === "skipped" || + reconciled.persistedStatus === "succeeded" || + reconciled.persistedStatus === "failed" || + reconciled.persistedStatus === "stalled" || + reconciled.persistedStatus === "skipped")) + ); + }); + + if (!allDone) { + resumeWaveIndex = i; break; + } + } + + // Determine pending tasks: tasks in resume wave and later that need execution + const actualPendingTaskIds: string[] = []; + for (let i = resumeWaveIndex; i < persistedState.wavePlan.length; i++) { + for (const taskId of persistedState.wavePlan[i]) { + const reconciled = reconciledMap.get(taskId); + if (!reconciled) { + actualPendingTaskIds.push(taskId); // Unknown task — treat as pending + continue; + } + if (reconciled.action === "reconnect") { + // Tasks with alive sessions need reconnection and remain pending. + actualPendingTaskIds.push(taskId); + } + if (reconciled.action === "re-execute") { + // Tasks with existing worktrees need re-execution and remain pending. + actualPendingTaskIds.push(taskId); + } + if (reconciled.action === "skip" && reconciled.persistedStatus === "pending") { + // Skipped tasks that were pending need execution + actualPendingTaskIds.push(taskId); + } + if (reconciled.action === "pending") { + // Never-started tasks from future waves need execution + actualPendingTaskIds.push(taskId); + } + } } + + return { + resumeWaveIndex, + completedTaskIds, + pendingTaskIds: actualPendingTaskIds, + failedTaskIds, + reconnectTaskIds, + reExecuteTaskIds, + }; } - let resumeWaveIndex = persistedState.wavePlan.length; - for (let i = 0; i < persistedState.wavePlan.length; i++) { - const waveTasks = persistedState.wavePlan[i]; - const allDone = waveTasks.every((taskId: string) => { - const reconciled = reconciledMap.get(taskId); - if (!reconciled) return false; - // A task is "done" for wave-skip purposes if it completed or is otherwise terminal. - // mark-failed is intentionally NOT included here. - return ( - reconciled.action === "mark-complete" || - (reconciled.action === "skip" && ( - reconciled.liveStatus === "succeeded" || - reconciled.liveStatus === "failed" || - reconciled.liveStatus === "stalled" || - reconciled.liveStatus === "skipped" || - reconciled.persistedStatus === "succeeded" || - reconciled.persistedStatus === "failed" || - reconciled.persistedStatus === "stalled" || - reconciled.persistedStatus === "skipped" - )) - ); + { + console.log( + " ▸ all tasks in wave 0 done → resumeWaveIndex=1, future-wave pending task remains pending", + ); + const state = minimalPersistedState({ + wavePlan: [["T1", "T2"], ["T3"]], + tasks: [ + makeTaskRecord({ taskId: "T1", status: "succeeded" }), + makeTaskRecord({ taskId: "T2", status: "succeeded" }), + // T3 is a future-wave task that was never allocated (no session name) + makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "" }), + ], }); + // All in wave 0 are succeeded → skip action + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + const point = computeResumePoint(state, reconciled); + assertEqual(point.resumeWaveIndex, 1, "resumes from wave 1"); + assertEqual(point.completedTaskIds.length, 2, "2 tasks completed"); + // T3: pending + no session → "pending" action → pendingTaskIds (not failed) + assert( + point.pendingTaskIds.includes("T3"), + "T3 is pending for execution (never-started future-wave task)", + ); + assert(!point.failedTaskIds.includes("T3"), "T3 is NOT failed (it was never started)"); + } - if (!allDone) { - resumeWaveIndex = i; - break; - } + { + console.log(" ▸ all tasks in wave 0 done → mark-failed for allocated-but-crashed pending task"); + const state = minimalPersistedState({ + wavePlan: [["T1", "T2"], ["T3"]], + tasks: [ + makeTaskRecord({ taskId: "T1", status: "succeeded" }), + makeTaskRecord({ taskId: "T2", status: "succeeded" }), + // T3 was allocated to a lane (has session name) but still pending — crashed before executing + makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "orch-lane-2" }), + ], + }); + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + const point = computeResumePoint(state, reconciled); + // Wave 0: T1+T2 succeeded (skip→done). Wave 1: T3 mark-failed → NOT done for wave-skip. + assertEqual(point.resumeWaveIndex, 1, "resumes from wave 1 (mark-failed NOT done for wave-skip)"); + // T3: pending status + has session + dead session + no .DONE + no worktree → mark-failed + assert(point.failedTaskIds.includes("T3"), "T3 is failed (allocated but crashed, no worktree)"); + assert(!point.pendingTaskIds.includes("T3"), "T3 is NOT pending (it was allocated and crashed)"); } - // Determine pending tasks: tasks in resume wave and later that need execution - const actualPendingTaskIds: string[] = []; - for (let i = resumeWaveIndex; i < persistedState.wavePlan.length; i++) { - for (const taskId of persistedState.wavePlan[i]) { - const reconciled = reconciledMap.get(taskId); - if (!reconciled) { - actualPendingTaskIds.push(taskId); // Unknown task — treat as pending - continue; - } - if (reconciled.action === "reconnect") { - // Tasks with alive sessions need reconnection and remain pending. - actualPendingTaskIds.push(taskId); - } - if (reconciled.action === "re-execute") { - // Tasks with existing worktrees need re-execution and remain pending. - actualPendingTaskIds.push(taskId); - } - if (reconciled.action === "skip" && reconciled.persistedStatus === "pending") { - // Skipped tasks that were pending need execution - actualPendingTaskIds.push(taskId); - } - if (reconciled.action === "pending") { - // Never-started tasks from future waves need execution - actualPendingTaskIds.push(taskId); - } - } + { + console.log(" ▸ partial wave 0 → resumeWaveIndex=0 with correct pending"); + const state = minimalPersistedState({ + wavePlan: [["T1", "T2"], ["T3"]], + tasks: [ + makeTaskRecord({ taskId: "T1", status: "succeeded" }), + makeTaskRecord({ taskId: "T2", status: "running" }), + makeTaskRecord({ taskId: "T3", status: "pending" }), + ], + }); + // T1 is succeeded→skip (terminal), T2 is running+dead→mark-failed (terminal), T3 is pending+has session→mark-failed (terminal) + // T1 succeeded (skip→done), T2 running+dead→mark-failed (NOT done), T3 pending+session→mark-failed + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + const point = computeResumePoint(state, reconciled); + assertEqual(point.resumeWaveIndex, 0, "resumes from wave 0 (mark-failed NOT done for wave-skip)"); + assert(point.completedTaskIds.includes("T1"), "T1 completed"); + assert(point.failedTaskIds.includes("T2"), "T2 failed"); } - return { - resumeWaveIndex, - completedTaskIds, - pendingTaskIds: actualPendingTaskIds, - failedTaskIds, - reconnectTaskIds, - reExecuteTaskIds, + { + console.log(" ▸ mixed done/pending across waves → correct categorization"); + const state = minimalPersistedState({ + wavePlan: [["T1"], ["T2", "T3"], ["T4"]], + tasks: [ + makeTaskRecord({ taskId: "T1", status: "succeeded" }), + makeTaskRecord({ taskId: "T2", status: "succeeded" }), + makeTaskRecord({ taskId: "T3", status: "running", sessionName: "orch-lane-2" }), + makeTaskRecord({ taskId: "T4", status: "pending" }), + ], + }); + // T1: succeeded→skip, T2: succeeded→skip, T3: running+alive→reconnect, T4: pending+dead→mark-failed + const reconciled = reconcileTaskStates(state, new Set(["orch-lane-2"]), new Set()); + const point = computeResumePoint(state, reconciled); + // Wave 0: T1 done. Wave 1: T2 done but T3 is reconnect (not "allDone" since reconnect != skip) + assertEqual(point.resumeWaveIndex, 1, "resumes from wave 1 (T3 still running)"); + assertEqual(point.completedTaskIds.length, 2, "T1 and T2 completed"); + assertEqual(point.reconnectTaskIds.length, 1, "T3 needs reconnection"); + assert(point.reconnectTaskIds.includes("T3"), "T3 in reconnect list"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 5.1: selectAbortTargetSessions + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 5.1: selectAbortTargetSessions ──"); + + // Reimplement selectAbortTargetSessions (mirrors source exactly) + type AbortTargetSession = { + sessionName: string; + laneId: string; + taskId: string | null; + taskFolderInWorktree: string | null; + worktreePath: string | null; }; -} -{ - console.log(" ▸ all tasks in wave 0 done → resumeWaveIndex=1, future-wave pending task remains pending"); - const state = minimalPersistedState({ - wavePlan: [["T1", "T2"], ["T3"]], - tasks: [ - makeTaskRecord({ taskId: "T1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", status: "succeeded" }), - // T3 is a future-wave task that was never allocated (no session name) - makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "" }), - ], - }); - // All in wave 0 are succeeded → skip action - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - const point = computeResumePoint(state, reconciled); - assertEqual(point.resumeWaveIndex, 1, "resumes from wave 1"); - assertEqual(point.completedTaskIds.length, 2, "2 tasks completed"); - // T3: pending + no session → "pending" action → pendingTaskIds (not failed) - assert(point.pendingTaskIds.includes("T3"), "T3 is pending for execution (never-started future-wave task)"); - assert(!point.failedTaskIds.includes("T3"), "T3 is NOT failed (it was never started)"); -} + function selectAbortTargetSessions( + allSessionNames: string[], + persistedState: any | null, + runtimeLanes: any[], + repoRoot: string, + ): AbortTargetSession[] { + const targetNames = allSessionNames.filter((name) => { + const suffix = name.replace(/^[^-]+-/, ""); + return suffix.startsWith("lane-") || suffix.startsWith("merge-"); + }); -{ - console.log(" ▸ all tasks in wave 0 done → mark-failed for allocated-but-crashed pending task"); - const state = minimalPersistedState({ - wavePlan: [["T1", "T2"], ["T3"]], - tasks: [ - makeTaskRecord({ taskId: "T1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", status: "succeeded" }), - // T3 was allocated to a lane (has session name) but still pending — crashed before executing - makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "orch-lane-2" }), - ], - }); - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - const point = computeResumePoint(state, reconciled); - // Wave 0: T1+T2 succeeded (skip→done). Wave 1: T3 mark-failed → NOT done for wave-skip. - assertEqual(point.resumeWaveIndex, 1, "resumes from wave 1 (mark-failed NOT done for wave-skip)"); - // T3: pending status + has session + dead session + no .DONE + no worktree → mark-failed - assert(point.failedTaskIds.includes("T3"), "T3 is failed (allocated but crashed, no worktree)"); - assert(!point.pendingTaskIds.includes("T3"), "T3 is NOT pending (it was allocated and crashed)"); -} + const persistedLookup = new Map(); + if (persistedState) { + for (const task of persistedState.tasks) { + if (task.sessionName) { + persistedLookup.set(task.sessionName, { + laneId: `lane-${task.laneNumber}`, + taskId: task.taskId, + taskFolder: task.taskFolder, + }); + } + } + } -{ - console.log(" ▸ partial wave 0 → resumeWaveIndex=0 with correct pending"); - const state = minimalPersistedState({ - wavePlan: [["T1", "T2"], ["T3"]], - tasks: [ - makeTaskRecord({ taskId: "T1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", status: "running" }), - makeTaskRecord({ taskId: "T3", status: "pending" }), - ], - }); - // T1 is succeeded→skip (terminal), T2 is running+dead→mark-failed (terminal), T3 is pending+has session→mark-failed (terminal) - // T1 succeeded (skip→done), T2 running+dead→mark-failed (NOT done), T3 pending+session→mark-failed - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - const point = computeResumePoint(state, reconciled); - assertEqual(point.resumeWaveIndex, 0, "resumes from wave 0 (mark-failed NOT done for wave-skip)"); - assert(point.completedTaskIds.includes("T1"), "T1 completed"); - assert(point.failedTaskIds.includes("T2"), "T2 failed"); -} + const runtimeLookup = new Map< + string, + { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null } + >(); + for (const lane of runtimeLanes) { + const currentTask = lane.tasks && lane.tasks.length > 0 ? lane.tasks[0] : null; + runtimeLookup.set(lane.laneSessionId, { + laneId: lane.laneId, + taskId: currentTask?.taskId || null, + worktreePath: lane.worktreePath, + taskFolder: currentTask?.task?.taskFolder || null, + }); + } -{ - console.log(" ▸ mixed done/pending across waves → correct categorization"); - const state = minimalPersistedState({ - wavePlan: [["T1"], ["T2", "T3"], ["T4"]], - tasks: [ - makeTaskRecord({ taskId: "T1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", status: "succeeded" }), - makeTaskRecord({ taskId: "T3", status: "running", sessionName: "orch-lane-2" }), - makeTaskRecord({ taskId: "T4", status: "pending" }), - ], - }); - // T1: succeeded→skip, T2: succeeded→skip, T3: running+alive→reconnect, T4: pending+dead→mark-failed - const reconciled = reconcileTaskStates(state, new Set(["orch-lane-2"]), new Set()); - const point = computeResumePoint(state, reconciled); - // Wave 0: T1 done. Wave 1: T2 done but T3 is reconnect (not "allDone" since reconnect != skip) - assertEqual(point.resumeWaveIndex, 1, "resumes from wave 1 (T3 still running)"); - assertEqual(point.completedTaskIds.length, 2, "T1 and T2 completed"); - assertEqual(point.reconnectTaskIds.length, 1, "T3 needs reconnection"); - assert(point.reconnectTaskIds.includes("T3"), "T3 in reconnect list"); -} + return targetNames.map((sessionName) => { + const runtime = runtimeLookup.get(sessionName); + const persisted = persistedLookup.get(sessionName); + + const laneId = runtime?.laneId || persisted?.laneId || "unknown"; + const taskId = runtime?.taskId || persisted?.taskId || null; + const worktreePath = runtime?.worktreePath || null; + const taskFolder = runtime?.taskFolder || persisted?.taskFolder || null; + + let taskFolderInWorktree: string | null = null; + if (taskFolder && worktreePath && repoRoot) { + const repoRootNorm = repoRoot.replace(/\\/g, "/"); + const folderNorm = taskFolder.replace(/\\/g, "/"); + let relativePath: string; + if (folderNorm.startsWith(repoRootNorm + "/")) { + relativePath = folderNorm.slice(repoRootNorm.length + 1); + } else { + relativePath = taskFolder; + } + taskFolderInWorktree = join(worktreePath, relativePath); + } -// ═══════════════════════════════════════════════════════════════════════ -// 5.1: selectAbortTargetSessions -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 5.1: selectAbortTargetSessions ──"); - -// Reimplement selectAbortTargetSessions (mirrors source exactly) -type AbortTargetSession = { - sessionName: string; - laneId: string; - taskId: string | null; - taskFolderInWorktree: string | null; - worktreePath: string | null; -}; - -function selectAbortTargetSessions( - allSessionNames: string[], - persistedState: any | null, - runtimeLanes: any[], - repoRoot: string, -): AbortTargetSession[] { - const targetNames = allSessionNames.filter(name => { - const suffix = name.replace(/^[^-]+-/, ""); - return suffix.startsWith("lane-") || suffix.startsWith("merge-"); - }); + return { sessionName, laneId, taskId, taskFolderInWorktree, worktreePath }; + }); + } - const persistedLookup = new Map(); - if (persistedState) { - for (const task of persistedState.tasks) { - if (task.sessionName) { - persistedLookup.set(task.sessionName, { - laneId: `lane-${task.laneNumber}`, - taskId: task.taskId, - taskFolder: task.taskFolder, - }); - } - } + { + console.log(" ▸ filters orch-lane-* and orch-merge-* sessions, ignores other sessions"); + const allSessions = [ + "orch-lane-1", + "orch-lane-2", + "orch-lane-1-worker", + "orch-lane-1-reviewer", + "orch-merge-1", + "orch-something-else", + "my-session", + ]; + const result = selectAbortTargetSessions(allSessions, null, [], "/repo"); + assertEqual(result.length, 5, "5 targets selected"); + const names = result.map((r) => r.sessionName); + assert(names.includes("orch-lane-1"), "includes orch-lane-1"); + assert(names.includes("orch-lane-2"), "includes orch-lane-2"); + assert(names.includes("orch-lane-1-worker"), "includes orch-lane-1-worker"); + assert(names.includes("orch-lane-1-reviewer"), "includes orch-lane-1-reviewer"); + assert(names.includes("orch-merge-1"), "includes orch-merge-1"); + assert(!names.includes("orch-something-else"), "excludes orch-something-else"); + assert(!names.includes("my-session"), "excludes my-session"); } - const runtimeLookup = new Map(); - for (const lane of runtimeLanes) { - const currentTask = lane.tasks && lane.tasks.length > 0 ? lane.tasks[0] : null; - runtimeLookup.set(lane.laneSessionId, { - laneId: lane.laneId, - taskId: currentTask?.taskId || null, - worktreePath: lane.worktreePath, - taskFolder: currentTask?.task?.taskFolder || null, + { + console.log(" ▸ enriches sessions with taskFolder from persisted state"); + const allSessions = ["orch-lane-1"]; + const persisted = minimalPersistedState({ + tasks: [ + makeTaskRecord({ + taskId: "TO-001", + sessionName: "orch-lane-1", + laneNumber: 1, + taskFolder: "/repo/docs/tasks/TO-001", + }), + ], }); + const runtimeLanes = [ + { + laneSessionId: "orch-lane-1", + laneId: "lane-1", + worktreePath: "/worktrees/lane-1", + tasks: [{ taskId: "TO-001", task: { taskFolder: "/repo/docs/tasks/TO-001" } }], + }, + ]; + const result = selectAbortTargetSessions(allSessions, persisted, runtimeLanes, "/repo"); + assertEqual(result.length, 1, "1 target selected"); + assertEqual(result[0].laneId, "lane-1", "lane ID from runtime"); + assertEqual(result[0].taskId, "TO-001", "task ID from runtime"); + assert(result[0].taskFolderInWorktree !== null, "task folder resolved"); + assert(result[0].taskFolderInWorktree!.includes("lane-1"), "task folder in worktree path"); + assert(result[0].taskFolderInWorktree!.includes("TO-001"), "task folder includes task path"); } - return targetNames.map(sessionName => { - const runtime = runtimeLookup.get(sessionName); - const persisted = persistedLookup.get(sessionName); + { + console.log(" ▸ handles no persisted state (null) gracefully"); + const allSessions = ["orch-lane-1", "orch-lane-2"]; + const result = selectAbortTargetSessions(allSessions, null, [], "/repo"); + assertEqual(result.length, 2, "2 targets selected"); + assertEqual(result[0].laneId, "unknown", "lane ID unknown without state"); + assertEqual(result[0].taskId, null, "no task ID without state"); + assertEqual(result[0].taskFolderInWorktree, null, "no task folder without state"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 5.2: planAbortActions + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 5.2: planAbortActions ──"); + + type AbortActionStep = + | { type: "write-wrapup" } + | { type: "poll-wait"; gracePeriodMs: number; pollIntervalMs: number } + | { type: "kill-remaining" } + | { type: "kill-all" }; + + function planAbortActions( + mode: "graceful" | "hard", + gracePeriodMs: number = 60_000, + pollIntervalMs: number = 2_000, + ): AbortActionStep[] { + if (mode === "hard") { + return [{ type: "kill-all" }]; + } + return [ + { type: "write-wrapup" }, + { type: "poll-wait", gracePeriodMs, pollIntervalMs }, + { type: "kill-remaining" }, + ]; + } - const laneId = runtime?.laneId || persisted?.laneId || "unknown"; - const taskId = runtime?.taskId || persisted?.taskId || null; - const worktreePath = runtime?.worktreePath || null; - const taskFolder = runtime?.taskFolder || persisted?.taskFolder || null; + { + console.log(" ▸ graceful mode returns write-wrapup → poll → kill-remaining steps"); + const steps = planAbortActions("graceful", 60000, 2000); + assertEqual(steps.length, 3, "3 steps for graceful"); + assertEqual(steps[0].type, "write-wrapup", "step 1: write-wrapup"); + assertEqual(steps[1].type, "poll-wait", "step 2: poll-wait"); + const pollStep = steps[1] as { type: "poll-wait"; gracePeriodMs: number; pollIntervalMs: number }; + assertEqual(pollStep.gracePeriodMs, 60000, "grace period 60s"); + assertEqual(pollStep.pollIntervalMs, 2000, "poll interval 2s"); + assertEqual(steps[2].type, "kill-remaining", "step 3: kill-remaining"); + } - let taskFolderInWorktree: string | null = null; - if (taskFolder && worktreePath && repoRoot) { - const repoRootNorm = repoRoot.replace(/\\/g, "/"); - const folderNorm = taskFolder.replace(/\\/g, "/"); - let relativePath: string; - if (folderNorm.startsWith(repoRootNorm + "/")) { - relativePath = folderNorm.slice(repoRootNorm.length + 1); - } else { - relativePath = taskFolder; - } - taskFolderInWorktree = join(worktreePath, relativePath); - } + { + console.log(" ▸ hard mode returns kill-all step only"); + const steps = planAbortActions("hard"); + assertEqual(steps.length, 1, "1 step for hard"); + assertEqual(steps[0].type, "kill-all", "step 1: kill-all"); + } - return { sessionName, laneId, taskId, taskFolderInWorktree, worktreePath }; - }); -} + // ═══════════════════════════════════════════════════════════════════════ + // 5.3: ORCH_MESSAGES for abort + // ═══════════════════════════════════════════════════════════════════════ -{ - console.log(" ▸ filters orch-lane-* and orch-merge-* sessions, ignores other sessions"); - const allSessions = [ - "orch-lane-1", - "orch-lane-2", - "orch-lane-1-worker", - "orch-lane-1-reviewer", - "orch-merge-1", - "orch-something-else", - "my-session", - ]; - const result = selectAbortTargetSessions(allSessions, null, [], "/repo"); - assertEqual(result.length, 5, "5 targets selected"); - const names = result.map(r => r.sessionName); - assert(names.includes("orch-lane-1"), "includes orch-lane-1"); - assert(names.includes("orch-lane-2"), "includes orch-lane-2"); - assert(names.includes("orch-lane-1-worker"), "includes orch-lane-1-worker"); - assert(names.includes("orch-lane-1-reviewer"), "includes orch-lane-1-reviewer"); - assert(names.includes("orch-merge-1"), "includes orch-merge-1"); - assert(!names.includes("orch-something-else"), "excludes orch-something-else"); - assert(!names.includes("my-session"), "excludes my-session"); -} + console.log("\n── 5.3: ORCH_MESSAGES for abort ──"); -{ - console.log(" ▸ enriches sessions with taskFolder from persisted state"); - const allSessions = ["orch-lane-1"]; - const persisted = minimalPersistedState({ - tasks: [ - makeTaskRecord({ - taskId: "TO-001", - sessionName: "orch-lane-1", - laneNumber: 1, - taskFolder: "/repo/docs/tasks/TO-001", - }), - ], - }); - const runtimeLanes = [ - { - laneSessionId: "orch-lane-1", - laneId: "lane-1", - worktreePath: "/worktrees/lane-1", - tasks: [{ taskId: "TO-001", task: { taskFolder: "/repo/docs/tasks/TO-001" } }], - }, - ]; - const result = selectAbortTargetSessions(allSessions, persisted, runtimeLanes, "/repo"); - assertEqual(result.length, 1, "1 target selected"); - assertEqual(result[0].laneId, "lane-1", "lane ID from runtime"); - assertEqual(result[0].taskId, "TO-001", "task ID from runtime"); - assert(result[0].taskFolderInWorktree !== null, "task folder resolved"); - assert(result[0].taskFolderInWorktree!.includes("lane-1"), "task folder in worktree path"); - assert(result[0].taskFolderInWorktree!.includes("TO-001"), "task folder includes task path"); -} + { + console.log(" ▸ all abort message functions return valid strings"); + + // Reimport the source to verify the messages are defined + // Since we can't import directly, we verify by reimplementing the message functions + const messages = { + abortGracefulStarting: (batchId: string, sessionCount: number) => + `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`, + abortGracefulWaiting: (batchId: string, graceSec: number) => + `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`, + abortGracefulForceKill: (count: number) => + `⚠️ Force-killing ${count} session(s) that did not exit within timeout`, + abortGracefulComplete: ( + batchId: string, + graceful: number, + forceKilled: number, + durationSec: number, + ) => + `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`, + abortHardStarting: (batchId: string, sessionCount: number) => + `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`, + abortHardComplete: (batchId: string, killed: number, durationSec: number) => + `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`, + abortPartialFailure: (failureCount: number) => + `⚠️ ${failureCount} error(s) during abort (see details above)`, + abortNoBatch: () => `No active batch to abort. Use /orch to start a batch.`, + abortComplete: (mode: "graceful" | "hard", sessionsKilled: number) => + `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`, + }; -{ - console.log(" ▸ handles no persisted state (null) gracefully"); - const allSessions = ["orch-lane-1", "orch-lane-2"]; - const result = selectAbortTargetSessions(allSessions, null, [], "/repo"); - assertEqual(result.length, 2, "2 targets selected"); - assertEqual(result[0].laneId, "unknown", "lane ID unknown without state"); - assertEqual(result[0].taskId, null, "no task ID without state"); - assertEqual(result[0].taskFolderInWorktree, null, "no task folder without state"); -} + // Verify each message returns a non-empty string + const gracefulStarting = messages.abortGracefulStarting("BATCH001", 3); + assert( + typeof gracefulStarting === "string" && gracefulStarting.length > 0, + "abortGracefulStarting returns string", + ); + assert(gracefulStarting.includes("BATCH001"), "abortGracefulStarting includes batchId"); + assert(gracefulStarting.includes("3"), "abortGracefulStarting includes session count"); -// ═══════════════════════════════════════════════════════════════════════ -// 5.2: planAbortActions -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 5.2: planAbortActions ──"); - -type AbortActionStep = - | { type: "write-wrapup" } - | { type: "poll-wait"; gracePeriodMs: number; pollIntervalMs: number } - | { type: "kill-remaining" } - | { type: "kill-all" }; - -function planAbortActions( - mode: "graceful" | "hard", - gracePeriodMs: number = 60_000, - pollIntervalMs: number = 2_000, -): AbortActionStep[] { - if (mode === "hard") { - return [{ type: "kill-all" }]; - } - return [ - { type: "write-wrapup" }, - { type: "poll-wait", gracePeriodMs, pollIntervalMs }, - { type: "kill-remaining" }, - ]; -} + const gracefulWaiting = messages.abortGracefulWaiting("BATCH001", 60); + assert( + typeof gracefulWaiting === "string" && gracefulWaiting.length > 0, + "abortGracefulWaiting returns string", + ); + assert(gracefulWaiting.includes("60"), "abortGracefulWaiting includes grace period"); -{ - console.log(" ▸ graceful mode returns write-wrapup → poll → kill-remaining steps"); - const steps = planAbortActions("graceful", 60000, 2000); - assertEqual(steps.length, 3, "3 steps for graceful"); - assertEqual(steps[0].type, "write-wrapup", "step 1: write-wrapup"); - assertEqual(steps[1].type, "poll-wait", "step 2: poll-wait"); - const pollStep = steps[1] as { type: "poll-wait"; gracePeriodMs: number; pollIntervalMs: number }; - assertEqual(pollStep.gracePeriodMs, 60000, "grace period 60s"); - assertEqual(pollStep.pollIntervalMs, 2000, "poll interval 2s"); - assertEqual(steps[2].type, "kill-remaining", "step 3: kill-remaining"); -} + const forceKill = messages.abortGracefulForceKill(2); + assert( + typeof forceKill === "string" && forceKill.length > 0, + "abortGracefulForceKill returns string", + ); + assert(forceKill.includes("2"), "abortGracefulForceKill includes count"); -{ - console.log(" ▸ hard mode returns kill-all step only"); - const steps = planAbortActions("hard"); - assertEqual(steps.length, 1, "1 step for hard"); - assertEqual(steps[0].type, "kill-all", "step 1: kill-all"); -} + const gracefulComplete = messages.abortGracefulComplete("BATCH001", 2, 1, 45); + assert( + typeof gracefulComplete === "string" && gracefulComplete.length > 0, + "abortGracefulComplete returns string", + ); + assert(gracefulComplete.includes("BATCH001"), "abortGracefulComplete includes batchId"); -// ═══════════════════════════════════════════════════════════════════════ -// 5.3: ORCH_MESSAGES for abort -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 5.3: ORCH_MESSAGES for abort ──"); - -{ - console.log(" ▸ all abort message functions return valid strings"); - - // Reimport the source to verify the messages are defined - // Since we can't import directly, we verify by reimplementing the message functions - const messages = { - abortGracefulStarting: (batchId: string, sessionCount: number) => - `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`, - abortGracefulWaiting: (batchId: string, graceSec: number) => - `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`, - abortGracefulForceKill: (count: number) => - `⚠️ Force-killing ${count} session(s) that did not exit within timeout`, - abortGracefulComplete: (batchId: string, graceful: number, forceKilled: number, durationSec: number) => - `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`, - abortHardStarting: (batchId: string, sessionCount: number) => - `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`, - abortHardComplete: (batchId: string, killed: number, durationSec: number) => - `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`, - abortPartialFailure: (failureCount: number) => - `⚠️ ${failureCount} error(s) during abort (see details above)`, - abortNoBatch: () => - `No active batch to abort. Use /orch to start a batch.`, - abortComplete: (mode: "graceful" | "hard", sessionsKilled: number) => - `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`, - }; + const hardStarting = messages.abortHardStarting("BATCH001", 5); + assert( + typeof hardStarting === "string" && hardStarting.length > 0, + "abortHardStarting returns string", + ); + assert(hardStarting.includes("5"), "abortHardStarting includes session count"); - // Verify each message returns a non-empty string - const gracefulStarting = messages.abortGracefulStarting("BATCH001", 3); - assert(typeof gracefulStarting === "string" && gracefulStarting.length > 0, "abortGracefulStarting returns string"); - assert(gracefulStarting.includes("BATCH001"), "abortGracefulStarting includes batchId"); - assert(gracefulStarting.includes("3"), "abortGracefulStarting includes session count"); + const hardComplete = messages.abortHardComplete("BATCH001", 4, 2); + assert( + typeof hardComplete === "string" && hardComplete.length > 0, + "abortHardComplete returns string", + ); + assert(hardComplete.includes("4"), "abortHardComplete includes kill count"); - const gracefulWaiting = messages.abortGracefulWaiting("BATCH001", 60); - assert(typeof gracefulWaiting === "string" && gracefulWaiting.length > 0, "abortGracefulWaiting returns string"); - assert(gracefulWaiting.includes("60"), "abortGracefulWaiting includes grace period"); + const partialFailure = messages.abortPartialFailure(3); + assert( + typeof partialFailure === "string" && partialFailure.length > 0, + "abortPartialFailure returns string", + ); + assert(partialFailure.includes("3"), "abortPartialFailure includes failure count"); - const forceKill = messages.abortGracefulForceKill(2); - assert(typeof forceKill === "string" && forceKill.length > 0, "abortGracefulForceKill returns string"); - assert(forceKill.includes("2"), "abortGracefulForceKill includes count"); + const noBatch = messages.abortNoBatch(); + assert(typeof noBatch === "string" && noBatch.length > 0, "abortNoBatch returns string"); + assert(noBatch.includes("/orch"), "abortNoBatch mentions /orch"); - const gracefulComplete = messages.abortGracefulComplete("BATCH001", 2, 1, 45); - assert(typeof gracefulComplete === "string" && gracefulComplete.length > 0, "abortGracefulComplete returns string"); - assert(gracefulComplete.includes("BATCH001"), "abortGracefulComplete includes batchId"); + const complete = messages.abortComplete("graceful", 3); + assert(typeof complete === "string" && complete.length > 0, "abortComplete returns string"); + assert(complete.includes("graceful"), "abortComplete includes mode"); + assert(complete.includes("Worktrees"), "abortComplete mentions preserved worktrees"); - const hardStarting = messages.abortHardStarting("BATCH001", 5); - assert(typeof hardStarting === "string" && hardStarting.length > 0, "abortHardStarting returns string"); - assert(hardStarting.includes("5"), "abortHardStarting includes session count"); + const hardAbortComplete = messages.abortComplete("hard", 5); + assert(hardAbortComplete.includes("hard"), "abortComplete hard mode includes mode"); + } - const hardComplete = messages.abortHardComplete("BATCH001", 4, 2); - assert(typeof hardComplete === "string" && hardComplete.length > 0, "abortHardComplete returns string"); - assert(hardComplete.includes("4"), "abortHardComplete includes kill count"); + // Also verify abort message functions exist in the source file + { + assert(source.includes("abortGracefulStarting:"), "source defines abortGracefulStarting"); + assert(source.includes("abortGracefulWaiting:"), "source defines abortGracefulWaiting"); + assert(source.includes("abortGracefulForceKill:"), "source defines abortGracefulForceKill"); + assert(source.includes("abortGracefulComplete:"), "source defines abortGracefulComplete"); + assert(source.includes("abortHardStarting:"), "source defines abortHardStarting"); + assert(source.includes("abortHardComplete:"), "source defines abortHardComplete"); + assert(source.includes("abortPartialFailure:"), "source defines abortPartialFailure"); + assert(source.includes("abortNoBatch:"), "source defines abortNoBatch"); + assert(source.includes("abortComplete:"), "source defines abortComplete"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 6.1: Mixed-Outcome Lane Guard + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 6.1: Mixed-outcome lane guard ──"); + + /** + * Reimplementation of the mixed-outcome lane guard logic from executeOrchBatch(). + * This tests the decision logic: a lane with both succeeded and failed tasks should + * trigger merge failure handling (status="partial"), NOT silently merge or skip. + */ + interface TestLaneTaskOutcome { + taskId: string; + status: "succeeded" | "failed" | "stalled" | "skipped" | "pending" | "running"; + } + interface TestLaneExecutionResult { + laneNumber: number; + laneId: string; + tasks: TestLaneTaskOutcome[]; + } + + function detectMixedOutcomeLanes( + laneResults: TestLaneExecutionResult[], + ): TestLaneExecutionResult[] { + return laneResults.filter((lr) => { + const hasSucceeded = lr.tasks.some((t) => t.status === "succeeded"); + const hasHardFailure = lr.tasks.some((t) => t.status === "failed" || t.status === "stalled"); + return hasSucceeded && hasHardFailure; + }); + } - const partialFailure = messages.abortPartialFailure(3); - assert(typeof partialFailure === "string" && partialFailure.length > 0, "abortPartialFailure returns string"); - assert(partialFailure.includes("3"), "abortPartialFailure includes failure count"); + function computeMergeOutcomeForWave( + laneResults: TestLaneExecutionResult[], + succeededTaskIds: string[], + ): { + status: "succeeded" | "partial" | "skipped"; + failedLane: number | null; + failureReason: string | null; + } { + const mixedOutcomeLanes = detectMixedOutcomeLanes(laneResults); - const noBatch = messages.abortNoBatch(); - assert(typeof noBatch === "string" && noBatch.length > 0, "abortNoBatch returns string"); - assert(noBatch.includes("/orch"), "abortNoBatch mentions /orch"); + if (succeededTaskIds.length === 0) { + return { status: "skipped", failedLane: null, failureReason: null }; + } - const complete = messages.abortComplete("graceful", 3); - assert(typeof complete === "string" && complete.length > 0, "abortComplete returns string"); - assert(complete.includes("graceful"), "abortComplete includes mode"); - assert(complete.includes("Worktrees"), "abortComplete mentions preserved worktrees"); + // Build mergeable lane count (succeeded lanes WITHOUT hard failures) + const laneOutcomeByNumber = new Map(); + for (const lr of laneResults) { + laneOutcomeByNumber.set(lr.laneNumber, lr); + } + const mergeableLaneCount = laneResults.filter((lane) => { + const hasSucceeded = lane.tasks.some((t) => t.status === "succeeded"); + const hasHardFailure = lane.tasks.some((t) => t.status === "failed" || t.status === "stalled"); + return hasSucceeded && !hasHardFailure; + }).length; + + if (mergeableLaneCount > 0 && mixedOutcomeLanes.length > 0) { + // Merge happens but mixed-outcome override forces "partial" + const mixedIds = mixedOutcomeLanes.map((l) => `lane-${l.laneNumber}`).join(", "); + return { + status: "partial", + failedLane: mixedOutcomeLanes[0].laneNumber, + failureReason: + `Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` + + `Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`, + }; + } - const hardAbortComplete = messages.abortComplete("hard", 5); - assert(hardAbortComplete.includes("hard"), "abortComplete hard mode includes mode"); -} + if (mergeableLaneCount === 0 && mixedOutcomeLanes.length > 0) { + // No mergeable lanes but mixed outcomes detected — still "partial" + const mixedIds = mixedOutcomeLanes.map((l) => `lane-${l.laneNumber}`).join(", "); + return { + status: "partial", + failedLane: mixedOutcomeLanes[0].laneNumber, + failureReason: + `Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` + + `Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`, + }; + } -// Also verify abort message functions exist in the source file -{ - assert(source.includes("abortGracefulStarting:"), "source defines abortGracefulStarting"); - assert(source.includes("abortGracefulWaiting:"), "source defines abortGracefulWaiting"); - assert(source.includes("abortGracefulForceKill:"), "source defines abortGracefulForceKill"); - assert(source.includes("abortGracefulComplete:"), "source defines abortGracefulComplete"); - assert(source.includes("abortHardStarting:"), "source defines abortHardStarting"); - assert(source.includes("abortHardComplete:"), "source defines abortHardComplete"); - assert(source.includes("abortPartialFailure:"), "source defines abortPartialFailure"); - assert(source.includes("abortNoBatch:"), "source defines abortNoBatch"); - assert(source.includes("abortComplete:"), "source defines abortComplete"); -} + if (mergeableLaneCount > 0) { + return { status: "succeeded", failedLane: null, failureReason: null }; + } -// ═══════════════════════════════════════════════════════════════════════ -// 6.1: Mixed-Outcome Lane Guard -// ═══════════════════════════════════════════════════════════════════════ + return { status: "skipped", failedLane: null, failureReason: null }; + } -console.log("\n── 6.1: Mixed-outcome lane guard ──"); + { + console.log(" ▸ lane with both succeeded and failed tasks → mergeResult.status = 'partial'"); -/** - * Reimplementation of the mixed-outcome lane guard logic from executeOrchBatch(). - * This tests the decision logic: a lane with both succeeded and failed tasks should - * trigger merge failure handling (status="partial"), NOT silently merge or skip. - */ -interface TestLaneTaskOutcome { - taskId: string; - status: "succeeded" | "failed" | "stalled" | "skipped" | "pending" | "running"; -} -interface TestLaneExecutionResult { - laneNumber: number; - laneId: string; - tasks: TestLaneTaskOutcome[]; -} + const laneResults: TestLaneExecutionResult[] = [ + { + laneNumber: 1, + laneId: "lane-1", + tasks: [ + { taskId: "T-001", status: "succeeded" }, + { taskId: "T-002", status: "failed" }, + ], + }, + ]; -function detectMixedOutcomeLanes(laneResults: TestLaneExecutionResult[]): TestLaneExecutionResult[] { - return laneResults.filter(lr => { - const hasSucceeded = lr.tasks.some(t => t.status === "succeeded"); - const hasHardFailure = lr.tasks.some( - t => t.status === "failed" || t.status === "stalled", + const result = computeMergeOutcomeForWave(laneResults, ["T-001"]); + assertEqual(result.status, "partial", "mixed-outcome lane triggers partial status"); + assert(result.failedLane !== null, "failedLane is set"); + assertEqual(result.failedLane, 1, "failedLane points to lane 1"); + assert(result.failureReason !== null, "failure reason is provided"); + assert(result.failureReason!.includes("lane-1"), "failure reason references mixed lane ID"); + assert( + result.failureReason!.includes("both succeeded and failed"), + "failure reason explains mixed outcomes", ); - return hasSucceeded && hasHardFailure; - }); -} + } -function computeMergeOutcomeForWave( - laneResults: TestLaneExecutionResult[], - succeededTaskIds: string[], -): { status: "succeeded" | "partial" | "skipped"; failedLane: number | null; failureReason: string | null } { - const mixedOutcomeLanes = detectMixedOutcomeLanes(laneResults); + { + console.log(" ▸ lane with only succeeded tasks → normal merge (not partial)"); - if (succeededTaskIds.length === 0) { - return { status: "skipped", failedLane: null, failureReason: null }; - } + const laneResults: TestLaneExecutionResult[] = [ + { + laneNumber: 1, + laneId: "lane-1", + tasks: [ + { taskId: "T-001", status: "succeeded" }, + { taskId: "T-002", status: "succeeded" }, + ], + }, + ]; - // Build mergeable lane count (succeeded lanes WITHOUT hard failures) - const laneOutcomeByNumber = new Map(); - for (const lr of laneResults) { - laneOutcomeByNumber.set(lr.laneNumber, lr); + const result = computeMergeOutcomeForWave(laneResults, ["T-001", "T-002"]); + assertEqual(result.status, "succeeded", "all-succeeded lane allows normal merge"); + assertEqual(result.failedLane, null, "no failed lane"); + assertEqual(result.failureReason, null, "no failure reason"); } - const mergeableLaneCount = laneResults.filter(lane => { - const hasSucceeded = lane.tasks.some(t => t.status === "succeeded"); - const hasHardFailure = lane.tasks.some( - t => t.status === "failed" || t.status === "stalled", - ); - return hasSucceeded && !hasHardFailure; - }).length; - if (mergeableLaneCount > 0 && mixedOutcomeLanes.length > 0) { - // Merge happens but mixed-outcome override forces "partial" - const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); - return { - status: "partial", - failedLane: mixedOutcomeLanes[0].laneNumber, - failureReason: - `Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` + - `Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`, - }; - } + { + console.log(" ▸ lane with succeeded + stalled tasks → partial (stalled is hard failure)"); - if (mergeableLaneCount === 0 && mixedOutcomeLanes.length > 0) { - // No mergeable lanes but mixed outcomes detected — still "partial" - const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); - return { - status: "partial", - failedLane: mixedOutcomeLanes[0].laneNumber, - failureReason: - `Lane(s) ${mixedIds} contain both succeeded and failed tasks. ` + - `Automatic partial-branch merge is disabled to avoid dropping succeeded commits.`, - }; - } + const laneResults: TestLaneExecutionResult[] = [ + { + laneNumber: 2, + laneId: "lane-2", + tasks: [ + { taskId: "T-001", status: "succeeded" }, + { taskId: "T-002", status: "stalled" }, + ], + }, + ]; - if (mergeableLaneCount > 0) { - return { status: "succeeded", failedLane: null, failureReason: null }; + const result = computeMergeOutcomeForWave(laneResults, ["T-001"]); + assertEqual(result.status, "partial", "succeeded + stalled = partial"); + assertEqual(result.failedLane, 2, "failed lane is 2"); } - return { status: "skipped", failedLane: null, failureReason: null }; -} + { + console.log(" ▸ multiple lanes: one clean + one mixed → partial due to mixed lane"); -{ - console.log(" ▸ lane with both succeeded and failed tasks → mergeResult.status = 'partial'"); + const laneResults: TestLaneExecutionResult[] = [ + { + laneNumber: 1, + laneId: "lane-1", + tasks: [{ taskId: "T-001", status: "succeeded" }], + }, + { + laneNumber: 2, + laneId: "lane-2", + tasks: [ + { taskId: "T-002", status: "succeeded" }, + { taskId: "T-003", status: "failed" }, + ], + }, + ]; - const laneResults: TestLaneExecutionResult[] = [ - { - laneNumber: 1, - laneId: "lane-1", - tasks: [ - { taskId: "T-001", status: "succeeded" }, - { taskId: "T-002", status: "failed" }, - ], - }, - ]; - - const result = computeMergeOutcomeForWave(laneResults, ["T-001"]); - assertEqual(result.status, "partial", "mixed-outcome lane triggers partial status"); - assert(result.failedLane !== null, "failedLane is set"); - assertEqual(result.failedLane, 1, "failedLane points to lane 1"); - assert(result.failureReason !== null, "failure reason is provided"); - assert(result.failureReason!.includes("lane-1"), "failure reason references mixed lane ID"); - assert(result.failureReason!.includes("both succeeded and failed"), "failure reason explains mixed outcomes"); -} + const result = computeMergeOutcomeForWave(laneResults, ["T-001", "T-002"]); + assertEqual(result.status, "partial", "mixed outcome in any lane escalates to partial"); + assertEqual(result.failedLane, 2, "failed lane is the mixed-outcome lane"); + } -{ - console.log(" ▸ lane with only succeeded tasks → normal merge (not partial)"); + { + console.log(" ▸ lane with only failed tasks (no succeeded) → merge skipped (no mixed outcomes)"); - const laneResults: TestLaneExecutionResult[] = [ - { - laneNumber: 1, - laneId: "lane-1", - tasks: [ - { taskId: "T-001", status: "succeeded" }, - { taskId: "T-002", status: "succeeded" }, - ], - }, - ]; + const laneResults: TestLaneExecutionResult[] = [ + { + laneNumber: 1, + laneId: "lane-1", + tasks: [ + { taskId: "T-001", status: "failed" }, + { taskId: "T-002", status: "skipped" }, + ], + }, + ]; - const result = computeMergeOutcomeForWave(laneResults, ["T-001", "T-002"]); - assertEqual(result.status, "succeeded", "all-succeeded lane allows normal merge"); - assertEqual(result.failedLane, null, "no failed lane"); - assertEqual(result.failureReason, null, "no failure reason"); -} + // No succeeded tasks + const result = computeMergeOutcomeForWave(laneResults, []); + assertEqual(result.status, "skipped", "all-failed lane = merge skipped"); + assertEqual(result.failedLane, null, "no failed lane (no mixed outcome)"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 6.2: Cleanup Suppression on Merge Pause/Abort + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 6.2: Cleanup suppression on merge pause/abort ──"); + + /** + * Reimplementation of cleanup suppression decision logic from executeOrchBatch(). + * Tests that when merge failure transitions batch to paused/stopped, + * preserveWorktreesForResume is set to true and cleanup is skipped. + */ + interface CleanupDecision { + phase: string; + preserveWorktreesForResume: boolean; + persistReasonBeforeCleanup: string | null; + errorsAdded: string[]; + } + + function simulateMergeFailureHandling( + mergeStatus: "failed" | "partial", + mergeFailurePolicy: "pause" | "abort", + waveIdx: number, + failureReason: string, + ): CleanupDecision { + let phase = "executing"; + let preserveWorktreesForResume = false; + let persistReasonBeforeCleanup: string | null = null; + const errorsAdded: string[] = []; + + // This mirrors the merge failure handling code in executeOrchBatch() + if (mergeStatus === "failed" || mergeStatus === "partial") { + if (mergeFailurePolicy === "pause") { + phase = "paused"; + errorsAdded.push( + `Merge failed at wave ${waveIdx + 1}: ${failureReason}. ` + + `Batch paused. Resolve conflicts and use /orch-resume to continue.`, + ); + persistReasonBeforeCleanup = "merge-failure-pause"; + preserveWorktreesForResume = true; + } else { + // abort policy + phase = "stopped"; + errorsAdded.push( + `Merge failed at wave ${waveIdx + 1}: ${failureReason}. ` + + `Batch aborted by on_merge_failure policy.`, + ); + persistReasonBeforeCleanup = "merge-failure-abort"; + preserveWorktreesForResume = true; + } + } -{ - console.log(" ▸ lane with succeeded + stalled tasks → partial (stalled is hard failure)"); + return { phase, preserveWorktreesForResume, persistReasonBeforeCleanup, errorsAdded }; + } - const laneResults: TestLaneExecutionResult[] = [ - { - laneNumber: 2, - laneId: "lane-2", - tasks: [ - { taskId: "T-001", status: "succeeded" }, - { taskId: "T-002", status: "stalled" }, - ], - }, - ]; + { + console.log( + " ▸ merge failure + pause policy → preserveWorktrees=true, phase=paused, persist before cleanup", + ); - const result = computeMergeOutcomeForWave(laneResults, ["T-001"]); - assertEqual(result.status, "partial", "succeeded + stalled = partial"); - assertEqual(result.failedLane, 2, "failed lane is 2"); -} + const result = simulateMergeFailureHandling("partial", "pause", 0, "conflict unresolved"); + assertEqual(result.phase, "paused", "phase transitions to paused"); + assertEqual(result.preserveWorktreesForResume, true, "worktrees preserved for resume"); + assertEqual( + result.persistReasonBeforeCleanup, + "merge-failure-pause", + "state persisted with reason merge-failure-pause", + ); + assertEqual(result.errorsAdded.length, 1, "one error added"); + assert(result.errorsAdded[0].includes("paused"), "error mentions paused"); + assert(result.errorsAdded[0].includes("/orch-resume"), "error suggests resume"); + } -{ - console.log(" ▸ multiple lanes: one clean + one mixed → partial due to mixed lane"); + { + console.log( + " ▸ merge failure + abort policy → preserveWorktrees=true, phase=stopped, persist before cleanup", + ); - const laneResults: TestLaneExecutionResult[] = [ - { - laneNumber: 1, - laneId: "lane-1", - tasks: [{ taskId: "T-001", status: "succeeded" }], - }, - { - laneNumber: 2, - laneId: "lane-2", - tasks: [ - { taskId: "T-002", status: "succeeded" }, - { taskId: "T-003", status: "failed" }, - ], - }, - ]; + const result = simulateMergeFailureHandling( + "failed", + "abort", + 1, + "BUILD_FAILURE on verification", + ); + assertEqual(result.phase, "stopped", "phase transitions to stopped"); + assertEqual(result.preserveWorktreesForResume, true, "worktrees preserved for debugging"); + assertEqual( + result.persistReasonBeforeCleanup, + "merge-failure-abort", + "state persisted with reason merge-failure-abort", + ); + assertEqual(result.errorsAdded.length, 1, "one error added"); + assert(result.errorsAdded[0].includes("aborted"), "error mentions aborted"); + } - const result = computeMergeOutcomeForWave(laneResults, ["T-001", "T-002"]); - assertEqual(result.status, "partial", "mixed outcome in any lane escalates to partial"); - assertEqual(result.failedLane, 2, "failed lane is the mixed-outcome lane"); -} + { + console.log( + " ▸ clean completion (no merge failure) → preserveWorktrees=false, cleanup proceeds", + ); -{ - console.log(" ▸ lane with only failed tasks (no succeeded) → merge skipped (no mixed outcomes)"); + // Simulate: no merge failure means we never enter the merge failure handling block + let preserveWorktreesForResume = false; + let phase = "completed"; - const laneResults: TestLaneExecutionResult[] = [ - { - laneNumber: 1, - laneId: "lane-1", - tasks: [ - { taskId: "T-001", status: "failed" }, - { taskId: "T-002", status: "skipped" }, - ], - }, - ]; + // The cleanup block checks preserveWorktreesForResume + const shouldCleanup = !preserveWorktreesForResume; - // No succeeded tasks - const result = computeMergeOutcomeForWave(laneResults, []); - assertEqual(result.status, "skipped", "all-failed lane = merge skipped"); - assertEqual(result.failedLane, null, "no failed lane (no mixed outcome)"); -} + assertEqual(shouldCleanup, true, "cleanup proceeds on clean completion"); + assertEqual(preserveWorktreesForResume, false, "worktrees not preserved"); + assertEqual(phase, "completed", "phase is completed"); + } -// ═══════════════════════════════════════════════════════════════════════ -// 6.2: Cleanup Suppression on Merge Pause/Abort -// ═══════════════════════════════════════════════════════════════════════ + // Verify the source code actually has the cleanup suppression logic + { + console.log(" ▸ verify source code has preserveWorktreesForResume guard in cleanup block"); + assert( + source.includes("if (preserveWorktreesForResume)"), + "source checks preserveWorktreesForResume in cleanup", + ); + assert( + source.includes("skipping final cleanup to preserve worktrees"), + "source logs cleanup skip reason", + ); + assert(source.includes("merge-failure-pause"), "source persists state before pause cleanup"); + assert(source.includes("merge-failure-abort"), "source persists state before abort cleanup"); + } -console.log("\n── 6.2: Cleanup suppression on merge pause/abort ──"); + // ═══════════════════════════════════════════════════════════════════════ + // 6.3: parseMergeResult Edge Cases + // ═══════════════════════════════════════════════════════════════════════ -/** - * Reimplementation of cleanup suppression decision logic from executeOrchBatch(). - * Tests that when merge failure transitions batch to paused/stopped, - * preserveWorktreesForResume is set to true and cleanup is skipped. - */ -interface CleanupDecision { - phase: string; - preserveWorktreesForResume: boolean; - persistReasonBeforeCleanup: string | null; - errorsAdded: string[]; -} + console.log("\n── 6.3: parseMergeResult edge cases ──"); -function simulateMergeFailureHandling( - mergeStatus: "failed" | "partial", - mergeFailurePolicy: "pause" | "abort", - waveIdx: number, - failureReason: string, -): CleanupDecision { - let phase = "executing"; - let preserveWorktreesForResume = false; - let persistReasonBeforeCleanup: string | null = null; - const errorsAdded: string[] = []; - - // This mirrors the merge failure handling code in executeOrchBatch() - if (mergeStatus === "failed" || mergeStatus === "partial") { - if (mergeFailurePolicy === "pause") { - phase = "paused"; - errorsAdded.push( - `Merge failed at wave ${waveIdx + 1}: ${failureReason}. ` + - `Batch paused. Resolve conflicts and use /orch-resume to continue.`, - ); - persistReasonBeforeCleanup = "merge-failure-pause"; - preserveWorktreesForResume = true; - } else { - // abort policy - phase = "stopped"; - errorsAdded.push( - `Merge failed at wave ${waveIdx + 1}: ${failureReason}. ` + - `Batch aborted by on_merge_failure policy.`, - ); - persistReasonBeforeCleanup = "merge-failure-abort"; - preserveWorktreesForResume = true; + // MergeError reimplementation + class TestMergeError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.name = "MergeError"; + this.code = code; } } - return { phase, preserveWorktreesForResume, persistReasonBeforeCleanup, errorsAdded }; -} - -{ - console.log(" ▸ merge failure + pause policy → preserveWorktrees=true, phase=paused, persist before cleanup"); + // Valid merge statuses (must match source) + const TEST_VALID_MERGE_STATUSES: ReadonlySet = new Set([ + "SUCCESS", + "CONFLICT_RESOLVED", + "CONFLICT_UNRESOLVED", + "BUILD_FAILURE", + ]); + + /** + * Reimplementation of parseMergeResult core logic — WITHOUT retry/sleepSync. + * Tests the validation logic rather than the retry mechanism. + * This mirrors the inner parsing logic of parseMergeResult exactly. + */ + function parseMergeResultCore(filePath: string): any { + if (!existsSync(filePath)) { + throw new TestMergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${filePath}`); + } - const result = simulateMergeFailureHandling("partial", "pause", 0, "conflict unresolved"); - assertEqual(result.phase, "paused", "phase transitions to paused"); - assertEqual(result.preserveWorktreesForResume, true, "worktrees preserved for resume"); - assertEqual(result.persistReasonBeforeCleanup, "merge-failure-pause", "state persisted with reason merge-failure-pause"); - assertEqual(result.errorsAdded.length, 1, "one error added"); - assert(result.errorsAdded[0].includes("paused"), "error mentions paused"); - assert(result.errorsAdded[0].includes("/orch-resume"), "error suggests resume"); -} + const raw = readFileSync(filePath, "utf-8").trim(); + if (!raw) { + throw new TestMergeError("MERGE_RESULT_INVALID", `Merge result file is empty: ${filePath}`); + } -{ - console.log(" ▸ merge failure + abort policy → preserveWorktrees=true, phase=stopped, persist before cleanup"); + let parsed: any; + try { + parsed = JSON.parse(raw); + } catch (err: unknown) { + throw new TestMergeError( + "MERGE_RESULT_INVALID", + `Failed to parse merge result JSON: ${(err as Error).message}. File: ${filePath}`, + ); + } - const result = simulateMergeFailureHandling("failed", "abort", 1, "BUILD_FAILURE on verification"); - assertEqual(result.phase, "stopped", "phase transitions to stopped"); - assertEqual(result.preserveWorktreesForResume, true, "worktrees preserved for debugging"); - assertEqual(result.persistReasonBeforeCleanup, "merge-failure-abort", "state persisted with reason merge-failure-abort"); - assertEqual(result.errorsAdded.length, 1, "one error added"); - assert(result.errorsAdded[0].includes("aborted"), "error mentions aborted"); -} + // Validate required fields + if (typeof parsed.status !== "string") { + throw new TestMergeError( + "MERGE_RESULT_MISSING_FIELDS", + `Merge result missing required field "status": ${filePath}`, + ); + } + if (typeof parsed.source_branch !== "string") { + throw new TestMergeError( + "MERGE_RESULT_MISSING_FIELDS", + `Merge result missing required field "source_branch": ${filePath}`, + ); + } + if (!parsed.verification || typeof parsed.verification !== "object") { + throw new TestMergeError( + "MERGE_RESULT_MISSING_FIELDS", + `Merge result missing required field "verification": ${filePath}`, + ); + } -{ - console.log(" ▸ clean completion (no merge failure) → preserveWorktrees=false, cleanup proceeds"); + // Validate status value — unknown → BUILD_FAILURE + if (!TEST_VALID_MERGE_STATUSES.has(parsed.status)) { + parsed.status = "BUILD_FAILURE"; + } - // Simulate: no merge failure means we never enter the merge failure handling block - let preserveWorktreesForResume = false; - let phase = "completed"; + // Normalize optional fields with defaults + return { + status: parsed.status, + source_branch: parsed.source_branch, + target_branch: parsed.target_branch || "", + merge_commit: parsed.merge_commit || "", + conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [], + verification: { + ran: !!parsed.verification.ran, + passed: !!parsed.verification.passed, + output: + typeof parsed.verification.output === "string" + ? parsed.verification.output.slice(0, 2000) + : "", + }, + }; + } - // The cleanup block checks preserveWorktreesForResume - const shouldCleanup = !preserveWorktreesForResume; + // Create temp dir for merge result tests + const mergeTestDir = join(tmpdir(), `orch-merge-test-${Date.now()}`); + mkdirSync(mergeTestDir, { recursive: true }); - assertEqual(shouldCleanup, true, "cleanup proceeds on clean completion"); - assertEqual(preserveWorktreesForResume, false, "worktrees not preserved"); - assertEqual(phase, "completed", "phase is completed"); -} + try { + { + console.log(" ▸ valid merge result JSON parses correctly"); + const validResult = { + status: "SUCCESS", + source_branch: "task/lane-1-20260309", + target_branch: "develop", + merge_commit: "abc123def456", + conflicts: [], + verification: { ran: true, passed: true, output: "All tests passed" }, + }; + const filePath = join(mergeTestDir, "valid-result.json"); + writeFileSync(filePath, JSON.stringify(validResult), "utf-8"); + + const result = parseMergeResultCore(filePath); + assertEqual(result.status, "SUCCESS", "status parsed correctly"); + assertEqual(result.source_branch, "task/lane-1-20260309", "source_branch parsed"); + assertEqual(result.target_branch, "develop", "target_branch parsed"); + assertEqual(result.merge_commit, "abc123def456", "merge_commit parsed"); + assertEqual(result.verification.ran, true, "verification.ran parsed"); + assertEqual(result.verification.passed, true, "verification.passed parsed"); + assertEqual(result.verification.output, "All tests passed", "verification.output parsed"); + } -// Verify the source code actually has the cleanup suppression logic -{ - console.log(" ▸ verify source code has preserveWorktreesForResume guard in cleanup block"); - assert(source.includes("if (preserveWorktreesForResume)"), "source checks preserveWorktreesForResume in cleanup"); - assert(source.includes("skipping final cleanup to preserve worktrees"), "source logs cleanup skip reason"); - assert(source.includes("merge-failure-pause"), "source persists state before pause cleanup"); - assert(source.includes("merge-failure-abort"), "source persists state before abort cleanup"); -} + { + console.log(" ▸ malformed JSON throws MERGE_RESULT_INVALID"); + const filePath = join(mergeTestDir, "malformed.json"); + writeFileSync(filePath, "{ this is not json }", "utf-8"); + + assertThrows( + () => parseMergeResultCore(filePath), + "MERGE_RESULT_INVALID", + "malformed JSON throws MERGE_RESULT_INVALID", + ); + } -// ═══════════════════════════════════════════════════════════════════════ -// 6.3: parseMergeResult Edge Cases -// ═══════════════════════════════════════════════════════════════════════ + { + console.log(" ▸ missing 'status' field throws MERGE_RESULT_MISSING_FIELDS"); + const noStatus = { + source_branch: "task/lane-1", + verification: { ran: true, passed: true, output: "" }, + }; + const filePath = join(mergeTestDir, "no-status.json"); + writeFileSync(filePath, JSON.stringify(noStatus), "utf-8"); -console.log("\n── 6.3: parseMergeResult edge cases ──"); + assertThrows( + () => parseMergeResultCore(filePath), + "MERGE_RESULT_MISSING_FIELDS", + "missing status throws MERGE_RESULT_MISSING_FIELDS", + ); + } -// MergeError reimplementation -class TestMergeError extends Error { - code: string; - constructor(code: string, message: string) { - super(message); - this.name = "MergeError"; - this.code = code; - } -} + { + console.log(" ▸ missing 'source_branch' field throws MERGE_RESULT_MISSING_FIELDS"); + const noSourceBranch = { + status: "SUCCESS", + verification: { ran: true, passed: true, output: "" }, + }; + const filePath = join(mergeTestDir, "no-source-branch.json"); + writeFileSync(filePath, JSON.stringify(noSourceBranch), "utf-8"); -// Valid merge statuses (must match source) -const TEST_VALID_MERGE_STATUSES: ReadonlySet = new Set([ - "SUCCESS", "CONFLICT_RESOLVED", "CONFLICT_UNRESOLVED", "BUILD_FAILURE", -]); + assertThrows( + () => parseMergeResultCore(filePath), + "MERGE_RESULT_MISSING_FIELDS", + "missing source_branch throws MERGE_RESULT_MISSING_FIELDS", + ); + } -/** - * Reimplementation of parseMergeResult core logic — WITHOUT retry/sleepSync. - * Tests the validation logic rather than the retry mechanism. - * This mirrors the inner parsing logic of parseMergeResult exactly. - */ -function parseMergeResultCore(filePath: string): any { - if (!existsSync(filePath)) { - throw new TestMergeError( - "MERGE_RESULT_INVALID", - `Merge result file not found: ${filePath}`, - ); - } + { + console.log(" ▸ missing 'verification' field throws MERGE_RESULT_MISSING_FIELDS"); + const noVerification = { + status: "SUCCESS", + source_branch: "task/lane-1", + }; + const filePath = join(mergeTestDir, "no-verification.json"); + writeFileSync(filePath, JSON.stringify(noVerification), "utf-8"); - const raw = readFileSync(filePath, "utf-8").trim(); - if (!raw) { - throw new TestMergeError( - "MERGE_RESULT_INVALID", - `Merge result file is empty: ${filePath}`, - ); - } + assertThrows( + () => parseMergeResultCore(filePath), + "MERGE_RESULT_MISSING_FIELDS", + "missing verification throws MERGE_RESULT_MISSING_FIELDS", + ); + } - let parsed: any; - try { - parsed = JSON.parse(raw); - } catch (err: unknown) { - throw new TestMergeError( - "MERGE_RESULT_INVALID", - `Failed to parse merge result JSON: ${(err as Error).message}. File: ${filePath}`, - ); - } + { + console.log(" ▸ unknown status maps to BUILD_FAILURE (fail-safe)"); + const unknownStatus = { + status: "CUSTOM_STATUS_UNKNOWN", + source_branch: "task/lane-1", + verification: { ran: false, passed: false, output: "" }, + }; + const filePath = join(mergeTestDir, "unknown-status.json"); + writeFileSync(filePath, JSON.stringify(unknownStatus), "utf-8"); - // Validate required fields - if (typeof parsed.status !== "string") { - throw new TestMergeError( - "MERGE_RESULT_MISSING_FIELDS", - `Merge result missing required field "status": ${filePath}`, - ); - } - if (typeof parsed.source_branch !== "string") { - throw new TestMergeError( - "MERGE_RESULT_MISSING_FIELDS", - `Merge result missing required field "source_branch": ${filePath}`, - ); - } - if (!parsed.verification || typeof parsed.verification !== "object") { - throw new TestMergeError( - "MERGE_RESULT_MISSING_FIELDS", - `Merge result missing required field "verification": ${filePath}`, - ); - } + const result = parseMergeResultCore(filePath); + assertEqual(result.status, "BUILD_FAILURE", "unknown status mapped to BUILD_FAILURE"); + assertEqual(result.source_branch, "task/lane-1", "source_branch preserved"); + } - // Validate status value — unknown → BUILD_FAILURE - if (!TEST_VALID_MERGE_STATUSES.has(parsed.status)) { - parsed.status = "BUILD_FAILURE"; - } - - // Normalize optional fields with defaults - return { - status: parsed.status, - source_branch: parsed.source_branch, - target_branch: parsed.target_branch || "", - merge_commit: parsed.merge_commit || "", - conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [], - verification: { - ran: !!parsed.verification.ran, - passed: !!parsed.verification.passed, - output: typeof parsed.verification.output === "string" - ? parsed.verification.output.slice(0, 2000) - : "", - }, - }; -} + { + console.log(" ▸ empty file throws MERGE_RESULT_INVALID"); + const filePath = join(mergeTestDir, "empty.json"); + writeFileSync(filePath, "", "utf-8"); + + assertThrows( + () => parseMergeResultCore(filePath), + "MERGE_RESULT_INVALID", + "empty file throws MERGE_RESULT_INVALID", + ); + } -// Create temp dir for merge result tests -const mergeTestDir = join(tmpdir(), `orch-merge-test-${Date.now()}`); -mkdirSync(mergeTestDir, { recursive: true }); + { + console.log(" ▸ non-existent file throws MERGE_RESULT_INVALID"); + const filePath = join(mergeTestDir, "does-not-exist.json"); -try { - { - console.log(" ▸ valid merge result JSON parses correctly"); - const validResult = { - status: "SUCCESS", - source_branch: "task/lane-1-20260309", - target_branch: "develop", - merge_commit: "abc123def456", - conflicts: [], - verification: { ran: true, passed: true, output: "All tests passed" }, - }; - const filePath = join(mergeTestDir, "valid-result.json"); - writeFileSync(filePath, JSON.stringify(validResult), "utf-8"); + assertThrows( + () => parseMergeResultCore(filePath), + "MERGE_RESULT_INVALID", + "non-existent file throws MERGE_RESULT_INVALID", + ); + } + + { + console.log(" ▸ all 4 valid merge statuses accepted"); + const statuses = ["SUCCESS", "CONFLICT_RESOLVED", "CONFLICT_UNRESOLVED", "BUILD_FAILURE"]; + let allValid = true; + for (const status of statuses) { + const data = { + status, + source_branch: `task/test-${status}`, + verification: { ran: true, passed: status === "SUCCESS", output: "" }, + }; + const filePath = join(mergeTestDir, `status-${status}.json`); + writeFileSync(filePath, JSON.stringify(data), "utf-8"); + try { + const result = parseMergeResultCore(filePath); + if (result.status !== status) allValid = false; + } catch { + allValid = false; + } + } + assert(allValid, "all 4 valid merge statuses parsed without mapping"); + } - const result = parseMergeResultCore(filePath); - assertEqual(result.status, "SUCCESS", "status parsed correctly"); - assertEqual(result.source_branch, "task/lane-1-20260309", "source_branch parsed"); - assertEqual(result.target_branch, "develop", "target_branch parsed"); - assertEqual(result.merge_commit, "abc123def456", "merge_commit parsed"); - assertEqual(result.verification.ran, true, "verification.ran parsed"); - assertEqual(result.verification.passed, true, "verification.passed parsed"); - assertEqual(result.verification.output, "All tests passed", "verification.output parsed"); + { + console.log(" ▸ optional fields default correctly when missing"); + const minimalValid = { + status: "SUCCESS", + source_branch: "task/minimal", + verification: { ran: false, passed: false }, + // No target_branch, merge_commit, conflicts, verification.output + }; + const filePath = join(mergeTestDir, "minimal-valid.json"); + writeFileSync(filePath, JSON.stringify(minimalValid), "utf-8"); + + const result = parseMergeResultCore(filePath); + assertEqual(result.target_branch, "", "missing target_branch defaults to empty string"); + assertEqual(result.merge_commit, "", "missing merge_commit defaults to empty string"); + assertEqual(result.conflicts.length, 0, "missing conflicts defaults to empty array"); + assertEqual( + result.verification.output, + "", + "missing verification.output defaults to empty string", + ); + } + } finally { + try { + rmSync(mergeTestDir, { recursive: true, force: true }); + } catch { + /* best effort */ + } } + // Verify the source code has the retry logic and unknown status handling { - console.log(" ▸ malformed JSON throws MERGE_RESULT_INVALID"); - const filePath = join(mergeTestDir, "malformed.json"); - writeFileSync(filePath, "{ this is not json }", "utf-8"); - - assertThrows( - () => parseMergeResultCore(filePath), - "MERGE_RESULT_INVALID", - "malformed JSON throws MERGE_RESULT_INVALID", + console.log(" ▸ verify source has retry logic and unknown status fallback"); + assert(source.includes("MERGE_RESULT_READ_RETRIES"), "source defines retry constant"); + assert( + source.includes(`parsed.status = "BUILD_FAILURE"`), + "source maps unknown status to BUILD_FAILURE", ); + assert( + source.includes("MERGE_RESULT_MISSING_FIELDS"), + "source uses MERGE_RESULT_MISSING_FIELDS error code", + ); + assert(source.includes("MERGE_RESULT_INVALID"), "source uses MERGE_RESULT_INVALID error code"); } - { - console.log(" ▸ missing 'status' field throws MERGE_RESULT_MISSING_FIELDS"); - const noStatus = { - source_branch: "task/lane-1", - verification: { ran: true, passed: true, output: "" }, - }; - const filePath = join(mergeTestDir, "no-status.json"); - writeFileSync(filePath, JSON.stringify(noStatus), "utf-8"); + // ═══════════════════════════════════════════════════════════════════════ + // 6.4: End-to-End Simulated Interruption Scenario + // ═══════════════════════════════════════════════════════════════════════ - assertThrows( - () => parseMergeResultCore(filePath), - "MERGE_RESULT_MISSING_FIELDS", - "missing status throws MERGE_RESULT_MISSING_FIELDS", - ); - } + console.log("\n── 6.4: End-to-end simulated interruption scenario ──"); { - console.log(" ▸ missing 'source_branch' field throws MERGE_RESULT_MISSING_FIELDS"); - const noSourceBranch = { - status: "SUCCESS", - verification: { ran: true, passed: true, output: "" }, - }; - const filePath = join(mergeTestDir, "no-source-branch.json"); - writeFileSync(filePath, JSON.stringify(noSourceBranch), "utf-8"); + console.log(" ▸ full persist → load → reconcile → resume-point pipeline"); - assertThrows( - () => parseMergeResultCore(filePath), - "MERGE_RESULT_MISSING_FIELDS", - "missing source_branch throws MERGE_RESULT_MISSING_FIELDS", - ); - } + // Step 1: Simulate a batch that was executing when disconnected + const e2eRoot = join(tmpdir(), `orch-e2e-test-${Date.now()}`); + mkdirSync(join(e2eRoot, ".pi"), { recursive: true }); - { - console.log(" ▸ missing 'verification' field throws MERGE_RESULT_MISSING_FIELDS"); - const noVerification = { - status: "SUCCESS", - source_branch: "task/lane-1", - }; - const filePath = join(mergeTestDir, "no-verification.json"); - writeFileSync(filePath, JSON.stringify(noVerification), "utf-8"); + try { + // Create a runtime state (simulating mid-batch execution) + const runtimeState = freshMinimalBatchState(); + runtimeState.phase = "executing"; + runtimeState.batchId = "20260309E2E"; + runtimeState.startedAt = Date.now() - 120000; + runtimeState.totalWaves = 3; + runtimeState.totalTasks = 5; + runtimeState.currentWaveIndex = 1; + runtimeState.succeededTasks = 2; + + const wavePlan = [["E2E-001", "E2E-002"], ["E2E-003", "E2E-004"], ["E2E-005"]]; + const lanes = [ + minimalLane(1, ["E2E-001", "E2E-003", "E2E-005"]), + minimalLane(2, ["E2E-002", "E2E-004"]), + ]; + const outcomes = [ + { ...minimalOutcome("E2E-001", "succeeded"), sessionName: "orch-lane-1" }, + { ...minimalOutcome("E2E-002", "succeeded"), sessionName: "orch-lane-2" }, + { ...minimalOutcome("E2E-003", "running"), sessionName: "orch-lane-1" }, + { ...minimalOutcome("E2E-004", "running"), sessionName: "orch-lane-2" }, + ]; + + // PERSIST: Write state to disk (simulating what executeOrchBatch does) + persistRuntimeState( + "wave-execution-mid", + runtimeState, + wavePlan, + lanes, + outcomes, + null, + e2eRoot, + ); - assertThrows( - () => parseMergeResultCore(filePath), - "MERGE_RESULT_MISSING_FIELDS", - "missing verification throws MERGE_RESULT_MISSING_FIELDS", - ); - } + // Verify file exists + assert(existsSync(batchStatePath(e2eRoot)), "state file persisted to disk"); + + // LOAD: Read it back (simulating what resumeOrchBatch does) + const loadedState = loadBatchState(e2eRoot); + assert(loadedState !== null, "state loaded successfully"); + assertEqual(loadedState!.phase, "executing", "loaded phase is executing"); + assertEqual(loadedState!.batchId, "20260309E2E", "loaded batchId matches"); + assertEqual(loadedState!.currentWaveIndex, 1, "loaded waveIndex is 1"); + assertEqual(loadedState!.totalWaves, 3, "loaded totalWaves is 3"); + // serializeBatchState builds full registry from wavePlan + outcomes. + // Wave plan has 5 tasks, outcomes has 4 → full set is 5. + assertEqual(loadedState!.tasks.length, 5, "5 task records persisted (all tasks in wave plan)"); + assertEqual(loadedState!.wavePlan.length, 3, "3 waves in plan"); + + // RECONCILE: Simulate that after disconnect, E2E-003's session is dead + .DONE exists, + // E2E-004's session is still alive, E2E-001/002 completed earlier + const aliveSessions = new Set(["orch-lane-2"]); // E2E-004's session + const doneTaskIds = new Set(["E2E-001", "E2E-002", "E2E-003"]); // E2E-003 completed while disconnected + + const reconciled = reconcileTaskStates(loadedState!, aliveSessions, doneTaskIds); + // 5 tasks reconciled: E2E-001..004 from outcomes + E2E-005 from wave plan (pending, no session) + assertEqual(reconciled.length, 5, "5 tasks reconciled"); + + // E2E-001: succeeded in persisted + DONE → mark-complete + const e001 = reconciled.find((r: any) => r.taskId === "E2E-001"); + assertEqual(e001!.action, "mark-complete", "E2E-001: done file → mark-complete"); + + // E2E-002: succeeded in persisted + DONE → mark-complete + const e002 = reconciled.find((r: any) => r.taskId === "E2E-002"); + assertEqual(e002!.action, "mark-complete", "E2E-002: done file → mark-complete"); + + // E2E-003: running in persisted + DONE → mark-complete (DONE takes precedence) + const e003 = reconciled.find((r: any) => r.taskId === "E2E-003"); + assertEqual(e003!.action, "mark-complete", "E2E-003: DONE takes precedence over running"); + + // E2E-004: running in persisted + alive session + no DONE → reconnect + const e004 = reconciled.find((r: any) => r.taskId === "E2E-004"); + assertEqual(e004!.action, "reconnect", "E2E-004: alive session → reconnect"); + + // RESUME POINT: Determine where to resume + const resumePoint = computeResumePoint(loadedState!, reconciled); + + // Wave 0 (E2E-001, E2E-002): both completed → skip + // Wave 1 (E2E-003, E2E-004): E2E-003 completed, E2E-004 still running → resume from wave 1 + assertEqual(resumePoint.resumeWaveIndex, 1, "resume from wave 1 (E2E-004 still running)"); + assertEqual(resumePoint.completedTaskIds.length, 3, "3 tasks completed (E2E-001, 002, 003)"); + assert(resumePoint.completedTaskIds.includes("E2E-001"), "E2E-001 in completed"); + assert(resumePoint.completedTaskIds.includes("E2E-002"), "E2E-002 in completed"); + assert(resumePoint.completedTaskIds.includes("E2E-003"), "E2E-003 in completed"); + assertEqual(resumePoint.reconnectTaskIds.length, 1, "1 task needs reconnection"); + assert(resumePoint.reconnectTaskIds.includes("E2E-004"), "E2E-004 needs reconnection"); + // E2E-005 was pending (wave 2, not started) with dead session → mark-failed by reconciler. + // However, it's in wave 2 (future wave), so computeResumePoint categorizes it correctly. + assertEqual( + resumePoint.failedTaskIds.length, + 1, + "1 task marked failed (E2E-005: pending + dead session)", + ); - { - console.log(" ▸ unknown status maps to BUILD_FAILURE (fail-safe)"); - const unknownStatus = { - status: "CUSTOM_STATUS_UNKNOWN", - source_branch: "task/lane-1", - verification: { ran: false, passed: false, output: "" }, - }; - const filePath = join(mergeTestDir, "unknown-status.json"); - writeFileSync(filePath, JSON.stringify(unknownStatus), "utf-8"); + // ORPHAN DETECTION: Check what analyzeOrchestratorStartupState would recommend + const orphanResult = analyzeOrchestratorStartupState( + ["orch-lane-2"], // One alive session + "valid", + loadedState!, + null, + doneTaskIds, + ); + assertEqual(orphanResult.recommendedAction, "resume", "orphan detection recommends resume"); + assert(orphanResult.userMessage.includes("/orch-resume"), "message suggests /orch-resume"); - const result = parseMergeResultCore(filePath); - assertEqual(result.status, "BUILD_FAILURE", "unknown status mapped to BUILD_FAILURE"); - assertEqual(result.source_branch, "task/lane-1", "source_branch preserved"); + // RESUME ELIGIBILITY: Check if state is resumable + const eligibility = checkResumeEligibility(loadedState!); + assertEqual(eligibility.eligible, true, "executing state is resumable"); + } finally { + try { + rmSync(e2eRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } - { - console.log(" ▸ empty file throws MERGE_RESULT_INVALID"); - const filePath = join(mergeTestDir, "empty.json"); - writeFileSync(filePath, "", "utf-8"); + // ═══════════════════════════════════════════════════════════════════════ + // Summary + // ═══════════════════════════════════════════════════════════════════════ + // 7.1: Schema v1 Compatibility — Load Path Regression Tests (Step 2) + // ═══════════════════════════════════════════════════════════════════════ - assertThrows( - () => parseMergeResultCore(filePath), - "MERGE_RESULT_INVALID", - "empty file throws MERGE_RESULT_INVALID", - ); - } + console.log("\n── 7.1: Schema v1 compatibility — load path regression tests ──"); { - console.log(" ▸ non-existent file throws MERGE_RESULT_INVALID"); - const filePath = join(mergeTestDir, "does-not-exist.json"); + console.log(" ▸ loadBatchState with v1 fixture yields v2 in memory (full load path)"); - assertThrows( - () => parseMergeResultCore(filePath), - "MERGE_RESULT_INVALID", - "non-existent file throws MERGE_RESULT_INVALID", - ); - } + // Write the v1 fixture to a temp root's .pi/batch-state.json, then load it + const v1LoadRoot = join(tmpdir(), `orch-v1-load-test-${Date.now()}`); + mkdirSync(join(v1LoadRoot, ".pi"), { recursive: true }); - { - console.log(" ▸ all 4 valid merge statuses accepted"); - const statuses = ["SUCCESS", "CONFLICT_RESOLVED", "CONFLICT_UNRESOLVED", "BUILD_FAILURE"]; - let allValid = true; - for (const status of statuses) { - const data = { - status, - source_branch: `task/test-${status}`, - verification: { ran: true, passed: status === "SUCCESS", output: "" }, - }; - const filePath = join(mergeTestDir, `status-${status}.json`); - writeFileSync(filePath, JSON.stringify(data), "utf-8"); + try { + const v1Json = loadFixture("batch-state-v1-valid.json"); + writeFileSync(batchStatePath(v1LoadRoot), v1Json, "utf-8"); + + const loaded = loadBatchState(v1LoadRoot); + assert(loaded !== null, "v1 load path: returns non-null"); + assertEqual( + loaded!.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "v1 load path: schemaVersion upconverted to 2", + ); + assertEqual(loaded!.mode, "repo", "v1 load path: mode defaults to 'repo'"); + assertEqual(loaded!.baseBranch, "", "v1 load path: baseBranch defaults to ''"); + + // Verify core fields preserved through full load path + assertEqual(loaded!.phase, "executing", "v1 load path: phase preserved"); + assertEqual(loaded!.batchId, "20260309T010000", "v1 load path: batchId preserved"); + assertEqual(loaded!.totalTasks, 3, "v1 load path: totalTasks preserved"); + assertEqual(loaded!.currentWaveIndex, 0, "v1 load path: currentWaveIndex preserved"); + assertEqual(loaded!.totalWaves, 2, "v1 load path: totalWaves preserved"); + + // Verify task records survived upconversion + assertEqual(loaded!.tasks.length, 3, "v1 load path: 3 task records preserved"); + assertEqual(loaded!.tasks[0].taskId, "TS-001", "v1 load path: task TS-001 preserved"); + assertEqual(loaded!.tasks[0].status, "succeeded", "v1 load path: task status preserved"); + assertEqual(loaded!.tasks[1].taskId, "TS-002", "v1 load path: task TS-002 preserved"); + assertEqual(loaded!.tasks[1].status, "running", "v1 load path: task TS-002 status preserved"); + assertEqual(loaded!.tasks[2].taskId, "TS-003", "v1 load path: task TS-003 preserved"); + assertEqual(loaded!.tasks[2].status, "pending", "v1 load path: task TS-003 status preserved"); + + // Verify task repo fields are undefined (v1 has no repo fields) + assertEqual(loaded!.tasks[0].repoId, undefined, "v1 load path: task[0].repoId is undefined"); + assertEqual( + loaded!.tasks[0].resolvedRepoId, + undefined, + "v1 load path: task[0].resolvedRepoId is undefined", + ); + assertEqual(loaded!.tasks[1].repoId, undefined, "v1 load path: task[1].repoId is undefined"); + assertEqual(loaded!.tasks[2].repoId, undefined, "v1 load path: task[2].repoId is undefined"); + + // Verify lane records survived upconversion + assertEqual(loaded!.lanes.length, 2, "v1 load path: 2 lane records preserved"); + assertEqual(loaded!.lanes[0].laneId, "lane-1", "v1 load path: lane-1 preserved"); + assertEqual(loaded!.lanes[1].laneId, "lane-2", "v1 load path: lane-2 preserved"); + + // Verify lane repo fields are undefined (v1 has no lane repoId) + assertEqual(loaded!.lanes[0].repoId, undefined, "v1 load path: lane[0].repoId is undefined"); + assertEqual(loaded!.lanes[1].repoId, undefined, "v1 load path: lane[1].repoId is undefined"); + + // Verify wavePlan preserved + assertEqual(loaded!.wavePlan.length, 2, "v1 load path: 2 waves preserved"); + assertEqual(loaded!.wavePlan[0].length, 2, "v1 load path: wave 0 has 2 tasks"); + assertEqual(loaded!.wavePlan[1].length, 1, "v1 load path: wave 1 has 1 task"); + } finally { try { - const result = parseMergeResultCore(filePath); - if (result.status !== status) allValid = false; + rmSync(v1LoadRoot, { recursive: true, force: true }); } catch { - allValid = false; + /* best effort */ } } - assert(allValid, "all 4 valid merge statuses parsed without mapping"); } { - console.log(" ▸ optional fields default correctly when missing"); - const minimalValid = { - status: "SUCCESS", - source_branch: "task/minimal", - verification: { ran: false, passed: false }, - // No target_branch, merge_commit, conflicts, verification.output - }; - const filePath = join(mergeTestDir, "minimal-valid.json"); - writeFileSync(filePath, JSON.stringify(minimalValid), "utf-8"); - - const result = parseMergeResultCore(filePath); - assertEqual(result.target_branch, "", "missing target_branch defaults to empty string"); - assertEqual(result.merge_commit, "", "missing merge_commit defaults to empty string"); - assertEqual(result.conflicts.length, 0, "missing conflicts defaults to empty array"); - assertEqual(result.verification.output, "", "missing verification.output defaults to empty string"); - } - -} finally { - try { rmSync(mergeTestDir, { recursive: true, force: true }); } catch { /* best effort */ } -} - -// Verify the source code has the retry logic and unknown status handling -{ - console.log(" ▸ verify source has retry logic and unknown status fallback"); - assert(source.includes("MERGE_RESULT_READ_RETRIES"), "source defines retry constant"); - assert(source.includes(`parsed.status = "BUILD_FAILURE"`), "source maps unknown status to BUILD_FAILURE"); - assert(source.includes("MERGE_RESULT_MISSING_FIELDS"), "source uses MERGE_RESULT_MISSING_FIELDS error code"); - assert(source.includes("MERGE_RESULT_INVALID"), "source uses MERGE_RESULT_INVALID error code"); -} - -// ═══════════════════════════════════════════════════════════════════════ -// 6.4: End-to-End Simulated Interruption Scenario -// ═══════════════════════════════════════════════════════════════════════ + console.log(" ▸ v1 file is NOT rewritten on load (on-disk schema remains 1)"); -console.log("\n── 6.4: End-to-end simulated interruption scenario ──"); + const v1NoRewriteRoot = join(tmpdir(), `orch-v1-norewrite-test-${Date.now()}`); + mkdirSync(join(v1NoRewriteRoot, ".pi"), { recursive: true }); -{ - console.log(" ▸ full persist → load → reconcile → resume-point pipeline"); - - // Step 1: Simulate a batch that was executing when disconnected - const e2eRoot = join(tmpdir(), `orch-e2e-test-${Date.now()}`); - mkdirSync(join(e2eRoot, ".pi"), { recursive: true }); + try { + const v1Json = loadFixture("batch-state-v1-valid.json"); + const statePath = batchStatePath(v1NoRewriteRoot); + writeFileSync(statePath, v1Json, "utf-8"); + + // Capture the on-disk content before load + const beforeLoad = readFileSync(statePath, "utf-8"); + const beforeParsed = JSON.parse(beforeLoad); + assertEqual(beforeParsed.schemaVersion, 1, "on-disk: v1 schemaVersion before load"); + + // Load (triggers in-memory upconversion) + const loaded = loadBatchState(v1NoRewriteRoot); + assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "in-memory: upconverted to v2"); + + // Read file again — it must NOT have been rewritten + const afterLoad = readFileSync(statePath, "utf-8"); + const afterParsed = JSON.parse(afterLoad); + assertEqual(afterParsed.schemaVersion, 1, "on-disk: v1 schemaVersion unchanged after load"); + assertEqual(afterParsed.mode, undefined, "on-disk: mode still absent (v1 has no mode)"); + assertEqual( + afterParsed.baseBranch, + undefined, + "on-disk: baseBranch still absent (v1 has no baseBranch)", + ); - try { - // Create a runtime state (simulating mid-batch execution) - const runtimeState = freshMinimalBatchState(); - runtimeState.phase = "executing"; - runtimeState.batchId = "20260309E2E"; - runtimeState.startedAt = Date.now() - 120000; - runtimeState.totalWaves = 3; - runtimeState.totalTasks = 5; - runtimeState.currentWaveIndex = 1; - runtimeState.succeededTasks = 2; - - const wavePlan = [["E2E-001", "E2E-002"], ["E2E-003", "E2E-004"], ["E2E-005"]]; - const lanes = [ - minimalLane(1, ["E2E-001", "E2E-003", "E2E-005"]), - minimalLane(2, ["E2E-002", "E2E-004"]), - ]; - const outcomes = [ - { ...minimalOutcome("E2E-001", "succeeded"), sessionName: "orch-lane-1" }, - { ...minimalOutcome("E2E-002", "succeeded"), sessionName: "orch-lane-2" }, - { ...minimalOutcome("E2E-003", "running"), sessionName: "orch-lane-1" }, - { ...minimalOutcome("E2E-004", "running"), sessionName: "orch-lane-2" }, - ]; + // Verify byte-level content unchanged + assertEqual(afterLoad, beforeLoad, "on-disk: file content identical before and after load"); + } finally { + try { + rmSync(v1NoRewriteRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + } - // PERSIST: Write state to disk (simulating what executeOrchBatch does) - persistRuntimeState("wave-execution-mid", runtimeState, wavePlan, lanes, outcomes, null, e2eRoot); - - // Verify file exists - assert(existsSync(batchStatePath(e2eRoot)), "state file persisted to disk"); - - // LOAD: Read it back (simulating what resumeOrchBatch does) - const loadedState = loadBatchState(e2eRoot); - assert(loadedState !== null, "state loaded successfully"); - assertEqual(loadedState!.phase, "executing", "loaded phase is executing"); - assertEqual(loadedState!.batchId, "20260309E2E", "loaded batchId matches"); - assertEqual(loadedState!.currentWaveIndex, 1, "loaded waveIndex is 1"); - assertEqual(loadedState!.totalWaves, 3, "loaded totalWaves is 3"); - // serializeBatchState builds full registry from wavePlan + outcomes. - // Wave plan has 5 tasks, outcomes has 4 → full set is 5. - assertEqual(loadedState!.tasks.length, 5, "5 task records persisted (all tasks in wave plan)"); - assertEqual(loadedState!.wavePlan.length, 3, "3 waves in plan"); - - // RECONCILE: Simulate that after disconnect, E2E-003's session is dead + .DONE exists, - // E2E-004's session is still alive, E2E-001/002 completed earlier - const aliveSessions = new Set(["orch-lane-2"]); // E2E-004's session - const doneTaskIds = new Set(["E2E-001", "E2E-002", "E2E-003"]); // E2E-003 completed while disconnected - - const reconciled = reconcileTaskStates(loadedState!, aliveSessions, doneTaskIds); - // 5 tasks reconciled: E2E-001..004 from outcomes + E2E-005 from wave plan (pending, no session) - assertEqual(reconciled.length, 5, "5 tasks reconciled"); - - // E2E-001: succeeded in persisted + DONE → mark-complete - const e001 = reconciled.find((r: any) => r.taskId === "E2E-001"); - assertEqual(e001!.action, "mark-complete", "E2E-001: done file → mark-complete"); - - // E2E-002: succeeded in persisted + DONE → mark-complete - const e002 = reconciled.find((r: any) => r.taskId === "E2E-002"); - assertEqual(e002!.action, "mark-complete", "E2E-002: done file → mark-complete"); - - // E2E-003: running in persisted + DONE → mark-complete (DONE takes precedence) - const e003 = reconciled.find((r: any) => r.taskId === "E2E-003"); - assertEqual(e003!.action, "mark-complete", "E2E-003: DONE takes precedence over running"); - - // E2E-004: running in persisted + alive session + no DONE → reconnect - const e004 = reconciled.find((r: any) => r.taskId === "E2E-004"); - assertEqual(e004!.action, "reconnect", "E2E-004: alive session → reconnect"); - - // RESUME POINT: Determine where to resume - const resumePoint = computeResumePoint(loadedState!, reconciled); - - // Wave 0 (E2E-001, E2E-002): both completed → skip - // Wave 1 (E2E-003, E2E-004): E2E-003 completed, E2E-004 still running → resume from wave 1 - assertEqual(resumePoint.resumeWaveIndex, 1, "resume from wave 1 (E2E-004 still running)"); - assertEqual(resumePoint.completedTaskIds.length, 3, "3 tasks completed (E2E-001, 002, 003)"); - assert(resumePoint.completedTaskIds.includes("E2E-001"), "E2E-001 in completed"); - assert(resumePoint.completedTaskIds.includes("E2E-002"), "E2E-002 in completed"); - assert(resumePoint.completedTaskIds.includes("E2E-003"), "E2E-003 in completed"); - assertEqual(resumePoint.reconnectTaskIds.length, 1, "1 task needs reconnection"); - assert(resumePoint.reconnectTaskIds.includes("E2E-004"), "E2E-004 needs reconnection"); - // E2E-005 was pending (wave 2, not started) with dead session → mark-failed by reconciler. - // However, it's in wave 2 (future wave), so computeResumePoint categorizes it correctly. - assertEqual(resumePoint.failedTaskIds.length, 1, "1 task marked failed (E2E-005: pending + dead session)"); - - // ORPHAN DETECTION: Check what analyzeOrchestratorStartupState would recommend - const orphanResult = analyzeOrchestratorStartupState( - ["orch-lane-2"], // One alive session - "valid", - loadedState!, - null, - doneTaskIds, - ); - assertEqual(orphanResult.recommendedAction, "resume", "orphan detection recommends resume"); - assert(orphanResult.userMessage.includes("/orch-resume"), "message suggests /orch-resume"); + { + console.log(" ▸ v1 load followed by explicit save writes v2 to disk"); - // RESUME ELIGIBILITY: Check if state is resumable - const eligibility = checkResumeEligibility(loadedState!); - assertEqual(eligibility.eligible, true, "executing state is resumable"); + const v1SaveRoot = join(tmpdir(), `orch-v1-save-test-${Date.now()}`); + mkdirSync(join(v1SaveRoot, ".pi"), { recursive: true }); - } finally { - try { rmSync(e2eRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + const v1Json = loadFixture("batch-state-v1-valid.json"); + const statePath = batchStatePath(v1SaveRoot); + writeFileSync(statePath, v1Json, "utf-8"); + + // Load v1 (in-memory upconversion) + const loaded = loadBatchState(v1SaveRoot); + assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "loaded as v2 in memory"); + + // Now save the upconverted state back (simulating what happens on next persist) + const reserializedJson = JSON.stringify(loaded, null, 2); + saveBatchState(reserializedJson, v1SaveRoot); + + // Read and verify it's now v2 on disk + const afterSave = readFileSync(statePath, "utf-8"); + const afterParsed = JSON.parse(afterSave); + assertEqual( + afterParsed.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "on-disk: v2 after explicit save", + ); + assertEqual(afterParsed.mode, "repo", "on-disk: mode persisted as 'repo'"); + assertEqual(afterParsed.baseBranch, "", "on-disk: baseBranch persisted as ''"); + } finally { + try { + rmSync(v1SaveRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } -} -// ═══════════════════════════════════════════════════════════════════════ -// Summary -// ═══════════════════════════════════════════════════════════════════════ -// 7.1: Schema v1 Compatibility — Load Path Regression Tests (Step 2) -// ═══════════════════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════════════════ + // 7.2: Schema v2 Compatibility — Load Path Regression Tests (Step 2) + // ═══════════════════════════════════════════════════════════════════════ -console.log("\n── 7.1: Schema v1 compatibility — load path regression tests ──"); + console.log("\n── 7.2: Schema v2 compatibility — load path regression tests ──"); -{ - console.log(" ▸ loadBatchState with v1 fixture yields v2 in memory (full load path)"); - - // Write the v1 fixture to a temp root's .pi/batch-state.json, then load it - const v1LoadRoot = join(tmpdir(), `orch-v1-load-test-${Date.now()}`); - mkdirSync(join(v1LoadRoot, ".pi"), { recursive: true }); + { + console.log(" ▸ loadBatchState with v2 repo-mode fixture (batch-state-valid.json)"); - try { - const v1Json = loadFixture("batch-state-v1-valid.json"); - writeFileSync(batchStatePath(v1LoadRoot), v1Json, "utf-8"); - - const loaded = loadBatchState(v1LoadRoot); - assert(loaded !== null, "v1 load path: returns non-null"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v1 load path: schemaVersion upconverted to 2"); - assertEqual(loaded!.mode, "repo", "v1 load path: mode defaults to 'repo'"); - assertEqual(loaded!.baseBranch, "", "v1 load path: baseBranch defaults to ''"); - - // Verify core fields preserved through full load path - assertEqual(loaded!.phase, "executing", "v1 load path: phase preserved"); - assertEqual(loaded!.batchId, "20260309T010000", "v1 load path: batchId preserved"); - assertEqual(loaded!.totalTasks, 3, "v1 load path: totalTasks preserved"); - assertEqual(loaded!.currentWaveIndex, 0, "v1 load path: currentWaveIndex preserved"); - assertEqual(loaded!.totalWaves, 2, "v1 load path: totalWaves preserved"); - - // Verify task records survived upconversion - assertEqual(loaded!.tasks.length, 3, "v1 load path: 3 task records preserved"); - assertEqual(loaded!.tasks[0].taskId, "TS-001", "v1 load path: task TS-001 preserved"); - assertEqual(loaded!.tasks[0].status, "succeeded", "v1 load path: task status preserved"); - assertEqual(loaded!.tasks[1].taskId, "TS-002", "v1 load path: task TS-002 preserved"); - assertEqual(loaded!.tasks[1].status, "running", "v1 load path: task TS-002 status preserved"); - assertEqual(loaded!.tasks[2].taskId, "TS-003", "v1 load path: task TS-003 preserved"); - assertEqual(loaded!.tasks[2].status, "pending", "v1 load path: task TS-003 status preserved"); - - // Verify task repo fields are undefined (v1 has no repo fields) - assertEqual(loaded!.tasks[0].repoId, undefined, "v1 load path: task[0].repoId is undefined"); - assertEqual(loaded!.tasks[0].resolvedRepoId, undefined, "v1 load path: task[0].resolvedRepoId is undefined"); - assertEqual(loaded!.tasks[1].repoId, undefined, "v1 load path: task[1].repoId is undefined"); - assertEqual(loaded!.tasks[2].repoId, undefined, "v1 load path: task[2].repoId is undefined"); - - // Verify lane records survived upconversion - assertEqual(loaded!.lanes.length, 2, "v1 load path: 2 lane records preserved"); - assertEqual(loaded!.lanes[0].laneId, "lane-1", "v1 load path: lane-1 preserved"); - assertEqual(loaded!.lanes[1].laneId, "lane-2", "v1 load path: lane-2 preserved"); - - // Verify lane repo fields are undefined (v1 has no lane repoId) - assertEqual(loaded!.lanes[0].repoId, undefined, "v1 load path: lane[0].repoId is undefined"); - assertEqual(loaded!.lanes[1].repoId, undefined, "v1 load path: lane[1].repoId is undefined"); - - // Verify wavePlan preserved - assertEqual(loaded!.wavePlan.length, 2, "v1 load path: 2 waves preserved"); - assertEqual(loaded!.wavePlan[0].length, 2, "v1 load path: wave 0 has 2 tasks"); - assertEqual(loaded!.wavePlan[1].length, 1, "v1 load path: wave 1 has 1 task"); + const v2RepoRoot = join(tmpdir(), `orch-v2-repo-load-test-${Date.now()}`); + mkdirSync(join(v2RepoRoot, ".pi"), { recursive: true }); - } finally { - try { rmSync(v1LoadRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + const v2Json = loadFixture("batch-state-valid.json"); + writeFileSync(batchStatePath(v2RepoRoot), v2Json, "utf-8"); + + const loaded = loadBatchState(v2RepoRoot); + assert(loaded !== null, "v2 repo-mode load: returns non-null"); + assertEqual( + loaded!.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "v2 repo-mode load: schemaVersion is 2", + ); + assertEqual(loaded!.mode, "repo", "v2 repo-mode load: mode is 'repo'"); + assertEqual(loaded!.baseBranch, "main", "v2 repo-mode load: baseBranch is 'main'"); + assertEqual(loaded!.phase, "executing", "v2 repo-mode load: phase preserved"); + assertEqual(loaded!.batchId, "20260309T010000", "v2 repo-mode load: batchId preserved"); + assertEqual(loaded!.tasks.length, 3, "v2 repo-mode load: 3 task records"); + assertEqual(loaded!.lanes.length, 2, "v2 repo-mode load: 2 lane records"); + + // Verify no spurious repo fields in repo-mode fixture + assertEqual(loaded!.tasks[0].repoId, undefined, "v2 repo-mode load: task repoId is undefined"); + assertEqual( + loaded!.tasks[0].resolvedRepoId, + undefined, + "v2 repo-mode load: task resolvedRepoId is undefined", + ); + assertEqual(loaded!.lanes[0].repoId, undefined, "v2 repo-mode load: lane repoId is undefined"); + } finally { + try { + rmSync(v2RepoRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } -} - -{ - console.log(" ▸ v1 file is NOT rewritten on load (on-disk schema remains 1)"); - - const v1NoRewriteRoot = join(tmpdir(), `orch-v1-norewrite-test-${Date.now()}`); - mkdirSync(join(v1NoRewriteRoot, ".pi"), { recursive: true }); - try { - const v1Json = loadFixture("batch-state-v1-valid.json"); - const statePath = batchStatePath(v1NoRewriteRoot); - writeFileSync(statePath, v1Json, "utf-8"); - - // Capture the on-disk content before load - const beforeLoad = readFileSync(statePath, "utf-8"); - const beforeParsed = JSON.parse(beforeLoad); - assertEqual(beforeParsed.schemaVersion, 1, "on-disk: v1 schemaVersion before load"); - - // Load (triggers in-memory upconversion) - const loaded = loadBatchState(v1NoRewriteRoot); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "in-memory: upconverted to v2"); + { + console.log(" ▸ loadBatchState with v2 workspace-mode fixture (batch-state-v2-workspace.json)"); - // Read file again — it must NOT have been rewritten - const afterLoad = readFileSync(statePath, "utf-8"); - const afterParsed = JSON.parse(afterLoad); - assertEqual(afterParsed.schemaVersion, 1, "on-disk: v1 schemaVersion unchanged after load"); - assertEqual(afterParsed.mode, undefined, "on-disk: mode still absent (v1 has no mode)"); - assertEqual(afterParsed.baseBranch, undefined, "on-disk: baseBranch still absent (v1 has no baseBranch)"); + const v2WsRoot = join(tmpdir(), `orch-v2-ws-load-test-${Date.now()}`); + mkdirSync(join(v2WsRoot, ".pi"), { recursive: true }); - // Verify byte-level content unchanged - assertEqual(afterLoad, beforeLoad, "on-disk: file content identical before and after load"); + try { + const v2WsJson = loadFixture("batch-state-v2-workspace.json"); + writeFileSync(batchStatePath(v2WsRoot), v2WsJson, "utf-8"); + + const loaded = loadBatchState(v2WsRoot); + assert(loaded !== null, "v2 workspace-mode load: returns non-null"); + assertEqual( + loaded!.schemaVersion, + BATCH_STATE_SCHEMA_VERSION, + "v2 workspace-mode load: schemaVersion is 2", + ); + assertEqual(loaded!.mode, "workspace", "v2 workspace-mode load: mode is 'workspace'"); + assertEqual(loaded!.baseBranch, "main", "v2 workspace-mode load: baseBranch preserved"); + assertEqual(loaded!.phase, "executing", "v2 workspace-mode load: phase preserved"); + assertEqual(loaded!.batchId, "20260315T100000", "v2 workspace-mode load: batchId preserved"); + + // Verify task repo fields from workspace-mode fixture + assertEqual(loaded!.tasks.length, 2, "v2 workspace-mode load: 2 task records"); + assertEqual(loaded!.tasks[0].taskId, "WS-001", "v2 workspace-mode load: task WS-001"); + assertEqual(loaded!.tasks[0].repoId, "api", "v2 workspace-mode load: task[0].repoId is 'api'"); + assertEqual( + loaded!.tasks[0].resolvedRepoId, + "api", + "v2 workspace-mode load: task[0].resolvedRepoId is 'api'", + ); + assertEqual(loaded!.tasks[1].taskId, "WS-002", "v2 workspace-mode load: task WS-002"); + assertEqual( + loaded!.tasks[1].repoId, + undefined, + "v2 workspace-mode load: task[1].repoId is undefined", + ); + assertEqual( + loaded!.tasks[1].resolvedRepoId, + "frontend", + "v2 workspace-mode load: task[1].resolvedRepoId is 'frontend'", + ); - } finally { - try { rmSync(v1NoRewriteRoot, { recursive: true, force: true }); } catch { /* best effort */ } + // Verify lane repo fields + assertEqual(loaded!.lanes.length, 2, "v2 workspace-mode load: 2 lane records"); + assertEqual(loaded!.lanes[0].repoId, "api", "v2 workspace-mode load: lane[0].repoId is 'api'"); + assertEqual( + loaded!.lanes[1].repoId, + "frontend", + "v2 workspace-mode load: lane[1].repoId is 'frontend'", + ); + } finally { + try { + rmSync(v2WsRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } -} -{ - console.log(" ▸ v1 load followed by explicit save writes v2 to disk"); + // ═══════════════════════════════════════════════════════════════════════ + // 7.3: Schema Version Guardrails (Step 2) + // ═══════════════════════════════════════════════════════════════════════ - const v1SaveRoot = join(tmpdir(), `orch-v1-save-test-${Date.now()}`); - mkdirSync(join(v1SaveRoot, ".pi"), { recursive: true }); + console.log("\n── 7.3: Schema version guardrails ──"); - try { - const v1Json = loadFixture("batch-state-v1-valid.json"); - const statePath = batchStatePath(v1SaveRoot); - writeFileSync(statePath, v1Json, "utf-8"); + { + console.log(" ▸ loadBatchState rejects unsupported schema version (>2) with actionable message"); - // Load v1 (in-memory upconversion) - const loaded = loadBatchState(v1SaveRoot); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "loaded as v2 in memory"); + const futureVersionRoot = join(tmpdir(), `orch-future-version-test-${Date.now()}`); + mkdirSync(join(futureVersionRoot, ".pi"), { recursive: true }); - // Now save the upconverted state back (simulating what happens on next persist) - const reserializedJson = JSON.stringify(loaded, null, 2); - saveBatchState(reserializedJson, v1SaveRoot); + try { + const futureVersionJson = loadFixture("batch-state-wrong-version.json"); + writeFileSync(batchStatePath(futureVersionRoot), futureVersionJson, "utf-8"); - // Read and verify it's now v2 on disk - const afterSave = readFileSync(statePath, "utf-8"); - const afterParsed = JSON.parse(afterSave); - assertEqual(afterParsed.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "on-disk: v2 after explicit save"); - assertEqual(afterParsed.mode, "repo", "on-disk: mode persisted as 'repo'"); - assertEqual(afterParsed.baseBranch, "", "on-disk: baseBranch persisted as ''"); + assertThrows( + () => loadBatchState(futureVersionRoot), + "STATE_SCHEMA_INVALID", + "future version (99) through load path throws STATE_SCHEMA_INVALID", + ); - } finally { - try { rmSync(v1SaveRoot, { recursive: true, force: true }); } catch { /* best effort */ } + // Also verify the error message is actionable + try { + loadBatchState(futureVersionRoot); + } catch (err: unknown) { + const e = err as { message?: string }; + assert( + e.message !== undefined && e.message.includes("Delete .pi/batch-state.json"), + "error message includes actionable instruction to delete state file", + ); + assert( + e.message !== undefined && e.message.includes("99"), + "error message includes the unsupported version number", + ); + } + } finally { + try { + rmSync(futureVersionRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } -} -// ═══════════════════════════════════════════════════════════════════════ -// 7.2: Schema v2 Compatibility — Load Path Regression Tests (Step 2) -// ═══════════════════════════════════════════════════════════════════════ + { + console.log(" ▸ loadBatchState rejects schema version 0 (below supported range)"); -console.log("\n── 7.2: Schema v2 compatibility — load path regression tests ──"); + const v0Root = join(tmpdir(), `orch-v0-test-${Date.now()}`); + mkdirSync(join(v0Root, ".pi"), { recursive: true }); -{ - console.log(" ▸ loadBatchState with v2 repo-mode fixture (batch-state-valid.json)"); + try { + const v0State = JSON.parse(loadFixture("batch-state-valid.json")); + v0State.schemaVersion = 0; + writeFileSync(batchStatePath(v0Root), JSON.stringify(v0State, null, 2), "utf-8"); + + assertThrows( + () => loadBatchState(v0Root), + "STATE_SCHEMA_INVALID", + "version 0 through load path throws STATE_SCHEMA_INVALID", + ); + } finally { + try { + rmSync(v0Root, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + } - const v2RepoRoot = join(tmpdir(), `orch-v2-repo-load-test-${Date.now()}`); - mkdirSync(join(v2RepoRoot, ".pi"), { recursive: true }); + { + console.log(" ▸ loadBatchState rejects schema version 3 (next unsupported)"); - try { - const v2Json = loadFixture("batch-state-valid.json"); - writeFileSync(batchStatePath(v2RepoRoot), v2Json, "utf-8"); - - const loaded = loadBatchState(v2RepoRoot); - assert(loaded !== null, "v2 repo-mode load: returns non-null"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2 repo-mode load: schemaVersion is 2"); - assertEqual(loaded!.mode, "repo", "v2 repo-mode load: mode is 'repo'"); - assertEqual(loaded!.baseBranch, "main", "v2 repo-mode load: baseBranch is 'main'"); - assertEqual(loaded!.phase, "executing", "v2 repo-mode load: phase preserved"); - assertEqual(loaded!.batchId, "20260309T010000", "v2 repo-mode load: batchId preserved"); - assertEqual(loaded!.tasks.length, 3, "v2 repo-mode load: 3 task records"); - assertEqual(loaded!.lanes.length, 2, "v2 repo-mode load: 2 lane records"); - - // Verify no spurious repo fields in repo-mode fixture - assertEqual(loaded!.tasks[0].repoId, undefined, "v2 repo-mode load: task repoId is undefined"); - assertEqual(loaded!.tasks[0].resolvedRepoId, undefined, "v2 repo-mode load: task resolvedRepoId is undefined"); - assertEqual(loaded!.lanes[0].repoId, undefined, "v2 repo-mode load: lane repoId is undefined"); + const v3Root = join(tmpdir(), `orch-v3-test-${Date.now()}`); + mkdirSync(join(v3Root, ".pi"), { recursive: true }); - } finally { - try { rmSync(v2RepoRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + const v3State = JSON.parse(loadFixture("batch-state-valid.json")); + v3State.schemaVersion = 3; + writeFileSync(batchStatePath(v3Root), JSON.stringify(v3State, null, 2), "utf-8"); + + assertThrows( + () => loadBatchState(v3Root), + "STATE_SCHEMA_INVALID", + "version 3 through load path throws STATE_SCHEMA_INVALID", + ); + } finally { + try { + rmSync(v3Root, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } -} -{ - console.log(" ▸ loadBatchState with v2 workspace-mode fixture (batch-state-v2-workspace.json)"); + { + console.log(" ▸ loadBatchState rejects malformed JSON through full load path"); - const v2WsRoot = join(tmpdir(), `orch-v2-ws-load-test-${Date.now()}`); - mkdirSync(join(v2WsRoot, ".pi"), { recursive: true }); + const malformedRoot = join(tmpdir(), `orch-malformed-load-test-${Date.now()}`); + mkdirSync(join(malformedRoot, ".pi"), { recursive: true }); - try { - const v2WsJson = loadFixture("batch-state-v2-workspace.json"); - writeFileSync(batchStatePath(v2WsRoot), v2WsJson, "utf-8"); - - const loaded = loadBatchState(v2WsRoot); - assert(loaded !== null, "v2 workspace-mode load: returns non-null"); - assertEqual(loaded!.schemaVersion, BATCH_STATE_SCHEMA_VERSION, "v2 workspace-mode load: schemaVersion is 2"); - assertEqual(loaded!.mode, "workspace", "v2 workspace-mode load: mode is 'workspace'"); - assertEqual(loaded!.baseBranch, "main", "v2 workspace-mode load: baseBranch preserved"); - assertEqual(loaded!.phase, "executing", "v2 workspace-mode load: phase preserved"); - assertEqual(loaded!.batchId, "20260315T100000", "v2 workspace-mode load: batchId preserved"); - - // Verify task repo fields from workspace-mode fixture - assertEqual(loaded!.tasks.length, 2, "v2 workspace-mode load: 2 task records"); - assertEqual(loaded!.tasks[0].taskId, "WS-001", "v2 workspace-mode load: task WS-001"); - assertEqual(loaded!.tasks[0].repoId, "api", "v2 workspace-mode load: task[0].repoId is 'api'"); - assertEqual(loaded!.tasks[0].resolvedRepoId, "api", "v2 workspace-mode load: task[0].resolvedRepoId is 'api'"); - assertEqual(loaded!.tasks[1].taskId, "WS-002", "v2 workspace-mode load: task WS-002"); - assertEqual(loaded!.tasks[1].repoId, undefined, "v2 workspace-mode load: task[1].repoId is undefined"); - assertEqual(loaded!.tasks[1].resolvedRepoId, "frontend", "v2 workspace-mode load: task[1].resolvedRepoId is 'frontend'"); - - // Verify lane repo fields - assertEqual(loaded!.lanes.length, 2, "v2 workspace-mode load: 2 lane records"); - assertEqual(loaded!.lanes[0].repoId, "api", "v2 workspace-mode load: lane[0].repoId is 'api'"); - assertEqual(loaded!.lanes[1].repoId, "frontend", "v2 workspace-mode load: lane[1].repoId is 'frontend'"); + try { + writeFileSync(batchStatePath(malformedRoot), "{ not valid json }", "utf-8"); - } finally { - try { rmSync(v2WsRoot, { recursive: true, force: true }); } catch { /* best effort */ } + assertThrows( + () => loadBatchState(malformedRoot), + "STATE_FILE_PARSE_ERROR", + "malformed JSON through load path throws STATE_FILE_PARSE_ERROR", + ); + } finally { + try { + rmSync(malformedRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } } -} - -// ═══════════════════════════════════════════════════════════════════════ -// 7.3: Schema Version Guardrails (Step 2) -// ═══════════════════════════════════════════════════════════════════════ -console.log("\n── 7.3: Schema version guardrails ──"); - -{ - console.log(" ▸ loadBatchState rejects unsupported schema version (>2) with actionable message"); + { + console.log(" ▸ loadBatchState rejects v2 with missing required mode field"); - const futureVersionRoot = join(tmpdir(), `orch-future-version-test-${Date.now()}`); - mkdirSync(join(futureVersionRoot, ".pi"), { recursive: true }); + const v2NoModeRoot = join(tmpdir(), `orch-v2-nomode-test-${Date.now()}`); + mkdirSync(join(v2NoModeRoot, ".pi"), { recursive: true }); - try { - const futureVersionJson = loadFixture("batch-state-wrong-version.json"); - writeFileSync(batchStatePath(futureVersionRoot), futureVersionJson, "utf-8"); + try { + const v2State = JSON.parse(loadFixture("batch-state-valid.json")); + delete v2State.mode; // Remove required v2 field + writeFileSync(batchStatePath(v2NoModeRoot), JSON.stringify(v2State, null, 2), "utf-8"); + + assertThrows( + () => loadBatchState(v2NoModeRoot), + "STATE_SCHEMA_INVALID", + "v2 without mode through load path throws STATE_SCHEMA_INVALID", + ); + } finally { + try { + rmSync(v2NoModeRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + } - assertThrows( - () => loadBatchState(futureVersionRoot), - "STATE_SCHEMA_INVALID", - "future version (99) through load path throws STATE_SCHEMA_INVALID", + { + console.log( + " ▸ v1 upconverted state is usable for resume flow (loadBatchState → reconcile → resume)", ); - // Also verify the error message is actionable + // Integration test: v1 file loaded, upconverted, then used in resume decision pipeline + const v1ResumeRoot = join(tmpdir(), `orch-v1-resume-test-${Date.now()}`); + mkdirSync(join(v1ResumeRoot, ".pi"), { recursive: true }); + try { - loadBatchState(futureVersionRoot); - } catch (err: unknown) { - const e = err as { message?: string }; - assert( - e.message !== undefined && e.message.includes("Delete .pi/batch-state.json"), - "error message includes actionable instruction to delete state file", + const v1Json = loadFixture("batch-state-v1-valid.json"); + writeFileSync(batchStatePath(v1ResumeRoot), v1Json, "utf-8"); + + // Load through full path (v1 → v2 upconversion) + const loaded = loadBatchState(v1ResumeRoot); + assert(loaded !== null, "v1 resume flow: state loaded"); + + // Check resume eligibility (executing phase is eligible) + const eligibility = checkResumeEligibility(loaded!); + assertEqual(eligibility.eligible, true, "v1 resume flow: executing phase is resumable"); + + // Reconcile tasks (simulate: TS-001 done, TS-002 dead, TS-003 not started) + const reconciled = reconcileTaskStates(loaded!, new Set(), new Set(["TS-001"])); + assertEqual(reconciled.length, 3, "v1 resume flow: 3 tasks reconciled"); + + // TS-001: succeeded + .DONE → mark-complete + const ts001 = reconciled.find((r: any) => r.taskId === "TS-001"); + assertEqual(ts001!.action, "mark-complete", "v1 resume: TS-001 mark-complete"); + + // TS-002: running + dead session + no .DONE → mark-failed + const ts002 = reconciled.find((r: any) => r.taskId === "TS-002"); + assertEqual(ts002!.action, "mark-failed", "v1 resume: TS-002 mark-failed"); + + // TS-003: pending + no session → "pending" action (never-started, remains pending for execution) + const ts003 = reconciled.find((r: any) => r.taskId === "TS-003"); + assertEqual(ts003!.action, "pending", "v1 resume: TS-003 pending (never-started, no session)"); + + // Compute resume point + // Wave 0: TS-001 mark-complete (done) + TS-002 mark-failed (NOT done for wave-skip) + const resumePoint = computeResumePoint(loaded!, reconciled); + assertEqual( + resumePoint.resumeWaveIndex, + 0, + "v1 resume: wave 0 (TS-002 mark-failed NOT done for wave-skip)", ); - assert( - e.message !== undefined && e.message.includes("99"), - "error message includes the unsupported version number", + assertEqual(resumePoint.completedTaskIds.length, 1, "v1 resume: 1 completed (TS-001)"); + assert(resumePoint.completedTaskIds.includes("TS-001"), "v1 resume: TS-001 completed"); + assertEqual(resumePoint.failedTaskIds.length, 1, "v1 resume: 1 failed (TS-002 only)"); + assert(resumePoint.pendingTaskIds.includes("TS-003"), "v1 resume: TS-003 pending for execution"); + + // Verify orphan detection with upconverted state + const orphanResult = analyzeOrchestratorStartupState( + [], // No orphan sessions + "valid", + loaded!, + null, + new Set(["TS-001"]), // TS-001 has .DONE ); + assertEqual( + orphanResult.recommendedAction, + "resume", + "v1 resume: orphan detection recommends resume", + ); + } finally { + try { + rmSync(v1ResumeRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } } - - } finally { - try { rmSync(futureVersionRoot, { recursive: true, force: true }); } catch { /* best effort */ } } -} -{ - console.log(" ▸ loadBatchState rejects schema version 0 (below supported range)"); + // ═══════════════════════════════════════════════════════════════════════ + // 7.1: Mixed-repo reconciliation (TP-007 Step 0) + // ═══════════════════════════════════════════════════════════════════════ - const v0Root = join(tmpdir(), `orch-v0-test-${Date.now()}`); - mkdirSync(join(v0Root, ".pi"), { recursive: true }); + console.log("\n── 7.1: Mixed-repo reconciliation ──"); - try { - const v0State = JSON.parse(loadFixture("batch-state-valid.json")); - v0State.schemaVersion = 0; - writeFileSync(batchStatePath(v0Root), JSON.stringify(v0State, null, 2), "utf-8"); + // Helper: create a workspace-mode persisted state with multi-repo lanes and tasks + function workspacePersistedState( + overrides?: Partial, + ): PersistedBatchStateForTest { + return { + schemaVersion: 2, + phase: "executing", + batchId: "20260315T120000", + baseBranch: "main", + mode: "workspace", + startedAt: Date.now() - 120000, + updatedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + wavePlan: [["WS-001", "WS-002"]], + lanes: [ + { + laneNumber: 1, + laneId: "api/lane-1", + laneSessionId: "orch-api-lane-1", + worktreePath: "/tmp/ws-wt-1", + branch: "task/api-lane-1-20260315T120000", + taskIds: ["WS-001"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "frontend/lane-2", + laneSessionId: "orch-frontend-lane-2", + worktreePath: "/tmp/ws-wt-2", + branch: "task/frontend-lane-2-20260315T120000", + taskIds: ["WS-002"], + repoId: "frontend", + }, + ], + tasks: [ + { + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-api-lane-1", + status: "running", + taskFolder: "/tmp/tasks/WS-001", + startedAt: Date.now() - 60000, + endedAt: null, + doneFileFound: false, + exitReason: "", + repoId: "api", + resolvedRepoId: "api", + }, + { + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-frontend-lane-2", + status: "running", + taskFolder: "/tmp/tasks/WS-002", + startedAt: Date.now() - 60000, + endedAt: null, + doneFileFound: false, + exitReason: "", + repoId: "frontend", + resolvedRepoId: "frontend", + }, + ], + mergeResults: [], + totalTasks: 2, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: [], + lastError: null, + errors: [], + ...overrides, + }; + } - assertThrows( - () => loadBatchState(v0Root), - "STATE_SCHEMA_INVALID", - "version 0 through load path throws STATE_SCHEMA_INVALID", - ); - - } finally { - try { rmSync(v0Root, { recursive: true, force: true }); } catch { /* best effort */ } + // Reimplement resolveRepoRoot for test self-containment (mirrors source) + function resolveRepoRoot( + repoId: string | undefined, + defaultRepoRoot: string, + workspaceConfig?: { repos: Map } | null, + ): string { + if (!repoId || !workspaceConfig) { + return defaultRepoRoot; + } + const repoConfig = workspaceConfig.repos.get(repoId); + if (!repoConfig) { + return defaultRepoRoot; + } + return repoConfig.path; } -} -{ - console.log(" ▸ loadBatchState rejects schema version 3 (next unsupported)"); - - const v3Root = join(tmpdir(), `orch-v3-test-${Date.now()}`); - mkdirSync(join(v3Root, ".pi"), { recursive: true }); + // Reimplement collectRepoRoots for test self-containment (mirrors source) + function collectRepoRoots( + persistedState: { lanes: Array<{ repoId?: string }> }, + defaultRepoRoot: string, + workspaceConfig?: { repos: Map } | null, + ): string[] { + const roots = new Set(); + for (const lane of persistedState.lanes) { + const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); + roots.add(root); + } + roots.add(defaultRepoRoot); + return [...roots]; + } - try { - const v3State = JSON.parse(loadFixture("batch-state-valid.json")); - v3State.schemaVersion = 3; - writeFileSync(batchStatePath(v3Root), JSON.stringify(v3State, null, 2), "utf-8"); + { + console.log(" ▸ workspace v2: one repo lane alive + another dead → correct reconcile actions"); + const state = workspacePersistedState(); + // WS-001 (api repo): session alive + // WS-002 (frontend repo): session dead, no .DONE + const aliveSessions = new Set(["orch-api-lane-1"]); + const doneTaskIds = new Set(); + const result = reconcileTaskStates(state, aliveSessions, doneTaskIds); + assertEqual(result.length, 2, "two tasks reconciled"); + + // WS-001: alive session → reconnect + assertEqual(result[0].taskId, "WS-001", "first task is WS-001"); + assertEqual(result[0].action, "reconnect", "WS-001: reconnect (alive session)"); + assertEqual(result[0].sessionAlive, true, "WS-001: session alive"); + + // WS-002: dead session + no .DONE + no worktree → mark-failed + assertEqual(result[1].taskId, "WS-002", "second task is WS-002"); + assertEqual( + result[1].action, + "mark-failed", + "WS-002: mark-failed (dead session, no DONE, no worktree)", + ); + assertEqual(result[1].sessionAlive, false, "WS-002: session not alive"); + assertEqual(result[1].liveStatus, "failed", "WS-002: live status failed"); + } - assertThrows( - () => loadBatchState(v3Root), - "STATE_SCHEMA_INVALID", - "version 3 through load path throws STATE_SCHEMA_INVALID", + { + console.log( + " ▸ workspace v2: .DONE in one repo + dead session in another → mark-complete vs mark-failed", ); + const state = workspacePersistedState(); + // WS-001 (api repo): .DONE found + // WS-002 (frontend repo): dead session, no .DONE + const aliveSessions = new Set(); + const doneTaskIds = new Set(["WS-001"]); + const result = reconcileTaskStates(state, aliveSessions, doneTaskIds); + assertEqual(result.length, 2, "two tasks reconciled"); - } finally { - try { rmSync(v3Root, { recursive: true, force: true }); } catch { /* best effort */ } - } -} + // WS-001: .DONE found → mark-complete (regardless of session state) + assertEqual(result[0].action, "mark-complete", "WS-001: mark-complete (.DONE found)"); + assertEqual(result[0].doneFileFound, true, "WS-001: done file found"); + assertEqual(result[0].liveStatus, "succeeded", "WS-001: live status succeeded"); -{ - console.log(" ▸ loadBatchState rejects malformed JSON through full load path"); + // WS-002: dead session + no .DONE → mark-failed + assertEqual(result[1].action, "mark-failed", "WS-002: mark-failed (dead session, no .DONE)"); + assertEqual(result[1].liveStatus, "failed", "WS-002: live status failed"); + } - const malformedRoot = join(tmpdir(), `orch-malformed-load-test-${Date.now()}`); - mkdirSync(join(malformedRoot, ".pi"), { recursive: true }); + { + console.log(" ▸ v1 state (no repo fields) reconciles correctly with all-undefined repo fields"); + // Simulate v1 state that was upconverted to v2 (mode="repo", no repo fields) + const state = minimalPersistedState({ + mode: "repo", + baseBranch: "", + tasks: [ + makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" }), + makeTaskRecord({ taskId: "T2", sessionName: "orch-lane-2", status: "succeeded" }), + ], + wavePlan: [["T1", "T2"]], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "b1", + taskIds: ["T1"], + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "b2", + taskIds: ["T2"], + }, + ], + }); + // Verify no repo fields on tasks or lanes + assertEqual(state.tasks[0].repoId, undefined, "v1 task[0] repoId undefined"); + assertEqual(state.tasks[0].resolvedRepoId, undefined, "v1 task[0] resolvedRepoId undefined"); + assertEqual(state.lanes[0].repoId, undefined, "v1 lane[0] repoId undefined"); - try { - writeFileSync(batchStatePath(malformedRoot), "{ not valid json }", "utf-8"); + // T1: running + dead session → mark-failed + // T2: succeeded + dead session → skip (terminal status) + const result = reconcileTaskStates(state, new Set(), new Set()); + assertEqual(result[0].action, "mark-failed", "v1 T1: mark-failed"); + assertEqual(result[1].action, "skip", "v1 T2: skip (already succeeded)"); + assertEqual(result[1].liveStatus, "succeeded", "v1 T2: live status preserved"); + } - assertThrows( - () => loadBatchState(malformedRoot), - "STATE_FILE_PARSE_ERROR", - "malformed JSON through load path throws STATE_FILE_PARSE_ERROR", + { + console.log( + " ▸ workspace v2: worktree exists vs missing split across repos → re-execute vs mark-failed", ); + const state = workspacePersistedState(); + // WS-001 (api repo): dead session + worktree exists → re-execute + // WS-002 (frontend repo): dead session + no worktree → mark-failed + const aliveSessions = new Set(); + const doneTaskIds = new Set(); + const existingWorktrees = new Set(["WS-001"]); // Only WS-001's worktree exists + const result = reconcileTaskStates(state, aliveSessions, doneTaskIds, existingWorktrees); + assertEqual(result.length, 2, "two tasks reconciled"); - } finally { - try { rmSync(malformedRoot, { recursive: true, force: true }); } catch { /* best effort */ } + // WS-001: dead + worktree exists → re-execute + assertEqual(result[0].action, "re-execute", "WS-001: re-execute (worktree exists)"); + assertEqual(result[0].worktreeExists, true, "WS-001: worktree exists"); + assertEqual(result[0].liveStatus, "pending", "WS-001: live status pending (for re-execution)"); + + // WS-002: dead + no worktree → mark-failed + assertEqual(result[1].action, "mark-failed", "WS-002: mark-failed (no worktree)"); + assertEqual(result[1].worktreeExists, false, "WS-002: worktree missing"); } -} -{ - console.log(" ▸ loadBatchState rejects v2 with missing required mode field"); + { + console.log( + " ▸ resolveRepoRoot: v2 lanes get correct repo root, v1/undefined lanes get default root", + ); + const wsConfig = { + repos: new Map([ + ["api", { path: "/repos/api" }], + ["frontend", { path: "/repos/frontend" }], + ]), + }; + const defaultRoot = "/repos/default"; - const v2NoModeRoot = join(tmpdir(), `orch-v2-nomode-test-${Date.now()}`); - mkdirSync(join(v2NoModeRoot, ".pi"), { recursive: true }); + // v2 workspace mode: repoId present → resolved to workspace config path + assertEqual( + resolveRepoRoot("api", defaultRoot, wsConfig), + "/repos/api", + "resolveRepoRoot('api') → workspace config path", + ); + assertEqual( + resolveRepoRoot("frontend", defaultRoot, wsConfig), + "/repos/frontend", + "resolveRepoRoot('frontend') → workspace config path", + ); - try { - const v2State = JSON.parse(loadFixture("batch-state-valid.json")); - delete v2State.mode; // Remove required v2 field - writeFileSync(batchStatePath(v2NoModeRoot), JSON.stringify(v2State, null, 2), "utf-8"); + // v1/repo mode: repoId undefined → default root + assertEqual( + resolveRepoRoot(undefined, defaultRoot, wsConfig), + defaultRoot, + "resolveRepoRoot(undefined) → default root", + ); - assertThrows( - () => loadBatchState(v2NoModeRoot), - "STATE_SCHEMA_INVALID", - "v2 without mode through load path throws STATE_SCHEMA_INVALID", + // No workspace config (repo mode): always default root + assertEqual( + resolveRepoRoot("api", defaultRoot, null), + defaultRoot, + "resolveRepoRoot('api', null config) → default root", ); - } finally { - try { rmSync(v2NoModeRoot, { recursive: true, force: true }); } catch { /* best effort */ } + // Unknown repoId: falls back to default + assertEqual( + resolveRepoRoot("unknown-repo", defaultRoot, wsConfig), + defaultRoot, + "resolveRepoRoot('unknown-repo') → default root (defensive fallback)", + ); } -} -{ - console.log(" ▸ v1 upconverted state is usable for resume flow (loadBatchState → reconcile → resume)"); - - // Integration test: v1 file loaded, upconverted, then used in resume decision pipeline - const v1ResumeRoot = join(tmpdir(), `orch-v1-resume-test-${Date.now()}`); - mkdirSync(join(v1ResumeRoot, ".pi"), { recursive: true }); + { + console.log(" ▸ collectRepoRoots: workspace mode collects per-repo roots from lanes"); + const wsConfig = { + repos: new Map([ + ["api", { path: "/repos/api" }], + ["frontend", { path: "/repos/frontend" }], + ]), + }; + const defaultRoot = "/repos/default"; + const state = workspacePersistedState(); - try { - const v1Json = loadFixture("batch-state-v1-valid.json"); - writeFileSync(batchStatePath(v1ResumeRoot), v1Json, "utf-8"); - - // Load through full path (v1 → v2 upconversion) - const loaded = loadBatchState(v1ResumeRoot); - assert(loaded !== null, "v1 resume flow: state loaded"); - - // Check resume eligibility (executing phase is eligible) - const eligibility = checkResumeEligibility(loaded!); - assertEqual(eligibility.eligible, true, "v1 resume flow: executing phase is resumable"); - - // Reconcile tasks (simulate: TS-001 done, TS-002 dead, TS-003 not started) - const reconciled = reconcileTaskStates(loaded!, new Set(), new Set(["TS-001"])); - assertEqual(reconciled.length, 3, "v1 resume flow: 3 tasks reconciled"); - - // TS-001: succeeded + .DONE → mark-complete - const ts001 = reconciled.find((r: any) => r.taskId === "TS-001"); - assertEqual(ts001!.action, "mark-complete", "v1 resume: TS-001 mark-complete"); - - // TS-002: running + dead session + no .DONE → mark-failed - const ts002 = reconciled.find((r: any) => r.taskId === "TS-002"); - assertEqual(ts002!.action, "mark-failed", "v1 resume: TS-002 mark-failed"); - - // TS-003: pending + no session → "pending" action (never-started, remains pending for execution) - const ts003 = reconciled.find((r: any) => r.taskId === "TS-003"); - assertEqual(ts003!.action, "pending", "v1 resume: TS-003 pending (never-started, no session)"); - - // Compute resume point - // Wave 0: TS-001 mark-complete (done) + TS-002 mark-failed (NOT done for wave-skip) - const resumePoint = computeResumePoint(loaded!, reconciled); - assertEqual(resumePoint.resumeWaveIndex, 0, "v1 resume: wave 0 (TS-002 mark-failed NOT done for wave-skip)"); - assertEqual(resumePoint.completedTaskIds.length, 1, "v1 resume: 1 completed (TS-001)"); - assert(resumePoint.completedTaskIds.includes("TS-001"), "v1 resume: TS-001 completed"); - assertEqual(resumePoint.failedTaskIds.length, 1, "v1 resume: 1 failed (TS-002 only)"); - assert(resumePoint.pendingTaskIds.includes("TS-003"), "v1 resume: TS-003 pending for execution"); - - // Verify orphan detection with upconverted state - const orphanResult = analyzeOrchestratorStartupState( - [], // No orphan sessions - "valid", - loaded!, - null, - new Set(["TS-001"]), // TS-001 has .DONE - ); - assertEqual(orphanResult.recommendedAction, "resume", "v1 resume: orphan detection recommends resume"); + const roots = collectRepoRoots(state, defaultRoot, wsConfig); + assert(roots.includes("/repos/api"), "collectRepoRoots includes api root"); + assert(roots.includes("/repos/frontend"), "collectRepoRoots includes frontend root"); + assert(roots.includes(defaultRoot), "collectRepoRoots includes default root"); + assertEqual(roots.length, 3, "collectRepoRoots returns 3 unique roots"); + } - } finally { - try { rmSync(v1ResumeRoot, { recursive: true, force: true }); } catch { /* best effort */ } + { + console.log(" ▸ collectRepoRoots: repo mode (v1) returns only default root"); + const state = minimalPersistedState({ + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "b1", + taskIds: ["T1"], + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "b2", + taskIds: ["T2"], + }, + ], + }); + const defaultRoot = "/repos/main"; + // No workspace config → repo mode + const roots = collectRepoRoots(state, defaultRoot, null); + assertEqual(roots.length, 1, "repo mode: only default root"); + assertEqual(roots[0], defaultRoot, "repo mode: root is default"); } -} -// ═══════════════════════════════════════════════════════════════════════ -// 7.1: Mixed-repo reconciliation (TP-007 Step 0) -// ═══════════════════════════════════════════════════════════════════════ - -console.log("\n── 7.1: Mixed-repo reconciliation ──"); - -// Helper: create a workspace-mode persisted state with multi-repo lanes and tasks -function workspacePersistedState(overrides?: Partial): PersistedBatchStateForTest { - return { - schemaVersion: 2, - phase: "executing", - batchId: "20260315T120000", - baseBranch: "main", - mode: "workspace", - startedAt: Date.now() - 120000, - updatedAt: Date.now(), - endedAt: null, - currentWaveIndex: 0, - totalWaves: 1, - wavePlan: [["WS-001", "WS-002"]], - lanes: [ - { - laneNumber: 1, - laneId: "api/lane-1", - laneSessionId: "orch-api-lane-1", - worktreePath: "/tmp/ws-wt-1", - branch: "task/api-lane-1-20260315T120000", - taskIds: ["WS-001"], - repoId: "api", - }, - { - laneNumber: 2, - laneId: "frontend/lane-2", - laneSessionId: "orch-frontend-lane-2", - worktreePath: "/tmp/ws-wt-2", - branch: "task/frontend-lane-2-20260315T120000", - taskIds: ["WS-002"], - repoId: "frontend", - }, - ], - tasks: [ - { - taskId: "WS-001", - laneNumber: 1, - sessionName: "orch-api-lane-1", - status: "running", - taskFolder: "/tmp/tasks/WS-001", - startedAt: Date.now() - 60000, - endedAt: null, - doneFileFound: false, - exitReason: "", - repoId: "api", - resolvedRepoId: "api", - }, - { - taskId: "WS-002", - laneNumber: 2, - sessionName: "orch-frontend-lane-2", - status: "running", - taskFolder: "/tmp/tasks/WS-002", - startedAt: Date.now() - 60000, - endedAt: null, - doneFileFound: false, - exitReason: "", - repoId: "frontend", - resolvedRepoId: "frontend", - }, - ], - mergeResults: [], - totalTasks: 2, - succeededTasks: 0, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: [], - lastError: null, - errors: [], - ...overrides, - }; -} + { + console.log(" ▸ workspace v2: computeResumePoint with mixed-repo outcomes"); + const state = workspacePersistedState({ + wavePlan: [["WS-001", "WS-002"], ["WS-003"]], + tasks: [ + { + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-api-lane-1", + status: "running", + taskFolder: "/tmp/tasks/WS-001", + startedAt: Date.now() - 60000, + endedAt: null, + doneFileFound: false, + exitReason: "", + repoId: "api", + resolvedRepoId: "api", + }, + { + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-frontend-lane-2", + status: "running", + taskFolder: "/tmp/tasks/WS-002", + startedAt: Date.now() - 60000, + endedAt: null, + doneFileFound: false, + exitReason: "", + repoId: "frontend", + resolvedRepoId: "frontend", + }, + { + taskId: "WS-003", + laneNumber: 1, + sessionName: "orch-api-lane-1", + status: "pending", + taskFolder: "/tmp/tasks/WS-003", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + repoId: "api", + resolvedRepoId: "api", + }, + ], + }); -// Reimplement resolveRepoRoot for test self-containment (mirrors source) -function resolveRepoRoot( - repoId: string | undefined, - defaultRepoRoot: string, - workspaceConfig?: { repos: Map } | null, -): string { - if (!repoId || !workspaceConfig) { - return defaultRepoRoot; + // WS-001 (api): .DONE found → mark-complete + // WS-002 (frontend): dead session → mark-failed + // WS-003 (api, wave 2): pending + const reconciled = reconcileTaskStates(state, new Set(), new Set(["WS-001"])); + const point = computeResumePoint(state, reconciled); + + // Wave 0: WS-001 mark-complete (done) + WS-002 mark-failed (NOT done for wave-skip) + assertEqual(point.resumeWaveIndex, 0, "resumes from wave 0 (mark-failed NOT done for wave-skip)"); + assert(point.completedTaskIds.includes("WS-001"), "WS-001 in completed"); + assert(point.failedTaskIds.includes("WS-002"), "WS-002 in failed"); + assert( + point.failedTaskIds.includes("WS-003"), + "WS-003 in failed (mark-failed: dead session + no DONE + no worktree)", + ); } - const repoConfig = workspaceConfig.repos.get(repoId); - if (!repoConfig) { - return defaultRepoRoot; + + { + console.log(" ▸ workspace v2: both repo lanes alive → both reconnect"); + const state = workspacePersistedState(); + const aliveSessions = new Set(["orch-api-lane-1", "orch-frontend-lane-2"]); + const result = reconcileTaskStates(state, aliveSessions, new Set()); + + assertEqual(result[0].action, "reconnect", "WS-001 (api): reconnect"); + assertEqual(result[1].action, "reconnect", "WS-002 (frontend): reconnect"); + + const point = computeResumePoint(state, result); + assertEqual(point.reconnectTaskIds.length, 2, "both tasks need reconnection"); + assertEqual(point.resumeWaveIndex, 0, "resume from wave 0 (tasks still running)"); + assert(point.pendingTaskIds.includes("WS-001"), "WS-001 in pending (reconnect)"); + assert(point.pendingTaskIds.includes("WS-002"), "WS-002 in pending (reconnect)"); } - return repoConfig.path; -} -// Reimplement collectRepoRoots for test self-containment (mirrors source) -function collectRepoRoots( - persistedState: { lanes: Array<{ repoId?: string }> }, - defaultRepoRoot: string, - workspaceConfig?: { repos: Map } | null, -): string[] { - const roots = new Set(); - for (const lane of persistedState.lanes) { - const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); - roots.add(root); - } - roots.add(defaultRepoRoot); - return [...roots]; -} + { + console.log(" ▸ workspace v2: all repos completed → resume past all waves"); + const state = workspacePersistedState({ + tasks: [ + { + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-api-lane-1", + status: "succeeded", + taskFolder: "/tmp/tasks/WS-001", + startedAt: Date.now() - 60000, + endedAt: Date.now() - 30000, + doneFileFound: true, + exitReason: "", + repoId: "api", + resolvedRepoId: "api", + }, + { + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-frontend-lane-2", + status: "succeeded", + taskFolder: "/tmp/tasks/WS-002", + startedAt: Date.now() - 60000, + endedAt: Date.now() - 30000, + doneFileFound: true, + exitReason: "", + repoId: "frontend", + resolvedRepoId: "frontend", + }, + ], + }); -{ - console.log(" ▸ workspace v2: one repo lane alive + another dead → correct reconcile actions"); - const state = workspacePersistedState(); - // WS-001 (api repo): session alive - // WS-002 (frontend repo): session dead, no .DONE - const aliveSessions = new Set(["orch-api-lane-1"]); - const doneTaskIds = new Set(); - const result = reconcileTaskStates(state, aliveSessions, doneTaskIds); - assertEqual(result.length, 2, "two tasks reconciled"); - - // WS-001: alive session → reconnect - assertEqual(result[0].taskId, "WS-001", "first task is WS-001"); - assertEqual(result[0].action, "reconnect", "WS-001: reconnect (alive session)"); - assertEqual(result[0].sessionAlive, true, "WS-001: session alive"); - - // WS-002: dead session + no .DONE + no worktree → mark-failed - assertEqual(result[1].taskId, "WS-002", "second task is WS-002"); - assertEqual(result[1].action, "mark-failed", "WS-002: mark-failed (dead session, no DONE, no worktree)"); - assertEqual(result[1].sessionAlive, false, "WS-002: session not alive"); - assertEqual(result[1].liveStatus, "failed", "WS-002: live status failed"); -} + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + const point = computeResumePoint(state, reconciled); -{ - console.log(" ▸ workspace v2: .DONE in one repo + dead session in another → mark-complete vs mark-failed"); - const state = workspacePersistedState(); - // WS-001 (api repo): .DONE found - // WS-002 (frontend repo): dead session, no .DONE - const aliveSessions = new Set(); - const doneTaskIds = new Set(["WS-001"]); - const result = reconcileTaskStates(state, aliveSessions, doneTaskIds); - assertEqual(result.length, 2, "two tasks reconciled"); - - // WS-001: .DONE found → mark-complete (regardless of session state) - assertEqual(result[0].action, "mark-complete", "WS-001: mark-complete (.DONE found)"); - assertEqual(result[0].doneFileFound, true, "WS-001: done file found"); - assertEqual(result[0].liveStatus, "succeeded", "WS-001: live status succeeded"); - - // WS-002: dead session + no .DONE → mark-failed - assertEqual(result[1].action, "mark-failed", "WS-002: mark-failed (dead session, no .DONE)"); - assertEqual(result[1].liveStatus, "failed", "WS-002: live status failed"); -} + assertEqual(point.resumeWaveIndex, 1, "resume past all waves (all done)"); + assertEqual(point.completedTaskIds.length, 2, "both tasks completed"); + assertEqual(point.failedTaskIds.length, 0, "no failed tasks"); + } -{ - console.log(" ▸ v1 state (no repo fields) reconciles correctly with all-undefined repo fields"); - // Simulate v1 state that was upconverted to v2 (mode="repo", no repo fields) - const state = minimalPersistedState({ - mode: "repo", - baseBranch: "", - tasks: [ - makeTaskRecord({ taskId: "T1", sessionName: "orch-lane-1", status: "running" }), - makeTaskRecord({ taskId: "T2", sessionName: "orch-lane-2", status: "succeeded" }), - ], - wavePlan: [["T1", "T2"]], - lanes: [ - { laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", worktreePath: "/tmp/wt-1", branch: "b1", taskIds: ["T1"] }, - { laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", worktreePath: "/tmp/wt-2", branch: "b2", taskIds: ["T2"] }, - ], - }); - // Verify no repo fields on tasks or lanes - assertEqual(state.tasks[0].repoId, undefined, "v1 task[0] repoId undefined"); - assertEqual(state.tasks[0].resolvedRepoId, undefined, "v1 task[0] resolvedRepoId undefined"); - assertEqual(state.lanes[0].repoId, undefined, "v1 lane[0] repoId undefined"); - - // T1: running + dead session → mark-failed - // T2: succeeded + dead session → skip (terminal status) - const result = reconcileTaskStates(state, new Set(), new Set()); - assertEqual(result[0].action, "mark-failed", "v1 T1: mark-failed"); - assertEqual(result[1].action, "skip", "v1 T2: skip (already succeeded)"); - assertEqual(result[1].liveStatus, "succeeded", "v1 T2: live status preserved"); -} + // ═══════════════════════════════════════════════════════════════════════ + // 8.1: Mixed-Repo Reconciliation (TP-007 Step 0) + // ═══════════════════════════════════════════════════════════════════════ -{ - console.log(" ▸ workspace v2: worktree exists vs missing split across repos → re-execute vs mark-failed"); - const state = workspacePersistedState(); - // WS-001 (api repo): dead session + worktree exists → re-execute - // WS-002 (frontend repo): dead session + no worktree → mark-failed - const aliveSessions = new Set(); - const doneTaskIds = new Set(); - const existingWorktrees = new Set(["WS-001"]); // Only WS-001's worktree exists - const result = reconcileTaskStates(state, aliveSessions, doneTaskIds, existingWorktrees); - assertEqual(result.length, 2, "two tasks reconciled"); - - // WS-001: dead + worktree exists → re-execute - assertEqual(result[0].action, "re-execute", "WS-001: re-execute (worktree exists)"); - assertEqual(result[0].worktreeExists, true, "WS-001: worktree exists"); - assertEqual(result[0].liveStatus, "pending", "WS-001: live status pending (for re-execution)"); - - // WS-002: dead + no worktree → mark-failed - assertEqual(result[1].action, "mark-failed", "WS-002: mark-failed (no worktree)"); - assertEqual(result[1].worktreeExists, false, "WS-002: worktree missing"); -} + console.log("\n── 8.1: Mixed-repo reconciliation scenarios (TP-007) ──"); -{ - console.log(" ▸ resolveRepoRoot: v2 lanes get correct repo root, v1/undefined lanes get default root"); - const wsConfig = { - repos: new Map([ - ["api", { path: "/repos/api" }], - ["frontend", { path: "/repos/frontend" }], - ]), - }; - const defaultRoot = "/repos/default"; - - // v2 workspace mode: repoId present → resolved to workspace config path - assertEqual( - resolveRepoRoot("api", defaultRoot, wsConfig), - "/repos/api", - "resolveRepoRoot('api') → workspace config path", - ); - assertEqual( - resolveRepoRoot("frontend", defaultRoot, wsConfig), - "/repos/frontend", - "resolveRepoRoot('frontend') → workspace config path", - ); - - // v1/repo mode: repoId undefined → default root - assertEqual( - resolveRepoRoot(undefined, defaultRoot, wsConfig), - defaultRoot, - "resolveRepoRoot(undefined) → default root", - ); - - // No workspace config (repo mode): always default root - assertEqual( - resolveRepoRoot("api", defaultRoot, null), - defaultRoot, - "resolveRepoRoot('api', null config) → default root", - ); - - // Unknown repoId: falls back to default - assertEqual( - resolveRepoRoot("unknown-repo", defaultRoot, wsConfig), - defaultRoot, - "resolveRepoRoot('unknown-repo') → default root (defensive fallback)", - ); -} + // Reimplement resolveRepoRoot for section 8.1 self-containment (mirrors source exactly). + // Renamed from `resolveRepoRoot` to avoid clashing with the section-7 helper of the same name + // (Biome lint/suspicious/noRedeclare). Bodies are functionally identical. + function resolveRepoRootMixedRepo( + repoId: string | undefined, + defaultRepoRoot: string, + workspaceConfig?: { repos: Map } | null, + ): string { + if (!repoId || !workspaceConfig) { + return defaultRepoRoot; + } + const repoConfig = workspaceConfig.repos.get(repoId); + if (!repoConfig) { + return defaultRepoRoot; + } + return repoConfig.path; + } + + // Helper: build a workspace-mode persisted state with multi-repo lanes + function makeWorkspaceState(overrides: Partial = {}): any { + return minimalPersistedState({ + mode: "workspace", + baseBranch: "main", + wavePlan: [["WS-001", "WS-002"]], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-batch", + taskIds: ["WS-001"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "task/lane-2-batch", + taskIds: ["WS-002"], + repoId: "frontend", + }, + ], + tasks: [ + makeTaskRecord({ + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/WS-001", + repoId: "api", + resolvedRepoId: "api", + }), + makeTaskRecord({ + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-lane-2", + status: "running", + taskFolder: "/tmp/tasks/WS-002", + resolvedRepoId: "frontend", + }), + ], + ...overrides, + }); + } -{ - console.log(" ▸ collectRepoRoots: workspace mode collects per-repo roots from lanes"); - const wsConfig = { + // Workspace config for resolveRepoRoot tests + const testWorkspaceConfig = { repos: new Map([ - ["api", { path: "/repos/api" }], - ["frontend", { path: "/repos/frontend" }], + ["api", { path: "/repos/api", defaultBranch: "main" }], + ["frontend", { path: "/repos/frontend", defaultBranch: "develop" }], ]), }; - const defaultRoot = "/repos/default"; - const state = workspacePersistedState(); - - const roots = collectRepoRoots(state, defaultRoot, wsConfig); - assert(roots.includes("/repos/api"), "collectRepoRoots includes api root"); - assert(roots.includes("/repos/frontend"), "collectRepoRoots includes frontend root"); - assert(roots.includes(defaultRoot), "collectRepoRoots includes default root"); - assertEqual(roots.length, 3, "collectRepoRoots returns 3 unique roots"); -} -{ - console.log(" ▸ collectRepoRoots: repo mode (v1) returns only default root"); - const state = minimalPersistedState({ - lanes: [ - { laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", worktreePath: "/tmp/wt-1", branch: "b1", taskIds: ["T1"] }, - { laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", worktreePath: "/tmp/wt-2", branch: "b2", taskIds: ["T2"] }, - ], - }); - const defaultRoot = "/repos/main"; - // No workspace config → repo mode - const roots = collectRepoRoots(state, defaultRoot, null); - assertEqual(roots.length, 1, "repo mode: only default root"); - assertEqual(roots[0], defaultRoot, "repo mode: root is default"); -} + { + console.log(" ▸ workspace v2: one repo lane alive + another dead → correct reconcile actions"); + const state = makeWorkspaceState(); + // WS-001 (api repo) has alive session, WS-002 (frontend repo) has dead session + const reconciled = reconcileTaskStates( + state, + new Set(["orch-lane-1"]), // only api lane alive + new Set(), // no .DONE files + ); + assertEqual(reconciled.length, 2, "workspace: 2 tasks reconciled"); -{ - console.log(" ▸ workspace v2: computeResumePoint with mixed-repo outcomes"); - const state = workspacePersistedState({ - wavePlan: [["WS-001", "WS-002"], ["WS-003"]], - tasks: [ - { - taskId: "WS-001", laneNumber: 1, sessionName: "orch-api-lane-1", - status: "running", taskFolder: "/tmp/tasks/WS-001", - startedAt: Date.now() - 60000, endedAt: null, - doneFileFound: false, exitReason: "", - repoId: "api", resolvedRepoId: "api", - }, - { - taskId: "WS-002", laneNumber: 2, sessionName: "orch-frontend-lane-2", - status: "running", taskFolder: "/tmp/tasks/WS-002", - startedAt: Date.now() - 60000, endedAt: null, - doneFileFound: false, exitReason: "", - repoId: "frontend", resolvedRepoId: "frontend", - }, - { - taskId: "WS-003", laneNumber: 1, sessionName: "orch-api-lane-1", - status: "pending", taskFolder: "/tmp/tasks/WS-003", - startedAt: null, endedAt: null, - doneFileFound: false, exitReason: "", - repoId: "api", resolvedRepoId: "api", - }, - ], - }); + const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); + assertEqual(ws001!.action, "reconnect", "workspace: WS-001 reconnect (alive session)"); + assertEqual(ws001!.sessionAlive, true, "workspace: WS-001 session alive"); - // WS-001 (api): .DONE found → mark-complete - // WS-002 (frontend): dead session → mark-failed - // WS-003 (api, wave 2): pending - const reconciled = reconcileTaskStates(state, new Set(), new Set(["WS-001"])); - const point = computeResumePoint(state, reconciled); - - // Wave 0: WS-001 mark-complete (done) + WS-002 mark-failed (NOT done for wave-skip) - assertEqual(point.resumeWaveIndex, 0, "resumes from wave 0 (mark-failed NOT done for wave-skip)"); - assert(point.completedTaskIds.includes("WS-001"), "WS-001 in completed"); - assert(point.failedTaskIds.includes("WS-002"), "WS-002 in failed"); - assert(point.failedTaskIds.includes("WS-003"), "WS-003 in failed (mark-failed: dead session + no DONE + no worktree)"); -} + const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); + assertEqual( + ws002!.action, + "mark-failed", + "workspace: WS-002 mark-failed (dead session, no .DONE, no worktree)", + ); + assertEqual(ws002!.liveStatus, "failed", "workspace: WS-002 live status is failed"); + } -{ - console.log(" ▸ workspace v2: both repo lanes alive → both reconnect"); - const state = workspacePersistedState(); - const aliveSessions = new Set(["orch-api-lane-1", "orch-frontend-lane-2"]); - const result = reconcileTaskStates(state, aliveSessions, new Set()); + { + console.log( + " ▸ workspace v2: .DONE in one repo + dead session in another → mark-complete vs mark-failed", + ); + const state = makeWorkspaceState(); + // WS-001 (api) completed (.DONE exists), WS-002 (frontend) dead session + const reconciled = reconcileTaskStates( + state, + new Set(), // no alive sessions + new Set(["WS-001"]), // WS-001 has .DONE + ); - assertEqual(result[0].action, "reconnect", "WS-001 (api): reconnect"); - assertEqual(result[1].action, "reconnect", "WS-002 (frontend): reconnect"); + const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); + assertEqual(ws001!.action, "mark-complete", "workspace: WS-001 mark-complete (.DONE found)"); + assertEqual(ws001!.doneFileFound, true, "workspace: WS-001 done file found"); + + const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); + assertEqual(ws002!.action, "mark-failed", "workspace: WS-002 mark-failed (dead, no .DONE)"); + + // Resume point should show correct categorization + const point = computeResumePoint(state, reconciled); + assert(point.completedTaskIds.includes("WS-001"), "workspace: WS-001 in completed"); + assert(point.failedTaskIds.includes("WS-002"), "workspace: WS-002 in failed"); + // Wave 0: WS-001 mark-complete (done) + WS-002 mark-failed (NOT done for wave-skip) + assertEqual( + point.resumeWaveIndex, + 0, + "workspace: resume from wave 0 (mark-failed NOT done for wave-skip)", + ); + } - const point = computeResumePoint(state, result); - assertEqual(point.reconnectTaskIds.length, 2, "both tasks need reconnection"); - assertEqual(point.resumeWaveIndex, 0, "resume from wave 0 (tasks still running)"); - assert(point.pendingTaskIds.includes("WS-001"), "WS-001 in pending (reconnect)"); - assert(point.pendingTaskIds.includes("WS-002"), "WS-002 in pending (reconnect)"); -} + { + console.log(" ▸ v1 state (no repo fields) reconciles correctly with all-undefined repo fields"); + // Simulate a v1-upconverted state: mode=repo, no repo fields on tasks/lanes + const v1State = minimalPersistedState({ + mode: "repo", + baseBranch: "", + wavePlan: [["T1", "T2"]], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-batch", + taskIds: ["T1", "T2"], + // No repoId — v1 behavior + }, + ], + tasks: [ + makeTaskRecord({ + taskId: "T1", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + }), + makeTaskRecord({ taskId: "T2", laneNumber: 1, sessionName: "orch-lane-1", status: "running" }), + ], + }); -{ - console.log(" ▸ workspace v2: all repos completed → resume past all waves"); - const state = workspacePersistedState({ - tasks: [ - { - taskId: "WS-001", laneNumber: 1, sessionName: "orch-api-lane-1", - status: "succeeded", taskFolder: "/tmp/tasks/WS-001", - startedAt: Date.now() - 60000, endedAt: Date.now() - 30000, - doneFileFound: true, exitReason: "", - repoId: "api", resolvedRepoId: "api", - }, - { - taskId: "WS-002", laneNumber: 2, sessionName: "orch-frontend-lane-2", - status: "succeeded", taskFolder: "/tmp/tasks/WS-002", - startedAt: Date.now() - 60000, endedAt: Date.now() - 30000, - doneFileFound: true, exitReason: "", - repoId: "frontend", resolvedRepoId: "frontend", - }, - ], - }); + // T1: succeeded → skip, T2: running + dead session → mark-failed + const reconciled = reconcileTaskStates(v1State, new Set(), new Set()); + const t1 = reconciled.find((r: any) => r.taskId === "T1"); + assertEqual(t1!.action, "skip", "v1: T1 skip (already succeeded)"); + const t2 = reconciled.find((r: any) => r.taskId === "T2"); + assertEqual(t2!.action, "mark-failed", "v1: T2 mark-failed (dead session)"); + + const point = computeResumePoint(v1State, reconciled); + // Wave 0: T1 skip/succeeded (done) + T2 mark-failed (NOT done for wave-skip) + assertEqual( + point.resumeWaveIndex, + 0, + "v1: resume from wave 0 (mark-failed NOT done for wave-skip)", + ); + assert(point.completedTaskIds.includes("T1"), "v1: T1 completed"); + assert(point.failedTaskIds.includes("T2"), "v1: T2 failed"); - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - const point = computeResumePoint(state, reconciled); + // Verify v1 lanes have no repoId + assertEqual(v1State.lanes[0].repoId, undefined, "v1: lane has no repoId"); + assertEqual(v1State.tasks[0].repoId, undefined, "v1: task has no repoId"); + } - assertEqual(point.resumeWaveIndex, 1, "resume past all waves (all done)"); - assertEqual(point.completedTaskIds.length, 2, "both tasks completed"); - assertEqual(point.failedTaskIds.length, 0, "no failed tasks"); -} + { + console.log( + " ▸ worktree exists vs missing split across repos → correct re-execute vs mark-failed", + ); + const state = makeWorkspaceState(); + // WS-001 (api): dead session + worktree exists → re-execute + // WS-002 (frontend): dead session + no worktree → mark-failed + const reconciled = reconcileTaskStates( + state, + new Set(), // no alive sessions + new Set(), // no .DONE files + new Set(["WS-001"]), // only WS-001 has worktree + ); -// ═══════════════════════════════════════════════════════════════════════ -// 8.1: Mixed-Repo Reconciliation (TP-007 Step 0) -// ═══════════════════════════════════════════════════════════════════════ + const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); + assertEqual(ws001!.action, "re-execute", "workspace: WS-001 re-execute (worktree exists)"); + assertEqual(ws001!.worktreeExists, true, "workspace: WS-001 worktree exists"); + assertEqual( + ws001!.liveStatus, + "pending", + "workspace: WS-001 live status pending (will be re-executed)", + ); -console.log("\n── 8.1: Mixed-repo reconciliation scenarios (TP-007) ──"); + const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); + assertEqual(ws002!.action, "mark-failed", "workspace: WS-002 mark-failed (no worktree)"); + assertEqual(ws002!.worktreeExists, false, "workspace: WS-002 no worktree"); -// Reimplement resolveRepoRoot for section 8.1 self-containment (mirrors source exactly). -// Renamed from `resolveRepoRoot` to avoid clashing with the section-7 helper of the same name -// (Biome lint/suspicious/noRedeclare). Bodies are functionally identical. -function resolveRepoRootMixedRepo( - repoId: string | undefined, - defaultRepoRoot: string, - workspaceConfig?: { repos: Map } | null, -): string { - if (!repoId || !workspaceConfig) { - return defaultRepoRoot; + const point = computeResumePoint(state, reconciled); + assert(point.reExecuteTaskIds.includes("WS-001"), "workspace: WS-001 in re-execute list"); + assert(point.failedTaskIds.includes("WS-002"), "workspace: WS-002 in failed list"); + assertEqual(point.resumeWaveIndex, 0, "workspace: resume from wave 0"); } - const repoConfig = workspaceConfig.repos.get(repoId); - if (!repoConfig) { - return defaultRepoRoot; - } - return repoConfig.path; -} - -// Helper: build a workspace-mode persisted state with multi-repo lanes -function makeWorkspaceState(overrides: Partial = {}): any { - return minimalPersistedState({ - mode: "workspace", - baseBranch: "main", - wavePlan: [["WS-001", "WS-002"]], - lanes: [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", branch: "task/lane-1-batch", - taskIds: ["WS-001"], repoId: "api", - }, - { - laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", - worktreePath: "/tmp/wt-2", branch: "task/lane-2-batch", - taskIds: ["WS-002"], repoId: "frontend", - }, - ], - tasks: [ - makeTaskRecord({ - taskId: "WS-001", laneNumber: 1, sessionName: "orch-lane-1", - status: "running", taskFolder: "/tmp/tasks/WS-001", - repoId: "api", resolvedRepoId: "api", - }), - makeTaskRecord({ - taskId: "WS-002", laneNumber: 2, sessionName: "orch-lane-2", - status: "running", taskFolder: "/tmp/tasks/WS-002", - resolvedRepoId: "frontend", - }), - ], - ...overrides, - }); -} - -// Workspace config for resolveRepoRoot tests -const testWorkspaceConfig = { - repos: new Map([ - ["api", { path: "/repos/api", defaultBranch: "main" }], - ["frontend", { path: "/repos/frontend", defaultBranch: "develop" }], - ]), -}; - -{ - console.log(" ▸ workspace v2: one repo lane alive + another dead → correct reconcile actions"); - const state = makeWorkspaceState(); - // WS-001 (api repo) has alive session, WS-002 (frontend repo) has dead session - const reconciled = reconcileTaskStates( - state, - new Set(["orch-lane-1"]), // only api lane alive - new Set(), // no .DONE files - ); - assertEqual(reconciled.length, 2, "workspace: 2 tasks reconciled"); - - const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); - assertEqual(ws001!.action, "reconnect", "workspace: WS-001 reconnect (alive session)"); - assertEqual(ws001!.sessionAlive, true, "workspace: WS-001 session alive"); - - const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); - assertEqual(ws002!.action, "mark-failed", "workspace: WS-002 mark-failed (dead session, no .DONE, no worktree)"); - assertEqual(ws002!.liveStatus, "failed", "workspace: WS-002 live status is failed"); -} - -{ - console.log(" ▸ workspace v2: .DONE in one repo + dead session in another → mark-complete vs mark-failed"); - const state = makeWorkspaceState(); - // WS-001 (api) completed (.DONE exists), WS-002 (frontend) dead session - const reconciled = reconcileTaskStates( - state, - new Set(), // no alive sessions - new Set(["WS-001"]), // WS-001 has .DONE - ); - - const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); - assertEqual(ws001!.action, "mark-complete", "workspace: WS-001 mark-complete (.DONE found)"); - assertEqual(ws001!.doneFileFound, true, "workspace: WS-001 done file found"); - - const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); - assertEqual(ws002!.action, "mark-failed", "workspace: WS-002 mark-failed (dead, no .DONE)"); - - // Resume point should show correct categorization - const point = computeResumePoint(state, reconciled); - assert(point.completedTaskIds.includes("WS-001"), "workspace: WS-001 in completed"); - assert(point.failedTaskIds.includes("WS-002"), "workspace: WS-002 in failed"); - // Wave 0: WS-001 mark-complete (done) + WS-002 mark-failed (NOT done for wave-skip) - assertEqual(point.resumeWaveIndex, 0, "workspace: resume from wave 0 (mark-failed NOT done for wave-skip)"); -} -{ - console.log(" ▸ v1 state (no repo fields) reconciles correctly with all-undefined repo fields"); - // Simulate a v1-upconverted state: mode=repo, no repo fields on tasks/lanes - const v1State = minimalPersistedState({ - mode: "repo", - baseBranch: "", - wavePlan: [["T1", "T2"]], - lanes: [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", branch: "task/lane-1-batch", - taskIds: ["T1", "T2"], - // No repoId — v1 behavior - }, - ], - tasks: [ - makeTaskRecord({ taskId: "T1", laneNumber: 1, sessionName: "orch-lane-1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", laneNumber: 1, sessionName: "orch-lane-1", status: "running" }), - ], - }); + { + console.log( + " ▸ resolveRepoRoot integration: v2 lanes get correct repo root, v1/undefined lanes get default root", + ); - // T1: succeeded → skip, T2: running + dead session → mark-failed - const reconciled = reconcileTaskStates(v1State, new Set(), new Set()); - const t1 = reconciled.find((r: any) => r.taskId === "T1"); - assertEqual(t1!.action, "skip", "v1: T1 skip (already succeeded)"); - const t2 = reconciled.find((r: any) => r.taskId === "T2"); - assertEqual(t2!.action, "mark-failed", "v1: T2 mark-failed (dead session)"); - - const point = computeResumePoint(v1State, reconciled); - // Wave 0: T1 skip/succeeded (done) + T2 mark-failed (NOT done for wave-skip) - assertEqual(point.resumeWaveIndex, 0, "v1: resume from wave 0 (mark-failed NOT done for wave-skip)"); - assert(point.completedTaskIds.includes("T1"), "v1: T1 completed"); - assert(point.failedTaskIds.includes("T2"), "v1: T2 failed"); - - // Verify v1 lanes have no repoId - assertEqual(v1State.lanes[0].repoId, undefined, "v1: lane has no repoId"); - assertEqual(v1State.tasks[0].repoId, undefined, "v1: task has no repoId"); -} + const defaultRoot = "/default/repo"; -{ - console.log(" ▸ worktree exists vs missing split across repos → correct re-execute vs mark-failed"); - const state = makeWorkspaceState(); - // WS-001 (api): dead session + worktree exists → re-execute - // WS-002 (frontend): dead session + no worktree → mark-failed - const reconciled = reconcileTaskStates( - state, - new Set(), // no alive sessions - new Set(), // no .DONE files - new Set(["WS-001"]), // only WS-001 has worktree - ); - - const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); - assertEqual(ws001!.action, "re-execute", "workspace: WS-001 re-execute (worktree exists)"); - assertEqual(ws001!.worktreeExists, true, "workspace: WS-001 worktree exists"); - assertEqual(ws001!.liveStatus, "pending", "workspace: WS-001 live status pending (will be re-executed)"); - - const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); - assertEqual(ws002!.action, "mark-failed", "workspace: WS-002 mark-failed (no worktree)"); - assertEqual(ws002!.worktreeExists, false, "workspace: WS-002 no worktree"); - - const point = computeResumePoint(state, reconciled); - assert(point.reExecuteTaskIds.includes("WS-001"), "workspace: WS-001 in re-execute list"); - assert(point.failedTaskIds.includes("WS-002"), "workspace: WS-002 in failed list"); - assertEqual(point.resumeWaveIndex, 0, "workspace: resume from wave 0"); -} + // v2 workspace: lane with repoId="api" → resolves to /repos/api + const apiRoot = resolveRepoRootMixedRepo("api", defaultRoot, testWorkspaceConfig); + assertEqual(apiRoot, "/repos/api", "resolveRepoRoot: api → /repos/api"); -{ - console.log(" ▸ resolveRepoRoot integration: v2 lanes get correct repo root, v1/undefined lanes get default root"); + const frontendRoot = resolveRepoRootMixedRepo("frontend", defaultRoot, testWorkspaceConfig); + assertEqual(frontendRoot, "/repos/frontend", "resolveRepoRoot: frontend → /repos/frontend"); - const defaultRoot = "/default/repo"; + // v1/repo mode: undefined repoId → returns default root + const undefinedRoot = resolveRepoRootMixedRepo(undefined, defaultRoot, testWorkspaceConfig); + assertEqual(undefinedRoot, defaultRoot, "resolveRepoRoot: undefined → default root"); - // v2 workspace: lane with repoId="api" → resolves to /repos/api - const apiRoot = resolveRepoRootMixedRepo("api", defaultRoot, testWorkspaceConfig); - assertEqual(apiRoot, "/repos/api", "resolveRepoRoot: api → /repos/api"); + // v1/repo mode: no workspace config → returns default root + const noConfigRoot = resolveRepoRootMixedRepo("api", defaultRoot, null); + assertEqual(noConfigRoot, defaultRoot, "resolveRepoRoot: null config → default root"); - const frontendRoot = resolveRepoRootMixedRepo("frontend", defaultRoot, testWorkspaceConfig); - assertEqual(frontendRoot, "/repos/frontend", "resolveRepoRoot: frontend → /repos/frontend"); + // v1/repo mode: empty string repoId → returns default root (falsy check) + const emptyRoot = resolveRepoRootMixedRepo("", defaultRoot, testWorkspaceConfig); + assertEqual(emptyRoot, defaultRoot, "resolveRepoRoot: empty string → default root"); - // v1/repo mode: undefined repoId → returns default root - const undefinedRoot = resolveRepoRootMixedRepo(undefined, defaultRoot, testWorkspaceConfig); - assertEqual(undefinedRoot, defaultRoot, "resolveRepoRoot: undefined → default root"); + // Unknown repoId → defensive fallback to default root + const unknownRoot = resolveRepoRootMixedRepo("unknown-repo", defaultRoot, testWorkspaceConfig); + assertEqual(unknownRoot, defaultRoot, "resolveRepoRoot: unknown repo → default root"); + } - // v1/repo mode: no workspace config → returns default root - const noConfigRoot = resolveRepoRootMixedRepo("api", defaultRoot, null); - assertEqual(noConfigRoot, defaultRoot, "resolveRepoRoot: null config → default root"); + { + console.log(" ▸ workspace v2: multi-wave with cross-repo completion states"); + // Wave 0: WS-001 (api) + WS-002 (frontend), both completed + // Wave 1: WS-003 (api) running, WS-004 (frontend) pending + const state = minimalPersistedState({ + mode: "workspace", + baseBranch: "main", + wavePlan: [ + ["WS-001", "WS-002"], + ["WS-003", "WS-004"], + ], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-batch", + taskIds: ["WS-001", "WS-003"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "task/lane-2-batch", + taskIds: ["WS-002", "WS-004"], + repoId: "frontend", + }, + ], + tasks: [ + makeTaskRecord({ + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + repoId: "api", + resolvedRepoId: "api", + }), + makeTaskRecord({ + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-lane-2", + status: "succeeded", + resolvedRepoId: "frontend", + }), + makeTaskRecord({ + taskId: "WS-003", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + repoId: "api", + resolvedRepoId: "api", + }), + makeTaskRecord({ + taskId: "WS-004", + laneNumber: 2, + sessionName: "orch-lane-2", + status: "pending", + resolvedRepoId: "frontend", + }), + ], + }); - // v1/repo mode: empty string repoId → returns default root (falsy check) - const emptyRoot = resolveRepoRootMixedRepo("", defaultRoot, testWorkspaceConfig); - assertEqual(emptyRoot, defaultRoot, "resolveRepoRoot: empty string → default root"); + // WS-001 and WS-002 done, WS-003 has alive session, WS-004 dead + const reconciled = reconcileTaskStates( + state, + new Set(["orch-lane-1"]), // WS-003's lane is alive + new Set(["WS-001", "WS-002"]), // wave 0 tasks have .DONE + ); - // Unknown repoId → defensive fallback to default root - const unknownRoot = resolveRepoRootMixedRepo("unknown-repo", defaultRoot, testWorkspaceConfig); - assertEqual(unknownRoot, defaultRoot, "resolveRepoRoot: unknown repo → default root"); -} + // Wave 0 should be fully done + const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); + const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); + assertEqual(ws001!.action, "mark-complete", "multi-wave: WS-001 mark-complete"); + assertEqual(ws002!.action, "mark-complete", "multi-wave: WS-002 mark-complete"); -{ - console.log(" ▸ workspace v2: multi-wave with cross-repo completion states"); - // Wave 0: WS-001 (api) + WS-002 (frontend), both completed - // Wave 1: WS-003 (api) running, WS-004 (frontend) pending - const state = minimalPersistedState({ - mode: "workspace", - baseBranch: "main", - wavePlan: [["WS-001", "WS-002"], ["WS-003", "WS-004"]], - lanes: [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", branch: "task/lane-1-batch", - taskIds: ["WS-001", "WS-003"], repoId: "api", - }, - { - laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", - worktreePath: "/tmp/wt-2", branch: "task/lane-2-batch", - taskIds: ["WS-002", "WS-004"], repoId: "frontend", - }, - ], - tasks: [ - makeTaskRecord({ taskId: "WS-001", laneNumber: 1, sessionName: "orch-lane-1", status: "succeeded", repoId: "api", resolvedRepoId: "api" }), - makeTaskRecord({ taskId: "WS-002", laneNumber: 2, sessionName: "orch-lane-2", status: "succeeded", resolvedRepoId: "frontend" }), - makeTaskRecord({ taskId: "WS-003", laneNumber: 1, sessionName: "orch-lane-1", status: "running", repoId: "api", resolvedRepoId: "api" }), - makeTaskRecord({ taskId: "WS-004", laneNumber: 2, sessionName: "orch-lane-2", status: "pending", resolvedRepoId: "frontend" }), - ], - }); + // Wave 1: WS-003 reconnect, WS-004 mark-failed + const ws003 = reconciled.find((r: any) => r.taskId === "WS-003"); + const ws004 = reconciled.find((r: any) => r.taskId === "WS-004"); + assertEqual(ws003!.action, "reconnect", "multi-wave: WS-003 reconnect"); + assertEqual(ws004!.action, "mark-failed", "multi-wave: WS-004 mark-failed"); - // WS-001 and WS-002 done, WS-003 has alive session, WS-004 dead - const reconciled = reconcileTaskStates( - state, - new Set(["orch-lane-1"]), // WS-003's lane is alive - new Set(["WS-001", "WS-002"]), // wave 0 tasks have .DONE - ); - - // Wave 0 should be fully done - const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); - const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); - assertEqual(ws001!.action, "mark-complete", "multi-wave: WS-001 mark-complete"); - assertEqual(ws002!.action, "mark-complete", "multi-wave: WS-002 mark-complete"); - - // Wave 1: WS-003 reconnect, WS-004 mark-failed - const ws003 = reconciled.find((r: any) => r.taskId === "WS-003"); - const ws004 = reconciled.find((r: any) => r.taskId === "WS-004"); - assertEqual(ws003!.action, "reconnect", "multi-wave: WS-003 reconnect"); - assertEqual(ws004!.action, "mark-failed", "multi-wave: WS-004 mark-failed"); - - const point = computeResumePoint(state, reconciled); - assertEqual(point.resumeWaveIndex, 1, "multi-wave: skips wave 0 (all done), resumes at wave 1"); - assertEqual(point.completedTaskIds.length, 2, "multi-wave: 2 completed"); - assertEqual(point.reconnectTaskIds.length, 1, "multi-wave: 1 reconnect (WS-003)"); - assertEqual(point.failedTaskIds.length, 1, "multi-wave: 1 failed (WS-004)"); - assert(point.reconnectTaskIds.includes("WS-003"), "multi-wave: WS-003 in reconnect"); - assert(point.failedTaskIds.includes("WS-004"), "multi-wave: WS-004 in failed"); -} + const point = computeResumePoint(state, reconciled); + assertEqual(point.resumeWaveIndex, 1, "multi-wave: skips wave 0 (all done), resumes at wave 1"); + assertEqual(point.completedTaskIds.length, 2, "multi-wave: 2 completed"); + assertEqual(point.reconnectTaskIds.length, 1, "multi-wave: 1 reconnect (WS-003)"); + assertEqual(point.failedTaskIds.length, 1, "multi-wave: 1 failed (WS-004)"); + assert(point.reconnectTaskIds.includes("WS-003"), "multi-wave: WS-003 in reconnect"); + assert(point.failedTaskIds.includes("WS-004"), "multi-wave: WS-004 in failed"); + } -{ - console.log(" ▸ workspace v2: all repos' tasks completed → resume wave past end"); - const state = minimalPersistedState({ - mode: "workspace", - baseBranch: "main", - wavePlan: [["WS-001", "WS-002"]], - lanes: [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", branch: "task/lane-1-batch", - taskIds: ["WS-001"], repoId: "api", - }, - { - laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", - worktreePath: "/tmp/wt-2", branch: "task/lane-2-batch", - taskIds: ["WS-002"], repoId: "frontend", - }, - ], - tasks: [ - makeTaskRecord({ taskId: "WS-001", laneNumber: 1, sessionName: "orch-lane-1", status: "succeeded", repoId: "api" }), - makeTaskRecord({ taskId: "WS-002", laneNumber: 2, sessionName: "orch-lane-2", status: "succeeded", resolvedRepoId: "frontend" }), - ], - }); + { + console.log(" ▸ workspace v2: all repos' tasks completed → resume wave past end"); + const state = minimalPersistedState({ + mode: "workspace", + baseBranch: "main", + wavePlan: [["WS-001", "WS-002"]], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-batch", + taskIds: ["WS-001"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "task/lane-2-batch", + taskIds: ["WS-002"], + repoId: "frontend", + }, + ], + tasks: [ + makeTaskRecord({ + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + repoId: "api", + }), + makeTaskRecord({ + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-lane-2", + status: "succeeded", + resolvedRepoId: "frontend", + }), + ], + }); - const reconciled = reconcileTaskStates(state, new Set(), new Set(["WS-001", "WS-002"])); - const point = computeResumePoint(state, reconciled); - assertEqual(point.resumeWaveIndex, 1, "all done: resume wave past end (wavePlan.length)"); - assertEqual(point.completedTaskIds.length, 2, "all done: both tasks completed"); - assertEqual(point.failedTaskIds.length, 0, "all done: no failures"); - assertEqual(point.pendingTaskIds.length, 0, "all done: no pending"); -} + const reconciled = reconcileTaskStates(state, new Set(), new Set(["WS-001", "WS-002"])); + const point = computeResumePoint(state, reconciled); + assertEqual(point.resumeWaveIndex, 1, "all done: resume wave past end (wavePlan.length)"); + assertEqual(point.completedTaskIds.length, 2, "all done: both tasks completed"); + assertEqual(point.failedTaskIds.length, 0, "all done: no failures"); + assertEqual(point.pendingTaskIds.length, 0, "all done: no pending"); + } -{ - console.log(" ▸ unique repo roots collected from persisted lanes (for worktree reset/cleanup)"); - // Simulate the per-repo root collection logic used in resumeOrchBatch - const persistedLanes = [ - { repoId: "api" }, - { repoId: "frontend" }, - { repoId: "api" }, // duplicate - { repoId: undefined }, // v1/repo-mode lane - ]; - const defaultRoot = "/default/repo"; - - const uniqueRoots = new Set(); - for (const lr of persistedLanes) { - uniqueRoots.add(resolveRepoRootMixedRepo(lr.repoId, defaultRoot, testWorkspaceConfig)); - } - - assertEqual(uniqueRoots.size, 3, "unique roots: 3 distinct roots (api, frontend, default)"); - assert(uniqueRoots.has("/repos/api"), "unique roots: includes api root"); - assert(uniqueRoots.has("/repos/frontend"), "unique roots: includes frontend root"); - assert(uniqueRoots.has(defaultRoot), "unique roots: includes default root (v1/undefined lane)"); -} + { + console.log(" ▸ unique repo roots collected from persisted lanes (for worktree reset/cleanup)"); + // Simulate the per-repo root collection logic used in resumeOrchBatch + const persistedLanes = [ + { repoId: "api" }, + { repoId: "frontend" }, + { repoId: "api" }, // duplicate + { repoId: undefined }, // v1/repo-mode lane + ]; + const defaultRoot = "/default/repo"; -{ - console.log(" ▸ v1 state with zero lanes: fallback adds default repo root"); - // Edge case: v1 state with no lanes persisted (very early crash) - const emptyLanesState = minimalPersistedState({ - mode: "repo", - lanes: [], - tasks: [], - wavePlan: [], - }); - const defaultRoot = "/default/repo"; + const uniqueRoots = new Set(); + for (const lr of persistedLanes) { + uniqueRoots.add(resolveRepoRootMixedRepo(lr.repoId, defaultRoot, testWorkspaceConfig)); + } - const uniqueRoots = new Set(); - for (const lr of emptyLanesState.lanes) { - uniqueRoots.add(resolveRepoRootMixedRepo(lr.repoId, defaultRoot, null)); + assertEqual(uniqueRoots.size, 3, "unique roots: 3 distinct roots (api, frontend, default)"); + assert(uniqueRoots.has("/repos/api"), "unique roots: includes api root"); + assert(uniqueRoots.has("/repos/frontend"), "unique roots: includes frontend root"); + assert(uniqueRoots.has(defaultRoot), "unique roots: includes default root (v1/undefined lane)"); } - if (uniqueRoots.size === 0) { - uniqueRoots.add(defaultRoot); - } - - assertEqual(uniqueRoots.size, 1, "empty lanes fallback: 1 root"); - assert(uniqueRoots.has(defaultRoot), "empty lanes fallback: default root used"); -} - -// ═══════════════════════════════════════════════════════════════════════ -// 4.7: Step 1 — Blocked propagation, skipped semantics, counter stability -// ═══════════════════════════════════════════════════════════════════════ -console.log("\n── 4.7: Step 1 — blocked propagation & skipped semantics ──"); + { + console.log(" ▸ v1 state with zero lanes: fallback adds default repo root"); + // Edge case: v1 state with no lanes persisted (very early crash) + const emptyLanesState = minimalPersistedState({ + mode: "repo", + lanes: [], + tasks: [], + wavePlan: [], + }); + const defaultRoot = "/default/repo"; -// Helper: build a simple dependency graph for testing blocked propagation -function buildTestDepGraph( - deps: Record, -): { dependencies: Map; dependents: Map; nodes: Set } { - const dependencies = new Map(); - const dependents = new Map(); - const nodes = new Set(); + const uniqueRoots = new Set(); + for (const lr of emptyLanesState.lanes) { + uniqueRoots.add(resolveRepoRootMixedRepo(lr.repoId, defaultRoot, null)); + } + if (uniqueRoots.size === 0) { + uniqueRoots.add(defaultRoot); + } - for (const [taskId, taskDeps] of Object.entries(deps)) { - nodes.add(taskId); - dependencies.set(taskId, taskDeps); - if (!dependents.has(taskId)) dependents.set(taskId, []); - for (const dep of taskDeps) { - nodes.add(dep); - if (!dependencies.has(dep)) dependencies.set(dep, []); - if (!dependents.has(dep)) dependents.set(dep, []); - dependents.get(dep)!.push(taskId); + assertEqual(uniqueRoots.size, 1, "empty lanes fallback: 1 root"); + assert(uniqueRoots.has(defaultRoot), "empty lanes fallback: default root used"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // 4.7: Step 1 — Blocked propagation, skipped semantics, counter stability + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── 4.7: Step 1 — blocked propagation & skipped semantics ──"); + + // Helper: build a simple dependency graph for testing blocked propagation + function buildTestDepGraph(deps: Record): { + dependencies: Map; + dependents: Map; + nodes: Set; + } { + const dependencies = new Map(); + const dependents = new Map(); + const nodes = new Set(); + + for (const [taskId, taskDeps] of Object.entries(deps)) { + nodes.add(taskId); + dependencies.set(taskId, taskDeps); + if (!dependents.has(taskId)) dependents.set(taskId, []); + for (const dep of taskDeps) { + nodes.add(dep); + if (!dependencies.has(dep)) dependencies.set(dep, []); + if (!dependents.has(dep)) dependents.set(dep, []); + dependents.get(dep)!.push(taskId); + } } - } - return { dependencies, dependents, nodes }; -} + return { dependencies, dependents, nodes }; + } -// Reimplement computeTransitiveDependents (mirrors execution.ts exactly) -function computeTransitiveDependents( - failedTaskIds: Set, - dependencyGraph: { dependents: Map }, -): Set { - const blocked = new Set(); - const queue = [...failedTaskIds]; + // Reimplement computeTransitiveDependents (mirrors execution.ts exactly) + function computeTransitiveDependents( + failedTaskIds: Set, + dependencyGraph: { dependents: Map }, + ): Set { + const blocked = new Set(); + const queue = [...failedTaskIds]; - while (queue.length > 0) { - const current = queue.shift()!; - const deps = dependencyGraph.dependents.get(current) || []; - const sortedDeps = [...deps].sort(); + while (queue.length > 0) { + const current = queue.shift()!; + const deps = dependencyGraph.dependents.get(current) || []; + const sortedDeps = [...deps].sort(); - for (const dep of sortedDeps) { - if (blocked.has(dep)) continue; - if (failedTaskIds.has(dep)) continue; - blocked.add(dep); - queue.push(dep); + for (const dep of sortedDeps) { + if (blocked.has(dep)) continue; + if (failedTaskIds.has(dep)) continue; + blocked.add(dep); + queue.push(dep); + } } - } - return blocked; -} + return blocked; + } -{ - console.log(" ▸ reconciled failure in repo A blocks dependent in repo B under skip-dependents"); - // Scenario: workspace mode, 2 waves - // Wave 0: WS-001 (api) fails on reconciliation, WS-002 (frontend) succeeds - // Wave 1: WS-003 (api) depends on WS-001, WS-004 (frontend) depends on WS-002 - // Under skip-dependents: WS-003 should be blocked, WS-004 should still execute - - const depGraph = buildTestDepGraph({ - "WS-001": [], - "WS-002": [], - "WS-003": ["WS-001"], // WS-003 depends on WS-001 - "WS-004": ["WS-002"], // WS-004 depends on WS-002 - }); + { + console.log(" ▸ reconciled failure in repo A blocks dependent in repo B under skip-dependents"); + // Scenario: workspace mode, 2 waves + // Wave 0: WS-001 (api) fails on reconciliation, WS-002 (frontend) succeeds + // Wave 1: WS-003 (api) depends on WS-001, WS-004 (frontend) depends on WS-002 + // Under skip-dependents: WS-003 should be blocked, WS-004 should still execute + + const depGraph = buildTestDepGraph({ + "WS-001": [], + "WS-002": [], + "WS-003": ["WS-001"], // WS-003 depends on WS-001 + "WS-004": ["WS-002"], // WS-004 depends on WS-002 + }); - const state = minimalPersistedState({ - mode: "workspace", - wavePlan: [["WS-001", "WS-002"], ["WS-003", "WS-004"]], - blockedTaskIds: [], - lanes: [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", branch: "task/lane-1-batch", - taskIds: ["WS-001", "WS-003"], repoId: "api", - }, - { - laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", - worktreePath: "/tmp/wt-2", branch: "task/lane-2-batch", - taskIds: ["WS-002", "WS-004"], repoId: "frontend", - }, - ], - tasks: [ - makeTaskRecord({ taskId: "WS-001", laneNumber: 1, sessionName: "orch-lane-1", status: "running", repoId: "api" }), - makeTaskRecord({ taskId: "WS-002", laneNumber: 2, sessionName: "orch-lane-2", status: "succeeded", resolvedRepoId: "frontend" }), - // Wave 2 tasks: never started (no session assigned) → action: "pending" - makeTaskRecord({ taskId: "WS-003", laneNumber: 0, sessionName: "", status: "pending", repoId: "api" }), - makeTaskRecord({ taskId: "WS-004", laneNumber: 0, sessionName: "", status: "pending", resolvedRepoId: "frontend" }), - ], - }); + const state = minimalPersistedState({ + mode: "workspace", + wavePlan: [ + ["WS-001", "WS-002"], + ["WS-003", "WS-004"], + ], + blockedTaskIds: [], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-batch", + taskIds: ["WS-001", "WS-003"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "task/lane-2-batch", + taskIds: ["WS-002", "WS-004"], + repoId: "frontend", + }, + ], + tasks: [ + makeTaskRecord({ + taskId: "WS-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + repoId: "api", + }), + makeTaskRecord({ + taskId: "WS-002", + laneNumber: 2, + sessionName: "orch-lane-2", + status: "succeeded", + resolvedRepoId: "frontend", + }), + // Wave 2 tasks: never started (no session assigned) → action: "pending" + makeTaskRecord({ + taskId: "WS-003", + laneNumber: 0, + sessionName: "", + status: "pending", + repoId: "api", + }), + makeTaskRecord({ + taskId: "WS-004", + laneNumber: 0, + sessionName: "", + status: "pending", + resolvedRepoId: "frontend", + }), + ], + }); - // WS-001: dead session, no .DONE, no worktree → mark-failed - // WS-002: .DONE exists → mark-complete - // WS-003, WS-004: pending + no session → action: "pending" - const reconciled = reconcileTaskStates(state, new Set(), new Set(["WS-002"])); - - const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); - const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); - const ws003 = reconciled.find((r: any) => r.taskId === "WS-003"); - const ws004 = reconciled.find((r: any) => r.taskId === "WS-004"); - assertEqual(ws001!.action, "mark-failed", "cross-repo blocked: WS-001 mark-failed"); - assertEqual(ws002!.action, "mark-complete", "cross-repo blocked: WS-002 mark-complete"); - assertEqual(ws003!.action, "pending", "cross-repo blocked: WS-003 pending (never started)"); - assertEqual(ws004!.action, "pending", "cross-repo blocked: WS-004 pending (never started)"); - - const point = computeResumePoint(state, reconciled); - assertEqual(point.failedTaskIds.length, 1, "cross-repo blocked: 1 failed (WS-001)"); - assert(point.failedTaskIds.includes("WS-001"), "cross-repo blocked: WS-001 in failed"); - - // Now simulate what resumeOrchBatch does: compute transitive dependents from failures - const failedSet = new Set(point.failedTaskIds); - const blocked = computeTransitiveDependents(failedSet, depGraph); - - assertEqual(blocked.size, 1, "cross-repo blocked: 1 task blocked (WS-003)"); - assert(blocked.has("WS-003"), "cross-repo blocked: WS-003 blocked (depends on failed WS-001)"); - assert(!blocked.has("WS-004"), "cross-repo blocked: WS-004 NOT blocked (WS-002 succeeded)"); - - // Verify wave 1 execution filter: WS-003 blocked, WS-004 eligible - const blockedTaskIds = new Set([...state.blockedTaskIds, ...blocked]); - const completedSet = new Set(point.completedTaskIds); - const wave1Tasks = state.wavePlan[1].filter( - (taskId: string) => !completedSet.has(taskId) && !failedSet.has(taskId) && !blockedTaskIds.has(taskId), - ); - assertEqual(wave1Tasks.length, 1, "cross-repo blocked: 1 task eligible in wave 1"); - assertEqual(wave1Tasks[0], "WS-004", "cross-repo blocked: WS-004 is the eligible task"); -} + // WS-001: dead session, no .DONE, no worktree → mark-failed + // WS-002: .DONE exists → mark-complete + // WS-003, WS-004: pending + no session → action: "pending" + const reconciled = reconcileTaskStates(state, new Set(), new Set(["WS-002"])); + + const ws001 = reconciled.find((r: any) => r.taskId === "WS-001"); + const ws002 = reconciled.find((r: any) => r.taskId === "WS-002"); + const ws003 = reconciled.find((r: any) => r.taskId === "WS-003"); + const ws004 = reconciled.find((r: any) => r.taskId === "WS-004"); + assertEqual(ws001!.action, "mark-failed", "cross-repo blocked: WS-001 mark-failed"); + assertEqual(ws002!.action, "mark-complete", "cross-repo blocked: WS-002 mark-complete"); + assertEqual(ws003!.action, "pending", "cross-repo blocked: WS-003 pending (never started)"); + assertEqual(ws004!.action, "pending", "cross-repo blocked: WS-004 pending (never started)"); + + const point = computeResumePoint(state, reconciled); + assertEqual(point.failedTaskIds.length, 1, "cross-repo blocked: 1 failed (WS-001)"); + assert(point.failedTaskIds.includes("WS-001"), "cross-repo blocked: WS-001 in failed"); + + // Now simulate what resumeOrchBatch does: compute transitive dependents from failures + const failedSet = new Set(point.failedTaskIds); + const blocked = computeTransitiveDependents(failedSet, depGraph); + + assertEqual(blocked.size, 1, "cross-repo blocked: 1 task blocked (WS-003)"); + assert(blocked.has("WS-003"), "cross-repo blocked: WS-003 blocked (depends on failed WS-001)"); + assert(!blocked.has("WS-004"), "cross-repo blocked: WS-004 NOT blocked (WS-002 succeeded)"); + + // Verify wave 1 execution filter: WS-003 blocked, WS-004 eligible + const blockedTaskIds = new Set([...state.blockedTaskIds, ...blocked]); + const completedSet = new Set(point.completedTaskIds); + const wave1Tasks = state.wavePlan[1].filter( + (taskId: string) => + !completedSet.has(taskId) && !failedSet.has(taskId) && !blockedTaskIds.has(taskId), + ); + assertEqual(wave1Tasks.length, 1, "cross-repo blocked: 1 task eligible in wave 1"); + assertEqual(wave1Tasks[0], "WS-004", "cross-repo blocked: WS-004 is the eligible task"); + } -{ - console.log(" ▸ persisted skipped tasks are not re-queued and wave is skipped over"); - const state = minimalPersistedState({ - wavePlan: [["T1", "T2"], ["T3"]], - skippedTasks: 1, - tasks: [ - makeTaskRecord({ taskId: "T1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", status: "skipped" }), - // T3 is a future-wave task that was never allocated - makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "" }), - ], - }); + { + console.log(" ▸ persisted skipped tasks are not re-queued and wave is skipped over"); + const state = minimalPersistedState({ + wavePlan: [["T1", "T2"], ["T3"]], + skippedTasks: 1, + tasks: [ + makeTaskRecord({ taskId: "T1", status: "succeeded" }), + makeTaskRecord({ taskId: "T2", status: "skipped" }), + // T3 is a future-wave task that was never allocated + makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "" }), + ], + }); - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - // T1: succeeded → skip(succeeded) - // T2: skipped → skip(skipped) - // T3: pending + no session → action: "pending" (future-wave, not failed) + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + // T1: succeeded → skip(succeeded) + // T2: skipped → skip(skipped) + // T3: pending + no session → action: "pending" (future-wave, not failed) - const t1 = reconciled.find((r: any) => r.taskId === "T1"); - const t2 = reconciled.find((r: any) => r.taskId === "T2"); - assertEqual(t1!.action, "skip", "skipped-wave: T1 skip (succeeded)"); - assertEqual(t2!.action, "skip", "skipped-wave: T2 skip (skipped)"); - assertEqual(t2!.persistedStatus, "skipped", "skipped-wave: T2 persisted status is skipped"); + const t1 = reconciled.find((r: any) => r.taskId === "T1"); + const t2 = reconciled.find((r: any) => r.taskId === "T2"); + assertEqual(t1!.action, "skip", "skipped-wave: T1 skip (succeeded)"); + assertEqual(t2!.action, "skip", "skipped-wave: T2 skip (skipped)"); + assertEqual(t2!.persistedStatus, "skipped", "skipped-wave: T2 persisted status is skipped"); - const point = computeResumePoint(state, reconciled); + const point = computeResumePoint(state, reconciled); - // Wave 0 should be skipped: T1 is succeeded (terminal), T2 is skipped (terminal) - assertEqual(point.resumeWaveIndex, 1, "skipped-wave: wave 0 skipped (all terminal)"); + // Wave 0 should be skipped: T1 is succeeded (terminal), T2 is skipped (terminal) + assertEqual(point.resumeWaveIndex, 1, "skipped-wave: wave 0 skipped (all terminal)"); - // T2 should NOT be in completedTaskIds or failedTaskIds or pendingTaskIds - assert(!point.completedTaskIds.includes("T2"), "skipped-wave: T2 not in completed"); - assert(!point.failedTaskIds.includes("T2"), "skipped-wave: T2 not in failed"); - assert(!point.pendingTaskIds.includes("T2"), "skipped-wave: T2 not re-queued as pending"); + // T2 should NOT be in completedTaskIds or failedTaskIds or pendingTaskIds + assert(!point.completedTaskIds.includes("T2"), "skipped-wave: T2 not in completed"); + assert(!point.failedTaskIds.includes("T2"), "skipped-wave: T2 not in failed"); + assert(!point.pendingTaskIds.includes("T2"), "skipped-wave: T2 not re-queued as pending"); - // T1 should be in completed - assert(point.completedTaskIds.includes("T1"), "skipped-wave: T1 in completed"); -} + // T1 should be in completed + assert(point.completedTaskIds.includes("T1"), "skipped-wave: T1 in completed"); + } -{ - console.log(" ▸ wave with only mark-failed tasks is skipped over"); - const state = minimalPersistedState({ - wavePlan: [["T1", "T2"], ["T3"]], - tasks: [ - makeTaskRecord({ taskId: "T1", status: "running" }), - makeTaskRecord({ taskId: "T2", status: "running" }), - makeTaskRecord({ taskId: "T3", status: "pending" }), - ], - }); + { + console.log(" ▸ wave with only mark-failed tasks is skipped over"); + const state = minimalPersistedState({ + wavePlan: [["T1", "T2"], ["T3"]], + tasks: [ + makeTaskRecord({ taskId: "T1", status: "running" }), + makeTaskRecord({ taskId: "T2", status: "running" }), + makeTaskRecord({ taskId: "T3", status: "pending" }), + ], + }); - // All dead, no .DONE, no worktrees → all mark-failed - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - assertEqual(reconciled[0].action, "mark-failed", "all-failed-wave: T1 mark-failed"); - assertEqual(reconciled[1].action, "mark-failed", "all-failed-wave: T2 mark-failed"); - assertEqual(reconciled[2].action, "mark-failed", "all-failed-wave: T3 mark-failed"); + // All dead, no .DONE, no worktrees → all mark-failed + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + assertEqual(reconciled[0].action, "mark-failed", "all-failed-wave: T1 mark-failed"); + assertEqual(reconciled[1].action, "mark-failed", "all-failed-wave: T2 mark-failed"); + assertEqual(reconciled[2].action, "mark-failed", "all-failed-wave: T3 mark-failed"); + + const point = computeResumePoint(state, reconciled); + // Wave 0: T1, T2 mark-failed → NOT done for wave-skip → resumeWaveIndex = 0 + assertEqual( + point.resumeWaveIndex, + 0, + "all-failed-wave: resumes from wave 0 (mark-failed is NOT done for wave-skip)", + ); + assertEqual(point.failedTaskIds.length, 3, "all-failed-wave: 3 failed tasks"); + } - const point = computeResumePoint(state, reconciled); - // Wave 0: T1, T2 mark-failed → NOT done for wave-skip → resumeWaveIndex = 0 - assertEqual(point.resumeWaveIndex, 0, "all-failed-wave: resumes from wave 0 (mark-failed is NOT done for wave-skip)"); - assertEqual(point.failedTaskIds.length, 3, "all-failed-wave: 3 failed tasks"); -} + { + console.log(" ▸ blocked/skipped counter stability across pause/resume cycle"); + // Simulate: first run had 2 blocked tasks and 1 skipped task, persisted + // Resume should carry those counters and add new ones without double-counting + + const state = minimalPersistedState({ + wavePlan: [ + ["T1", "T2"], + ["T3", "T4", "T5"], + ], + blockedTasks: 2, + blockedTaskIds: ["T4", "T5"], // blocked from prior run + skippedTasks: 1, + tasks: [ + makeTaskRecord({ taskId: "T1", status: "succeeded" }), + makeTaskRecord({ taskId: "T2", status: "failed" }), + // Wave 2 tasks: never started (no session assigned) + makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "" }), + makeTaskRecord({ taskId: "T4", status: "pending", sessionName: "" }), + makeTaskRecord({ taskId: "T5", status: "pending", sessionName: "" }), + ], + }); -{ - console.log(" ▸ blocked/skipped counter stability across pause/resume cycle"); - // Simulate: first run had 2 blocked tasks and 1 skipped task, persisted - // Resume should carry those counters and add new ones without double-counting - - const state = minimalPersistedState({ - wavePlan: [["T1", "T2"], ["T3", "T4", "T5"]], - blockedTasks: 2, - blockedTaskIds: ["T4", "T5"], // blocked from prior run - skippedTasks: 1, - tasks: [ - makeTaskRecord({ taskId: "T1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", status: "failed" }), - // Wave 2 tasks: never started (no session assigned) - makeTaskRecord({ taskId: "T3", status: "pending", sessionName: "" }), - makeTaskRecord({ taskId: "T4", status: "pending", sessionName: "" }), - makeTaskRecord({ taskId: "T5", status: "pending", sessionName: "" }), - ], - }); + const reconciled = reconcileTaskStates(state, new Set(), new Set()); + const point = computeResumePoint(state, reconciled); + + // Wave 0: T1 succeeded (skip, terminal), T2 failed (skip, terminal) → wave 0 skipped + // Wave 1: T3, T4, T5 are pending (no session → action: "pending", NOT terminal) → resume here + assertEqual(point.resumeWaveIndex, 1, "counter-stability: wave 0 skipped"); + assertEqual(point.completedTaskIds.length, 1, "counter-stability: 1 completed (T1)"); + assertEqual(point.failedTaskIds.length, 1, "counter-stability: 1 failed (T2)"); + + // Simulate runtime state reconstruction (mirrors resumeOrchBatch step 6) + const succeededTasks = point.completedTaskIds.length; // 1 + const failedTasks = point.failedTaskIds.length; // 1 + const skippedTasks = state.skippedTasks; // 1 (carried) + const blockedTasks = state.blockedTasks; // 2 (carried) + const blockedTaskIds = new Set(state.blockedTaskIds); // {T4, T5} + + // T2 is failed (from persisted state). Compute new blocked dependents: + const depGraph = buildTestDepGraph({ + T1: [], + T2: [], + T3: ["T2"], + T4: ["T1"], + T5: ["T3"], + }); - const reconciled = reconcileTaskStates(state, new Set(), new Set()); - const point = computeResumePoint(state, reconciled); - - // Wave 0: T1 succeeded (skip, terminal), T2 failed (skip, terminal) → wave 0 skipped - // Wave 1: T3, T4, T5 are pending (no session → action: "pending", NOT terminal) → resume here - assertEqual(point.resumeWaveIndex, 1, "counter-stability: wave 0 skipped"); - assertEqual(point.completedTaskIds.length, 1, "counter-stability: 1 completed (T1)"); - assertEqual(point.failedTaskIds.length, 1, "counter-stability: 1 failed (T2)"); - - // Simulate runtime state reconstruction (mirrors resumeOrchBatch step 6) - const succeededTasks = point.completedTaskIds.length; // 1 - const failedTasks = point.failedTaskIds.length; // 1 - const skippedTasks = state.skippedTasks; // 1 (carried) - const blockedTasks = state.blockedTasks; // 2 (carried) - const blockedTaskIds = new Set(state.blockedTaskIds); // {T4, T5} - - // T2 is failed (from persisted state). Compute new blocked dependents: - const depGraph = buildTestDepGraph({ - "T1": [], - "T2": [], - "T3": ["T2"], - "T4": ["T1"], - "T5": ["T3"], - }); + const failedSet = new Set(point.failedTaskIds); + // T2 failed → T3 depends on T2 → blocked. T5 depends on T3 → transitively blocked. + const newBlocked = computeTransitiveDependents(failedSet, depGraph); - const failedSet = new Set(point.failedTaskIds); - // T2 failed → T3 depends on T2 → blocked. T5 depends on T3 → transitively blocked. - const newBlocked = computeTransitiveDependents(failedSet, depGraph); - - for (const taskId of newBlocked) { - blockedTaskIds.add(taskId); - } - - // T3 depends on T2 (failed) → T3 blocked - // T5 depends on T3 (now blocked) → T5 also blocked via transitive closure - // T4 depends on T1 (succeeded) → T4 NOT newly blocked - assert(blockedTaskIds.has("T3"), "counter-stability: T3 newly blocked (depends on failed T2)"); - assert(blockedTaskIds.has("T5"), "counter-stability: T5 still blocked (transitive via T3)"); - assert(blockedTaskIds.has("T4"), "counter-stability: T4 still blocked (carried from persisted)"); - - // In wave 1, count blocked tasks in that wave - const wave1BlockedCount = state.wavePlan[1].filter( - (taskId: string) => blockedTaskIds.has(taskId), - ).length; - assertEqual(wave1BlockedCount, 3, "counter-stability: all 3 wave-1 tasks blocked"); - - // Final counters - assertEqual(succeededTasks, 1, "counter-stability: succeededTasks = 1"); - assertEqual(failedTasks, 1, "counter-stability: failedTasks = 1"); - assertEqual(skippedTasks, 1, "counter-stability: skippedTasks = 1 (carried)"); - assertEqual(blockedTasks, 2, "counter-stability: blockedTasks starts at 2 (carried)"); - // blockedTasks would be incremented per-wave in the loop (wave 1 adds 3 more, minus already-counted ones) -} + for (const taskId of newBlocked) { + blockedTaskIds.add(taskId); + } + + // T3 depends on T2 (failed) → T3 blocked + // T5 depends on T3 (now blocked) → T5 also blocked via transitive closure + // T4 depends on T1 (succeeded) → T4 NOT newly blocked + assert(blockedTaskIds.has("T3"), "counter-stability: T3 newly blocked (depends on failed T2)"); + assert(blockedTaskIds.has("T5"), "counter-stability: T5 still blocked (transitive via T3)"); + assert(blockedTaskIds.has("T4"), "counter-stability: T4 still blocked (carried from persisted)"); + + // In wave 1, count blocked tasks in that wave + const wave1BlockedCount = state.wavePlan[1].filter((taskId: string) => + blockedTaskIds.has(taskId), + ).length; + assertEqual(wave1BlockedCount, 3, "counter-stability: all 3 wave-1 tasks blocked"); -{ - console.log(" ▸ v1 fallback: computeResumePoint works identically without repo fields"); - // v1 state has no repoId, resolvedRepoId fields on tasks/lanes - const v1State = minimalPersistedState({ - mode: "repo", - wavePlan: [["T1"], ["T2", "T3"]], - blockedTaskIds: [], - lanes: [ + // Final counters + assertEqual(succeededTasks, 1, "counter-stability: succeededTasks = 1"); + assertEqual(failedTasks, 1, "counter-stability: failedTasks = 1"); + assertEqual(skippedTasks, 1, "counter-stability: skippedTasks = 1 (carried)"); + assertEqual(blockedTasks, 2, "counter-stability: blockedTasks starts at 2 (carried)"); + // blockedTasks would be incremented per-wave in the loop (wave 1 adds 3 more, minus already-counted ones) + } + + { + console.log(" ▸ v1 fallback: computeResumePoint works identically without repo fields"); + // v1 state has no repoId, resolvedRepoId fields on tasks/lanes + const v1State = minimalPersistedState({ + mode: "repo", + wavePlan: [["T1"], ["T2", "T3"]], + blockedTaskIds: [], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-batch", + taskIds: ["T1", "T2"], + // No repoId — v1 + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/tmp/wt-2", + branch: "task/lane-2-batch", + taskIds: ["T3"], + // No repoId — v1 + }, + ], + tasks: [ + makeTaskRecord({ + taskId: "T1", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + }), + makeTaskRecord({ taskId: "T2", laneNumber: 1, sessionName: "orch-lane-1", status: "running" }), + makeTaskRecord({ taskId: "T3", laneNumber: 2, sessionName: "orch-lane-2", status: "pending" }), + ], + }); + + // T1 done, T2 dead session (had session), T3 dead session (had session) + const reconciled = reconcileTaskStates(v1State, new Set(), new Set()); + const point = computeResumePoint(v1State, reconciled); + + // T1: succeeded → skip(succeeded) → completed + assertEqual(point.completedTaskIds.length, 1, "v1 fallback: 1 completed (T1)"); + assert(point.completedTaskIds.includes("T1"), "v1 fallback: T1 in completed"); + + // T2: running + dead + has session → mark-failed + // T3: pending + dead + has session → mark-failed + assertEqual(point.failedTaskIds.length, 2, "v1 fallback: 2 failed (T2, T3)"); + + // Wave 0: T1 succeeded (skip→done). Wave 1: T2, T3 mark-failed (NOT done for wave-skip). + assertEqual( + point.resumeWaveIndex, + 1, + "v1 fallback: resumes from wave 1 (mark-failed NOT done for wave-skip)", + ); + + // Blocked propagation with v1 dep graph + const depGraph = buildTestDepGraph({ + T1: [], + T2: ["T1"], + T3: ["T2"], + }); + + const failedSet = new Set(point.failedTaskIds); + const blocked = computeTransitiveDependents(failedSet, depGraph); + // T2 failed, T3 failed (both already in failedTaskIds) → T3 depends on T2 + // But T3 is already in failedSet, so no NEW blocked tasks + assertEqual(blocked.size, 0, "v1 fallback: no new blocked (T3 already failed directly)"); + } + + { + console.log(" ▸ transitive blocked propagation across repos: A→B→C chain"); + // Scenario: A (api) fails → B (frontend, depends on A) blocked → C (api, depends on B) also blocked + const depGraph = buildTestDepGraph({ + A: [], + B: ["A"], + C: ["B"], + }); + + const failedSet = new Set(["A"]); + const blocked = computeTransitiveDependents(failedSet, depGraph); + assertEqual(blocked.size, 2, "transitive-chain: 2 tasks blocked"); + assert(blocked.has("B"), "transitive-chain: B blocked (direct dep of A)"); + assert(blocked.has("C"), "transitive-chain: C blocked (transitive via B)"); + assert(!blocked.has("A"), "transitive-chain: A not in blocked set (it's in failedSet)"); + } + + { + console.log(" ▸ mark-complete action always categorizes as completed (not filtered by status)"); + // Previously, mark-complete was grouped with skip and could miss tasks + // if the persistedStatus wasn't explicitly "succeeded" + const state = minimalPersistedState({ + wavePlan: [["T1"]], + tasks: [makeTaskRecord({ taskId: "T1", status: "running" })], + }); + + // T1 has .DONE → mark-complete regardless of persisted status + const reconciled = reconcileTaskStates(state, new Set(), new Set(["T1"])); + assertEqual( + reconciled[0].action, + "mark-complete", + "mark-complete-always: action is mark-complete", + ); + assertEqual( + reconciled[0].persistedStatus, + "running", + "mark-complete-always: persisted was running", + ); + + const point = computeResumePoint(state, reconciled); + assertEqual(point.completedTaskIds.length, 1, "mark-complete-always: T1 in completed"); + assert(point.completedTaskIds.includes("T1"), "mark-complete-always: T1 present"); + assertEqual(point.failedTaskIds.length, 0, "mark-complete-always: no failures"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // TP-007 Step 2: Execute resumed waves safely — repo-scoped context & persistence + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n── TP-007 Step 2: reconstructAllocatedLanes & collectAllRepoRoots ──"); + + // ── Reimplement Step 2 helpers for test self-containment ───────────── + + function reconstructAllocatedLanes( + persistedLanes: Array<{ + laneNumber: number; + laneId: string; + laneSessionId: string; + worktreePath: string; + branch: string; + taskIds: string[]; + repoId?: string; + }>, + persistedTasks?: Array<{ + taskId: string; + repoId?: string; + resolvedRepoId?: string; + taskFolder?: string; + }>, + ): any[] { + const taskLookup = new Map(); + if (persistedTasks) { + for (const t of persistedTasks) { + taskLookup.set(t.taskId, t); + } + } + + return persistedLanes.map((lr) => ({ + laneNumber: lr.laneNumber, + laneId: lr.laneId, + laneSessionId: lr.laneSessionId, + worktreePath: lr.worktreePath, + branch: lr.branch, + tasks: lr.taskIds.map((taskId: string) => { + const persistedTask = taskLookup.get(taskId); + const taskStub: any = {}; + if (persistedTask?.repoId !== undefined) { + taskStub.promptRepoId = persistedTask.repoId; + } + if (persistedTask?.resolvedRepoId !== undefined) { + taskStub.resolvedRepoId = persistedTask.resolvedRepoId; + } + if (persistedTask?.taskFolder) { + taskStub.taskFolder = persistedTask.taskFolder; + } + return { + taskId, + order: 0, + task: Object.keys(taskStub).length > 0 ? taskStub : null, + estimatedMinutes: 0, + }; + }), + strategy: "round-robin", + estimatedLoad: 0, + estimatedMinutes: 0, + ...(lr.repoId !== undefined ? { repoId: lr.repoId } : {}), + })); + } + + function collectAllRepoRoots( + laneSources: Array>, + defaultRepoRoot: string, + workspaceConfig?: { repos: Map } | null, + ): string[] { + const roots = new Set(); + for (const lanes of laneSources) { + for (const lane of lanes) { + const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); + roots.add(root); + } + } + roots.add(defaultRepoRoot); + return [...roots]; + } + + // 2.1: reconstructAllocatedLanes preserves repo attribution + { + console.log( + " ▸ reconstructAllocatedLanes: preserves laneNumber, laneId, branch, repoId from persisted records", + ); + const persistedLanes = [ { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", branch: "task/lane-1-batch", + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/work/wt-1", + branch: "orch/batch-1-lane-1", taskIds: ["T1", "T2"], - // No repoId — v1 + repoId: "api", }, { - laneNumber: 2, laneId: "lane-2", laneSessionId: "orch-lane-2", - worktreePath: "/tmp/wt-2", branch: "task/lane-2-batch", + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/work/wt-2", + branch: "orch/batch-1-lane-2", taskIds: ["T3"], - // No repoId — v1 + repoId: "frontend", }, - ], - tasks: [ - makeTaskRecord({ taskId: "T1", laneNumber: 1, sessionName: "orch-lane-1", status: "succeeded" }), - makeTaskRecord({ taskId: "T2", laneNumber: 1, sessionName: "orch-lane-1", status: "running" }), - makeTaskRecord({ taskId: "T3", laneNumber: 2, sessionName: "orch-lane-2", status: "pending" }), - ], - }); + ]; - // T1 done, T2 dead session (had session), T3 dead session (had session) - const reconciled = reconcileTaskStates(v1State, new Set(), new Set()); - const point = computeResumePoint(v1State, reconciled); + const allocated = reconstructAllocatedLanes(persistedLanes); + assertEqual(allocated.length, 2, "reconstructed 2 lanes"); + assertEqual(allocated[0].laneNumber, 1, "lane 1 number preserved"); + assertEqual(allocated[0].laneId, "lane-1", "lane 1 id preserved"); + assertEqual(allocated[0].laneSessionId, "orch-lane-1", "lane 1 session preserved"); + assertEqual(allocated[0].worktreePath, "/work/wt-1", "lane 1 worktree preserved"); + assertEqual(allocated[0].branch, "orch/batch-1-lane-1", "lane 1 branch preserved"); + assertEqual(allocated[0].repoId, "api", "lane 1 repoId preserved"); + assertEqual(allocated[0].tasks.length, 2, "lane 1 has 2 task stubs"); + assertEqual(allocated[0].tasks[0].taskId, "T1", "lane 1 task 1 ID correct"); + assertEqual(allocated[0].tasks[1].taskId, "T2", "lane 1 task 2 ID correct"); - // T1: succeeded → skip(succeeded) → completed - assertEqual(point.completedTaskIds.length, 1, "v1 fallback: 1 completed (T1)"); - assert(point.completedTaskIds.includes("T1"), "v1 fallback: T1 in completed"); + assertEqual(allocated[1].laneNumber, 2, "lane 2 number preserved"); + assertEqual(allocated[1].repoId, "frontend", "lane 2 repoId preserved"); + assertEqual(allocated[1].tasks.length, 1, "lane 2 has 1 task stub"); + } - // T2: running + dead + has session → mark-failed - // T3: pending + dead + has session → mark-failed - assertEqual(point.failedTaskIds.length, 2, "v1 fallback: 2 failed (T2, T3)"); + // 2.2: reconstructAllocatedLanes with v1 lanes (no repoId) + { + console.log( + " ▸ reconstructAllocatedLanes: v1 lanes (no repoId) produce lanes without repoId field", + ); + const v1Lanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/work/wt-1", + branch: "orch/batch-1-lane-1", + taskIds: ["T1"], + }, + ]; - // Wave 0: T1 succeeded (skip→done). Wave 1: T2, T3 mark-failed (NOT done for wave-skip). - assertEqual(point.resumeWaveIndex, 1, "v1 fallback: resumes from wave 1 (mark-failed NOT done for wave-skip)"); + const allocated = reconstructAllocatedLanes(v1Lanes); + assertEqual(allocated.length, 1, "v1 reconstructed 1 lane"); + assertEqual(allocated[0].repoId, undefined, "v1 lane has no repoId"); + assertEqual(allocated[0].laneNumber, 1, "v1 lane number preserved"); + } - // Blocked propagation with v1 dep graph - const depGraph = buildTestDepGraph({ - "T1": [], - "T2": ["T1"], - "T3": ["T2"], - }); + // 2.3: collectAllRepoRoots merges roots from multiple sources + { + console.log(" ▸ collectAllRepoRoots: merges repos from persisted + newly allocated lanes"); + const wsConfig = { + repos: new Map([ + ["api", { path: "/repos/api" }], + ["frontend", { path: "/repos/frontend" }], + ["backend", { path: "/repos/backend" }], + ]), + }; - const failedSet = new Set(point.failedTaskIds); - const blocked = computeTransitiveDependents(failedSet, depGraph); - // T2 failed, T3 failed (both already in failedTaskIds) → T3 depends on T2 - // But T3 is already in failedSet, so no NEW blocked tasks - assertEqual(blocked.size, 0, "v1 fallback: no new blocked (T3 already failed directly)"); -} + // Persisted lanes have api + frontend + const persistedLanes = [ + { repoId: "api" as string | undefined }, + { repoId: "frontend" as string | undefined }, + ]; + // Newly allocated lanes introduce backend + const newLanes = [ + { repoId: "backend" as string | undefined }, + { repoId: "api" as string | undefined }, // duplicate, should deduplicate + ]; -{ - console.log(" ▸ transitive blocked propagation across repos: A→B→C chain"); - // Scenario: A (api) fails → B (frontend, depends on A) blocked → C (api, depends on B) also blocked - const depGraph = buildTestDepGraph({ - "A": [], - "B": ["A"], - "C": ["B"], - }); + const roots = collectAllRepoRoots([persistedLanes, newLanes], "/default", wsConfig); + assert(roots.includes("/repos/api"), "includes api from persisted"); + assert(roots.includes("/repos/frontend"), "includes frontend from persisted"); + assert(roots.includes("/repos/backend"), "includes backend from new lanes"); + assert(roots.includes("/default"), "includes default root"); + assertEqual(roots.length, 4, "4 unique roots (3 repos + default)"); + } - const failedSet = new Set(["A"]); - const blocked = computeTransitiveDependents(failedSet, depGraph); - assertEqual(blocked.size, 2, "transitive-chain: 2 tasks blocked"); - assert(blocked.has("B"), "transitive-chain: B blocked (direct dep of A)"); - assert(blocked.has("C"), "transitive-chain: C blocked (transitive via B)"); - assert(!blocked.has("A"), "transitive-chain: A not in blocked set (it's in failedSet)"); -} + // 2.4: collectAllRepoRoots in repo mode (no workspaceConfig) + { + console.log(" ▸ collectAllRepoRoots: repo mode (null workspace) returns only default root"); + const persistedLanes = [ + { repoId: undefined as string | undefined }, + { repoId: undefined as string | undefined }, + ]; + const roots = collectAllRepoRoots([persistedLanes], "/myrepo", null); + assertEqual(roots.length, 1, "repo mode: 1 root"); + assert(roots.includes("/myrepo"), "repo mode: only default root"); + } -{ - console.log(" ▸ mark-complete action always categorizes as completed (not filtered by status)"); - // Previously, mark-complete was grouped with skip and could miss tasks - // if the persistedStatus wasn't explicitly "succeeded" - const state = minimalPersistedState({ - wavePlan: [["T1"]], - tasks: [ - makeTaskRecord({ taskId: "T1", status: "running" }), - ], - }); + // 2.5: Serialization round-trip preserves lane records from reconstructed lanes + { + console.log( + " ▸ serializeBatchState: reconstructed lanes preserve repo attribution through serialization", + ); + const persistedLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/work/wt-1", + branch: "orch/batch-1-lane-1", + taskIds: ["T1"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "lane-2", + laneSessionId: "orch-lane-2", + worktreePath: "/work/wt-2", + branch: "orch/batch-1-lane-2", + taskIds: ["T2"], + repoId: "frontend", + }, + ]; - // T1 has .DONE → mark-complete regardless of persisted status - const reconciled = reconcileTaskStates(state, new Set(), new Set(["T1"])); - assertEqual(reconciled[0].action, "mark-complete", "mark-complete-always: action is mark-complete"); - assertEqual(reconciled[0].persistedStatus, "running", "mark-complete-always: persisted was running"); + const allocated = reconstructAllocatedLanes(persistedLanes); + + // Simulate what resumeOrchBatch does: serialize with reconstructed lanes + const state: MinimalBatchState = { + phase: "executing", + batchId: "test-batch", + baseBranch: "main", + mode: "workspace", + startedAt: Date.now() - 5000, + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + totalTasks: 2, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [], + }; - const point = computeResumePoint(state, reconciled); - assertEqual(point.completedTaskIds.length, 1, "mark-complete-always: T1 in completed"); - assert(point.completedTaskIds.includes("T1"), "mark-complete-always: T1 present"); - assertEqual(point.failedTaskIds.length, 0, "mark-complete-always: no failures"); -} + const outcomes: any[] = [ + { + taskId: "T1", + status: "succeeded", + startTime: 1000, + endTime: 2000, + exitReason: ".DONE found", + sessionName: "orch-lane-1", + doneFileFound: true, + }, + { + taskId: "T2", + status: "running", + startTime: 1000, + endTime: null, + exitReason: "", + sessionName: "orch-lane-2", + doneFileFound: false, + }, + ]; -// ═══════════════════════════════════════════════════════════════════════ -// TP-007 Step 2: Execute resumed waves safely — repo-scoped context & persistence -// ═══════════════════════════════════════════════════════════════════════ + const json = serializeBatchState(state, [["T1", "T2"]], allocated, outcomes); + const parsed = JSON.parse(json); -console.log("\n── TP-007 Step 2: reconstructAllocatedLanes & collectAllRepoRoots ──"); + // Lane records must survive serialization + assertEqual(parsed.lanes.length, 2, "serialized 2 lane records"); + assertEqual(parsed.lanes[0].laneNumber, 1, "lane 1 number in output"); + assertEqual(parsed.lanes[0].repoId, "api", "lane 1 repoId in output"); + assertEqual(parsed.lanes[0].laneSessionId, "orch-lane-1", "lane 1 session in output"); + assertEqual(parsed.lanes[1].laneNumber, 2, "lane 2 number in output"); + assertEqual(parsed.lanes[1].repoId, "frontend", "lane 2 repoId in output"); -// ── Reimplement Step 2 helpers for test self-containment ───────────── + // Task records should still have correct lane assignment + const t1 = parsed.tasks.find((t: any) => t.taskId === "T1"); + const t2 = parsed.tasks.find((t: any) => t.taskId === "T2"); + assertEqual(t1.laneNumber, 1, "T1 assigned to lane 1"); + assertEqual(t2.laneNumber, 2, "T2 assigned to lane 2"); + } -function reconstructAllocatedLanes( - persistedLanes: Array<{ laneNumber: number; laneId: string; laneSessionId: string; worktreePath: string; branch: string; taskIds: string[]; repoId?: string }>, - persistedTasks?: Array<{ taskId: string; repoId?: string; resolvedRepoId?: string; taskFolder?: string }>, -): any[] { - const taskLookup = new Map(); - if (persistedTasks) { - for (const t of persistedTasks) { - taskLookup.set(t.taskId, t); - } + // 2.6: Empty persisted lanes reconstructs to empty (graceful) + { + console.log(" ▸ reconstructAllocatedLanes: empty input produces empty output"); + const allocated = reconstructAllocatedLanes([]); + assertEqual(allocated.length, 0, "empty lanes: no reconstruction"); } - return persistedLanes.map((lr) => ({ - laneNumber: lr.laneNumber, - laneId: lr.laneId, - laneSessionId: lr.laneSessionId, - worktreePath: lr.worktreePath, - branch: lr.branch, - tasks: lr.taskIds.map((taskId: string) => { - const persistedTask = taskLookup.get(taskId); - const taskStub: any = {}; - if (persistedTask?.repoId !== undefined) { - taskStub.promptRepoId = persistedTask.repoId; - } - if (persistedTask?.resolvedRepoId !== undefined) { - taskStub.resolvedRepoId = persistedTask.resolvedRepoId; - } - if (persistedTask?.taskFolder) { - taskStub.taskFolder = persistedTask.taskFolder; - } - return { - taskId, - order: 0, - task: Object.keys(taskStub).length > 0 ? taskStub : null, - estimatedMinutes: 0, - }; - }), - strategy: "round-robin", - estimatedLoad: 0, - estimatedMinutes: 0, - ...(lr.repoId !== undefined ? { repoId: lr.repoId } : {}), - })); -} + // 2.7: Checkpoint attribution invariants across persistence triggers + { + console.log( + " ▸ checkpoint attribution: lanes[] and tasks[].repoId survive resume-reconciliation → wave-execution-complete", + ); -function collectAllRepoRoots( - laneSources: Array>, - defaultRepoRoot: string, - workspaceConfig?: { repos: Map } | null, -): string[] { - const roots = new Set(); - for (const lanes of laneSources) { - for (const lane of lanes) { - const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); - roots.add(root); - } - } - roots.add(defaultRepoRoot); - return [...roots]; -} + // Simulate the resume flow: persisted state → reconstruct → first persistence call → wave execution → second persistence call + const persistedLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/work/wt-1", + branch: "orch/batch-1-lane-1", + taskIds: ["T1"], + repoId: "api", + }, + ]; -// 2.1: reconstructAllocatedLanes preserves repo attribution -{ - console.log(" ▸ reconstructAllocatedLanes: preserves laneNumber, laneId, branch, repoId from persisted records"); - const persistedLanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/work/wt-1", - branch: "orch/batch-1-lane-1", - taskIds: ["T1", "T2"], - repoId: "api", - }, - { - laneNumber: 2, - laneId: "lane-2", - laneSessionId: "orch-lane-2", - worktreePath: "/work/wt-2", - branch: "orch/batch-1-lane-2", - taskIds: ["T3"], - repoId: "frontend", - }, - ]; - - const allocated = reconstructAllocatedLanes(persistedLanes); - assertEqual(allocated.length, 2, "reconstructed 2 lanes"); - assertEqual(allocated[0].laneNumber, 1, "lane 1 number preserved"); - assertEqual(allocated[0].laneId, "lane-1", "lane 1 id preserved"); - assertEqual(allocated[0].laneSessionId, "orch-lane-1", "lane 1 session preserved"); - assertEqual(allocated[0].worktreePath, "/work/wt-1", "lane 1 worktree preserved"); - assertEqual(allocated[0].branch, "orch/batch-1-lane-1", "lane 1 branch preserved"); - assertEqual(allocated[0].repoId, "api", "lane 1 repoId preserved"); - assertEqual(allocated[0].tasks.length, 2, "lane 1 has 2 task stubs"); - assertEqual(allocated[0].tasks[0].taskId, "T1", "lane 1 task 1 ID correct"); - assertEqual(allocated[0].tasks[1].taskId, "T2", "lane 1 task 2 ID correct"); - - assertEqual(allocated[1].laneNumber, 2, "lane 2 number preserved"); - assertEqual(allocated[1].repoId, "frontend", "lane 2 repoId preserved"); - assertEqual(allocated[1].tasks.length, 1, "lane 2 has 1 task stub"); -} + // Phase 1: resume-reconciliation checkpoint (before any wave executes) + const reconstructed = reconstructAllocatedLanes(persistedLanes); + const reconcileState: MinimalBatchState = { + phase: "executing", + batchId: "test-batch", + baseBranch: "main", + mode: "workspace", + startedAt: Date.now() - 5000, + endedAt: null, + currentWaveIndex: 0, + totalWaves: 2, + totalTasks: 2, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [], + }; -// 2.2: reconstructAllocatedLanes with v1 lanes (no repoId) -{ - console.log(" ▸ reconstructAllocatedLanes: v1 lanes (no repoId) produce lanes without repoId field"); - const v1Lanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/work/wt-1", - branch: "orch/batch-1-lane-1", - taskIds: ["T1"], - }, - ]; - - const allocated = reconstructAllocatedLanes(v1Lanes); - assertEqual(allocated.length, 1, "v1 reconstructed 1 lane"); - assertEqual(allocated[0].repoId, undefined, "v1 lane has no repoId"); - assertEqual(allocated[0].laneNumber, 1, "v1 lane number preserved"); -} + const reconcileOutcomes: any[] = [ + { + taskId: "T1", + status: "succeeded", + startTime: 1000, + endTime: 2000, + exitReason: ".DONE found", + sessionName: "orch-lane-1", + doneFileFound: true, + }, + ]; -// 2.3: collectAllRepoRoots merges roots from multiple sources -{ - console.log(" ▸ collectAllRepoRoots: merges repos from persisted + newly allocated lanes"); - const wsConfig = { - repos: new Map([ - ["api", { path: "/repos/api" }], - ["frontend", { path: "/repos/frontend" }], - ["backend", { path: "/repos/backend" }], - ]), - }; + const json1 = serializeBatchState( + reconcileState, + [["T1"], ["T2"]], + reconstructed, + reconcileOutcomes, + ); + const parsed1 = JSON.parse(json1); - // Persisted lanes have api + frontend - const persistedLanes = [ - { repoId: "api" as string | undefined }, - { repoId: "frontend" as string | undefined }, - ]; - // Newly allocated lanes introduce backend - const newLanes = [ - { repoId: "backend" as string | undefined }, - { repoId: "api" as string | undefined }, // duplicate, should deduplicate - ]; - - const roots = collectAllRepoRoots([persistedLanes, newLanes], "/default", wsConfig); - assert(roots.includes("/repos/api"), "includes api from persisted"); - assert(roots.includes("/repos/frontend"), "includes frontend from persisted"); - assert(roots.includes("/repos/backend"), "includes backend from new lanes"); - assert(roots.includes("/default"), "includes default root"); - assertEqual(roots.length, 4, "4 unique roots (3 repos + default)"); -} + // Verify lanes survive first checkpoint + assertEqual(parsed1.lanes.length, 1, "reconcile checkpoint: 1 lane record"); + assertEqual(parsed1.lanes[0].repoId, "api", "reconcile checkpoint: repoId preserved"); + assertEqual(parsed1.lanes[0].laneNumber, 1, "reconcile checkpoint: laneNumber preserved"); -// 2.4: collectAllRepoRoots in repo mode (no workspaceConfig) -{ - console.log(" ▸ collectAllRepoRoots: repo mode (null workspace) returns only default root"); - const persistedLanes = [{ repoId: undefined as string | undefined }, { repoId: undefined as string | undefined }]; - const roots = collectAllRepoRoots([persistedLanes], "/myrepo", null); - assertEqual(roots.length, 1, "repo mode: 1 root"); - assert(roots.includes("/myrepo"), "repo mode: only default root"); -} + // Phase 2: wave-execution-complete (new wave allocates lanes in new repo) + const newWaveLanes: any[] = [ + { + laneNumber: 3, + laneId: "lane-3", + laneSessionId: "orch-lane-3", + worktreePath: "/work/wt-3", + branch: "orch/batch-1-lane-3", + tasks: [ + { + taskId: "T2", + order: 0, + task: { promptRepoId: "frontend", resolvedRepoId: "frontend" }, + estimatedMinutes: 5, + }, + ], + strategy: "round-robin", + estimatedLoad: 1, + estimatedMinutes: 5, + repoId: "frontend", + }, + ]; -// 2.5: Serialization round-trip preserves lane records from reconstructed lanes -{ - console.log(" ▸ serializeBatchState: reconstructed lanes preserve repo attribution through serialization"); - const persistedLanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/work/wt-1", - branch: "orch/batch-1-lane-1", - taskIds: ["T1"], - repoId: "api", - }, - { - laneNumber: 2, - laneId: "lane-2", - laneSessionId: "orch-lane-2", - worktreePath: "/work/wt-2", - branch: "orch/batch-1-lane-2", - taskIds: ["T2"], - repoId: "frontend", - }, - ]; - - const allocated = reconstructAllocatedLanes(persistedLanes); - - // Simulate what resumeOrchBatch does: serialize with reconstructed lanes - const state: MinimalBatchState = { - phase: "executing", - batchId: "test-batch", - baseBranch: "main", - mode: "workspace", - startedAt: Date.now() - 5000, - endedAt: null, - currentWaveIndex: 0, - totalWaves: 1, - totalTasks: 2, - succeededTasks: 0, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: new Set(), - errors: [], - mergeResults: [], - }; + const waveOutcomes = [ + ...reconcileOutcomes, + { + taskId: "T2", + status: "succeeded", + startTime: 3000, + endTime: 4000, + exitReason: "done", + sessionName: "orch-lane-3", + doneFileFound: true, + }, + ]; + const json2 = serializeBatchState(reconcileState, [["T1"], ["T2"]], newWaveLanes, waveOutcomes); + const parsed2 = JSON.parse(json2); + + // New wave lanes take over (latestAllocatedLanes behavior) + assertEqual(parsed2.lanes.length, 1, "wave checkpoint: 1 lane (latest wave)"); + assertEqual(parsed2.lanes[0].repoId, "frontend", "wave checkpoint: new repo 'frontend'"); + assertEqual(parsed2.lanes[0].laneNumber, 3, "wave checkpoint: lane 3 from new wave"); + + // Task T2 should get repo fields from allocated task + const t2 = parsed2.tasks.find((t: any) => t.taskId === "T2"); + assertEqual(t2.repoId, "frontend", "wave checkpoint: T2 repoId from allocated task"); + assertEqual( + t2.resolvedRepoId, + "frontend", + "wave checkpoint: T2 resolvedRepoId from allocated task", + ); + } - const outcomes: any[] = [ - { taskId: "T1", status: "succeeded", startTime: 1000, endTime: 2000, exitReason: ".DONE found", sessionName: "orch-lane-1", doneFileFound: true }, - { taskId: "T2", status: "running", startTime: 1000, endTime: null, exitReason: "", sessionName: "orch-lane-2", doneFileFound: false }, - ]; - - const json = serializeBatchState(state, [["T1", "T2"]], allocated, outcomes); - const parsed = JSON.parse(json); - - // Lane records must survive serialization - assertEqual(parsed.lanes.length, 2, "serialized 2 lane records"); - assertEqual(parsed.lanes[0].laneNumber, 1, "lane 1 number in output"); - assertEqual(parsed.lanes[0].repoId, "api", "lane 1 repoId in output"); - assertEqual(parsed.lanes[0].laneSessionId, "orch-lane-1", "lane 1 session in output"); - assertEqual(parsed.lanes[1].laneNumber, 2, "lane 2 number in output"); - assertEqual(parsed.lanes[1].repoId, "frontend", "lane 2 repoId in output"); - - // Task records should still have correct lane assignment - const t1 = parsed.tasks.find((t: any) => t.taskId === "T1"); - const t2 = parsed.tasks.find((t: any) => t.taskId === "T2"); - assertEqual(t1.laneNumber, 1, "T1 assigned to lane 1"); - assertEqual(t2.laneNumber, 2, "T2 assigned to lane 2"); -} + // 2.8: collectAllRepoRoots covers repos introduced by resumed waves + { + console.log( + " ▸ collectAllRepoRoots: repos from resumed wave allocation are included in cleanup set", + ); + const wsConfig = { + repos: new Map([ + ["api", { path: "/repos/api" }], + ["newrepo", { path: "/repos/newrepo" }], + ]), + }; -// 2.6: Empty persisted lanes reconstructs to empty (graceful) -{ - console.log(" ▸ reconstructAllocatedLanes: empty input produces empty output"); - const allocated = reconstructAllocatedLanes([]); - assertEqual(allocated.length, 0, "empty lanes: no reconstruction"); -} + // Scenario: persisted state only had "api" lanes. Resumed wave introduces "newrepo". + const persistedLaneSources = [{ repoId: "api" as string | undefined }]; + const newAllocatedSources = [{ repoId: "newrepo" as string | undefined }]; -// 2.7: Checkpoint attribution invariants across persistence triggers -{ - console.log(" ▸ checkpoint attribution: lanes[] and tasks[].repoId survive resume-reconciliation → wave-execution-complete"); + // Without collectAllRepoRoots, only api would be cleaned up. + // With it, both are included. + const roots = collectAllRepoRoots( + [persistedLaneSources, newAllocatedSources], + "/default", + wsConfig, + ); + assert(roots.includes("/repos/api"), "cleanup includes api (from persisted)"); + assert(roots.includes("/repos/newrepo"), "cleanup includes newrepo (from resumed wave)"); + assert(roots.includes("/default"), "cleanup includes default"); + assertEqual(roots.length, 3, "3 unique roots for cleanup"); + } - // Simulate the resume flow: persisted state → reconstruct → first persistence call → wave execution → second persistence call - const persistedLanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/work/wt-1", - branch: "orch/batch-1-lane-1", - taskIds: ["T1"], - repoId: "api", - }, - ]; - - // Phase 1: resume-reconciliation checkpoint (before any wave executes) - const reconstructed = reconstructAllocatedLanes(persistedLanes); - const reconcileState: MinimalBatchState = { - phase: "executing", - batchId: "test-batch", - baseBranch: "main", - mode: "workspace", - startedAt: Date.now() - 5000, - endedAt: null, - currentWaveIndex: 0, - totalWaves: 2, - totalTasks: 2, - succeededTasks: 1, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: new Set(), - errors: [], - mergeResults: [], - }; + // 2.9: v1 fallback parity — reconstructAllocatedLanes + collectAllRepoRoots in repo mode + { + console.log( + " ▸ v1 fallback: reconstructAllocatedLanes + collectAllRepoRoots unchanged for v1 state", + ); + const v1Lanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/work/wt-1", + branch: "orch/batch-1-lane-1", + taskIds: ["T1"], + // no repoId — v1 behavior + }, + ]; - const reconcileOutcomes: any[] = [ - { taskId: "T1", status: "succeeded", startTime: 1000, endTime: 2000, exitReason: ".DONE found", sessionName: "orch-lane-1", doneFileFound: true }, - ]; - - const json1 = serializeBatchState(reconcileState, [["T1"], ["T2"]], reconstructed, reconcileOutcomes); - const parsed1 = JSON.parse(json1); - - // Verify lanes survive first checkpoint - assertEqual(parsed1.lanes.length, 1, "reconcile checkpoint: 1 lane record"); - assertEqual(parsed1.lanes[0].repoId, "api", "reconcile checkpoint: repoId preserved"); - assertEqual(parsed1.lanes[0].laneNumber, 1, "reconcile checkpoint: laneNumber preserved"); - - // Phase 2: wave-execution-complete (new wave allocates lanes in new repo) - const newWaveLanes: any[] = [{ - laneNumber: 3, - laneId: "lane-3", - laneSessionId: "orch-lane-3", - worktreePath: "/work/wt-3", - branch: "orch/batch-1-lane-3", - tasks: [{ taskId: "T2", order: 0, task: { promptRepoId: "frontend", resolvedRepoId: "frontend" }, estimatedMinutes: 5 }], - strategy: "round-robin", - estimatedLoad: 1, - estimatedMinutes: 5, - repoId: "frontend", - }]; - - const waveOutcomes = [...reconcileOutcomes, { taskId: "T2", status: "succeeded", startTime: 3000, endTime: 4000, exitReason: "done", sessionName: "orch-lane-3", doneFileFound: true }]; - const json2 = serializeBatchState(reconcileState, [["T1"], ["T2"]], newWaveLanes, waveOutcomes); - const parsed2 = JSON.parse(json2); - - // New wave lanes take over (latestAllocatedLanes behavior) - assertEqual(parsed2.lanes.length, 1, "wave checkpoint: 1 lane (latest wave)"); - assertEqual(parsed2.lanes[0].repoId, "frontend", "wave checkpoint: new repo 'frontend'"); - assertEqual(parsed2.lanes[0].laneNumber, 3, "wave checkpoint: lane 3 from new wave"); - - // Task T2 should get repo fields from allocated task - const t2 = parsed2.tasks.find((t: any) => t.taskId === "T2"); - assertEqual(t2.repoId, "frontend", "wave checkpoint: T2 repoId from allocated task"); - assertEqual(t2.resolvedRepoId, "frontend", "wave checkpoint: T2 resolvedRepoId from allocated task"); -} + const allocated = reconstructAllocatedLanes(v1Lanes); + assertEqual(allocated.length, 1, "v1 parity: 1 lane reconstructed"); + assertEqual(allocated[0].repoId, undefined, "v1 parity: no repoId"); -// 2.8: collectAllRepoRoots covers repos introduced by resumed waves -{ - console.log(" ▸ collectAllRepoRoots: repos from resumed wave allocation are included in cleanup set"); - const wsConfig = { - repos: new Map([ - ["api", { path: "/repos/api" }], - ["newrepo", { path: "/repos/newrepo" }], - ]), - }; + // collectAllRepoRoots with v1 lanes + null workspace → only default + const roots = collectAllRepoRoots([allocated], "/myrepo", null); + assertEqual(roots.length, 1, "v1 parity: only default root"); + assert(roots.includes("/myrepo"), "v1 parity: default root present"); + } - // Scenario: persisted state only had "api" lanes. Resumed wave introduces "newrepo". - const persistedLaneSources = [{ repoId: "api" as string | undefined }]; - const newAllocatedSources = [{ repoId: "newrepo" as string | undefined }]; - - // Without collectAllRepoRoots, only api would be cleaned up. - // With it, both are included. - const roots = collectAllRepoRoots([persistedLaneSources, newAllocatedSources], "/default", wsConfig); - assert(roots.includes("/repos/api"), "cleanup includes api (from persisted)"); - assert(roots.includes("/repos/newrepo"), "cleanup includes newrepo (from resumed wave)"); - assert(roots.includes("/default"), "cleanup includes default"); - assertEqual(roots.length, 3, "3 unique roots for cleanup"); -} + // 2.10: Checkpoint round-trip through validatePersistedState preserves repo attribution + { + console.log( + " ▸ checkpoint round-trip: serialize → validate → lanes[].repoId + tasks[].repoId survive", + ); -// 2.9: v1 fallback parity — reconstructAllocatedLanes + collectAllRepoRoots in repo mode -{ - console.log(" ▸ v1 fallback: reconstructAllocatedLanes + collectAllRepoRoots unchanged for v1 state"); - const v1Lanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/work/wt-1", - branch: "orch/batch-1-lane-1", - taskIds: ["T1"], - // no repoId — v1 behavior - }, - ]; - - const allocated = reconstructAllocatedLanes(v1Lanes); - assertEqual(allocated.length, 1, "v1 parity: 1 lane reconstructed"); - assertEqual(allocated[0].repoId, undefined, "v1 parity: no repoId"); - - // collectAllRepoRoots with v1 lanes + null workspace → only default - const roots = collectAllRepoRoots([allocated], "/myrepo", null); - assertEqual(roots.length, 1, "v1 parity: only default root"); - assert(roots.includes("/myrepo"), "v1 parity: default root present"); -} + const persistedLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/work/wt-1", + branch: "orch/batch-1-lane-1", + taskIds: ["T1"], + repoId: "api", + }, + ]; -// 2.10: Checkpoint round-trip through validatePersistedState preserves repo attribution -{ - console.log(" ▸ checkpoint round-trip: serialize → validate → lanes[].repoId + tasks[].repoId survive"); + const allocated = reconstructAllocatedLanes(persistedLanes); + + const state: MinimalBatchState = { + phase: "paused", + batchId: "rt-batch", + baseBranch: "main", + mode: "workspace", + startedAt: Date.now() - 5000, + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + totalTasks: 1, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [], + }; - const persistedLanes = [ - { - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/work/wt-1", - branch: "orch/batch-1-lane-1", - taskIds: ["T1"], - repoId: "api", - }, - ]; - - const allocated = reconstructAllocatedLanes(persistedLanes); - - const state: MinimalBatchState = { - phase: "paused", - batchId: "rt-batch", - baseBranch: "main", - mode: "workspace", - startedAt: Date.now() - 5000, - endedAt: null, - currentWaveIndex: 0, - totalWaves: 1, - totalTasks: 1, - succeededTasks: 0, - failedTasks: 0, - skippedTasks: 0, - blockedTasks: 0, - blockedTaskIds: new Set(), - errors: [], - mergeResults: [], - }; + const outcomes: any[] = [ + { + taskId: "T1", + status: "running", + startTime: 1000, + endTime: null, + exitReason: "", + sessionName: "orch-lane-1", + doneFileFound: false, + }, + ]; - const outcomes: any[] = [ - { taskId: "T1", status: "running", startTime: 1000, endTime: null, exitReason: "", sessionName: "orch-lane-1", doneFileFound: false }, - ]; + // Serialize + const json = serializeBatchState(state, [["T1"]], allocated, outcomes); + const raw = JSON.parse(json); - // Serialize - const json = serializeBatchState(state, [["T1"]], allocated, outcomes); - const raw = JSON.parse(json); + // Manually set taskFolder (normally done by persistRuntimeState enrichment) + raw.tasks[0].taskFolder = "/tasks/T1"; - // Manually set taskFolder (normally done by persistRuntimeState enrichment) - raw.tasks[0].taskFolder = "/tasks/T1"; + // Validate (simulates loadBatchState → validatePersistedState) + const validated = validatePersistedState(raw); - // Validate (simulates loadBatchState → validatePersistedState) - const validated = validatePersistedState(raw); + assertEqual(validated.lanes.length, 1, "round-trip: 1 lane"); + assertEqual(validated.lanes[0].repoId, "api", "round-trip: lane repoId preserved"); + assertEqual(validated.lanes[0].laneNumber, 1, "round-trip: lane number preserved"); + assertEqual(validated.lanes[0].laneSessionId, "orch-lane-1", "round-trip: session preserved"); - assertEqual(validated.lanes.length, 1, "round-trip: 1 lane"); - assertEqual(validated.lanes[0].repoId, "api", "round-trip: lane repoId preserved"); - assertEqual(validated.lanes[0].laneNumber, 1, "round-trip: lane number preserved"); - assertEqual(validated.lanes[0].laneSessionId, "orch-lane-1", "round-trip: session preserved"); + assertEqual(validated.tasks.length, 1, "round-trip: 1 task"); + assertEqual(validated.tasks[0].taskId, "T1", "round-trip: task ID preserved"); + assertEqual(validated.tasks[0].laneNumber, 1, "round-trip: task lane number preserved"); - assertEqual(validated.tasks.length, 1, "round-trip: 1 task"); - assertEqual(validated.tasks[0].taskId, "T1", "round-trip: task ID preserved"); - assertEqual(validated.tasks[0].laneNumber, 1, "round-trip: task lane number preserved"); + // Validate is also usable for next resume + const reReconstruct = reconstructAllocatedLanes(validated.lanes); + assertEqual(reReconstruct.length, 1, "re-reconstruct: 1 lane"); + assertEqual( + reReconstruct[0].repoId, + "api", + "re-reconstruct: repoId preserved across pause/resume", + ); + } - // Validate is also usable for next resume - const reReconstruct = reconstructAllocatedLanes(validated.lanes); - assertEqual(reReconstruct.length, 1, "re-reconstruct: 1 lane"); - assertEqual(reReconstruct[0].repoId, "api", "re-reconstruct: repoId preserved across pause/resume"); -} + // ── TP-007 Step 2 additional tests ─────────────────────────────────── -// ── TP-007 Step 2 additional tests ─────────────────────────────────── + // 2.11: Task repo carry-forward via persistedTasks parameter + { + console.log( + " ▸ reconstructAllocatedLanes: persistedTasks carries repo fields for archived tasks", + ); + const persistedLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/wt/1", + branch: "b-1", + taskIds: ["T1", "T2"], + repoId: "api", + }, + ]; + const persistedTasks = [ + { taskId: "T1", repoId: "api", resolvedRepoId: "api", taskFolder: "/tasks/T1" }, + { taskId: "T2", repoId: "api", resolvedRepoId: "api", taskFolder: "/tasks/T2" }, + ]; -// 2.11: Task repo carry-forward via persistedTasks parameter -{ - console.log(" ▸ reconstructAllocatedLanes: persistedTasks carries repo fields for archived tasks"); - const persistedLanes = [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "orch-lane-1", - worktreePath: "/wt/1", branch: "b-1", taskIds: ["T1", "T2"], repoId: "api", - }, - ]; - const persistedTasks = [ - { taskId: "T1", repoId: "api", resolvedRepoId: "api", taskFolder: "/tasks/T1" }, - { taskId: "T2", repoId: "api", resolvedRepoId: "api", taskFolder: "/tasks/T2" }, - ]; - - const allocated = reconstructAllocatedLanes(persistedLanes, persistedTasks); - assertEqual(allocated[0].tasks[0].task?.promptRepoId, "api", "task-carry: T1 promptRepoId"); - assertEqual(allocated[0].tasks[0].task?.resolvedRepoId, "api", "task-carry: T1 resolvedRepoId"); - assertEqual(allocated[0].tasks[0].task?.taskFolder, "/tasks/T1", "task-carry: T1 taskFolder"); - assertEqual(allocated[0].tasks[1].task?.promptRepoId, "api", "task-carry: T2 promptRepoId"); - - // Serialize and verify repo fields round-trip - const state: MinimalBatchState = { - phase: "executing", batchId: "B1", baseBranch: "main", mode: "workspace", - startedAt: Date.now(), endedAt: null, currentWaveIndex: 0, totalWaves: 1, - totalTasks: 2, succeededTasks: 1, failedTasks: 0, skippedTasks: 0, - blockedTasks: 0, blockedTaskIds: new Set(), errors: [], mergeResults: [], - }; - const outcomes = [ - { taskId: "T1", status: "succeeded", startTime: 1000, endTime: 2000, exitReason: "done", sessionName: "orch-lane-1", doneFileFound: true }, - { taskId: "T2", status: "running", startTime: 1000, endTime: null, exitReason: "", sessionName: "orch-lane-1", doneFileFound: false }, - ]; - const json = serializeBatchState(state, [["T1", "T2"]], allocated, outcomes); - const parsed = JSON.parse(json); - const t1 = parsed.tasks.find((t: any) => t.taskId === "T1"); - const t2 = parsed.tasks.find((t: any) => t.taskId === "T2"); - assertEqual(t1.repoId, "api", "task-carry-roundtrip: T1 repoId in output"); - assertEqual(t1.resolvedRepoId, "api", "task-carry-roundtrip: T1 resolvedRepoId in output"); - assertEqual(t2.repoId, "api", "task-carry-roundtrip: T2 repoId in output"); -} + const allocated = reconstructAllocatedLanes(persistedLanes, persistedTasks); + assertEqual(allocated[0].tasks[0].task?.promptRepoId, "api", "task-carry: T1 promptRepoId"); + assertEqual(allocated[0].tasks[0].task?.resolvedRepoId, "api", "task-carry: T1 resolvedRepoId"); + assertEqual(allocated[0].tasks[0].task?.taskFolder, "/tasks/T1", "task-carry: T1 taskFolder"); + assertEqual(allocated[0].tasks[1].task?.promptRepoId, "api", "task-carry: T2 promptRepoId"); + + // Serialize and verify repo fields round-trip + const state: MinimalBatchState = { + phase: "executing", + batchId: "B1", + baseBranch: "main", + mode: "workspace", + startedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + totalTasks: 2, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [], + }; + const outcomes = [ + { + taskId: "T1", + status: "succeeded", + startTime: 1000, + endTime: 2000, + exitReason: "done", + sessionName: "orch-lane-1", + doneFileFound: true, + }, + { + taskId: "T2", + status: "running", + startTime: 1000, + endTime: null, + exitReason: "", + sessionName: "orch-lane-1", + doneFileFound: false, + }, + ]; + const json = serializeBatchState(state, [["T1", "T2"]], allocated, outcomes); + const parsed = JSON.parse(json); + const t1 = parsed.tasks.find((t: any) => t.taskId === "T1"); + const t2 = parsed.tasks.find((t: any) => t.taskId === "T2"); + assertEqual(t1.repoId, "api", "task-carry-roundtrip: T1 repoId in output"); + assertEqual(t1.resolvedRepoId, "api", "task-carry-roundtrip: T1 resolvedRepoId in output"); + assertEqual(t2.repoId, "api", "task-carry-roundtrip: T2 repoId in output"); + } -// 2.12: Without persistedTasks, tasks have null task stub (v1 compat) -{ - console.log(" ▸ reconstructAllocatedLanes: without persistedTasks, task stubs are null (backward compat)"); - const persistedLanes = [ - { - laneNumber: 1, laneId: "lane-1", laneSessionId: "s1", - worktreePath: "/wt/1", branch: "b-1", taskIds: ["T1"], - }, - ]; + // 2.12: Without persistedTasks, tasks have null task stub (v1 compat) + { + console.log( + " ▸ reconstructAllocatedLanes: without persistedTasks, task stubs are null (backward compat)", + ); + const persistedLanes = [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "s1", + worktreePath: "/wt/1", + branch: "b-1", + taskIds: ["T1"], + }, + ]; - const allocated = reconstructAllocatedLanes(persistedLanes); - assertEqual(allocated[0].tasks[0].task, null, "no-tasks-param: task stub is null"); -} + const allocated = reconstructAllocatedLanes(persistedLanes); + assertEqual(allocated[0].tasks[0].task, null, "no-tasks-param: task stub is null"); + } -// 2.13: Blocked counter — persisted-blocked in unvisited waves counted at resume init -{ - console.log(" ▸ blocked counter: persisted-blocked tasks in unvisited waves counted at resume init"); - - // Simulate: 3 waves, paused at wave 1 (0-indexed). T3 (wave 2) is blocked - // but wave 2 was never entered. blockedTasks = 1 (only T-fail-dep from wave 1). - const wavePlan = [["T1", "T-fail"], ["T-fail-dep"], ["T3"]]; - const persistedBlockedTaskIds = new Set(["T-fail-dep", "T3"]); - const persistedBlockedTasks = 1; // Only T-fail-dep was counted (wave 1 was entered) - const resumeWaveIndex = 2; // Resume at wave 2 (T-fail-dep in wave 1 was already handled) - - // Count persisted-blocked tasks in unvisited waves (>= resumeWaveIndex) - let uncountedBlocked = 0; - for (let wi = resumeWaveIndex; wi < wavePlan.length; wi++) { - for (const taskId of wavePlan[wi]) { - if (persistedBlockedTaskIds.has(taskId)) { - uncountedBlocked++; + // 2.13: Blocked counter — persisted-blocked in unvisited waves counted at resume init + { + console.log( + " ▸ blocked counter: persisted-blocked tasks in unvisited waves counted at resume init", + ); + + // Simulate: 3 waves, paused at wave 1 (0-indexed). T3 (wave 2) is blocked + // but wave 2 was never entered. blockedTasks = 1 (only T-fail-dep from wave 1). + const wavePlan = [["T1", "T-fail"], ["T-fail-dep"], ["T3"]]; + const persistedBlockedTaskIds = new Set(["T-fail-dep", "T3"]); + const persistedBlockedTasks = 1; // Only T-fail-dep was counted (wave 1 was entered) + const resumeWaveIndex = 2; // Resume at wave 2 (T-fail-dep in wave 1 was already handled) + + // Count persisted-blocked tasks in unvisited waves (>= resumeWaveIndex) + let uncountedBlocked = 0; + for (let wi = resumeWaveIndex; wi < wavePlan.length; wi++) { + for (const taskId of wavePlan[wi]) { + if (persistedBlockedTaskIds.has(taskId)) { + uncountedBlocked++; + } } } - } - const totalBlocked = persistedBlockedTasks + uncountedBlocked; - assertEqual(uncountedBlocked, 1, "blocked-unvisited: T3 is 1 uncounted task"); - assertEqual(totalBlocked, 2, "blocked-unvisited: total = 1 (carried) + 1 (T3)"); + const totalBlocked = persistedBlockedTasks + uncountedBlocked; + assertEqual(uncountedBlocked, 1, "blocked-unvisited: T3 is 1 uncounted task"); + assertEqual(totalBlocked, 2, "blocked-unvisited: total = 1 (carried) + 1 (T3)"); - // Verify per-wave counting doesn't double-count - // Wave 2 has T3 in persistedBlockedTaskIds → excluded by guard - const wave2BlockedInLoop = wavePlan[2].filter( - taskId => persistedBlockedTaskIds.has(taskId) && !persistedBlockedTaskIds.has(taskId), - ); - assertEqual(wave2BlockedInLoop.length, 0, "blocked-unvisited: T3 not double-counted in loop"); -} + // Verify per-wave counting doesn't double-count + // Wave 2 has T3 in persistedBlockedTaskIds → excluded by guard + const wave2BlockedInLoop = wavePlan[2].filter( + (taskId) => persistedBlockedTaskIds.has(taskId) && !persistedBlockedTaskIds.has(taskId), + ); + assertEqual(wave2BlockedInLoop.length, 0, "blocked-unvisited: T3 not double-counted in loop"); + } -// 2.14: Blocked counter — all blocked tasks in visited waves → no uncounted -{ - console.log(" ▸ blocked counter: all blocked tasks in already-visited waves → uncounted = 0"); - const wavePlan = [["T1", "T-fail"], ["T-dep"]]; - const persistedBlockedTaskIds = new Set(["T-dep"]); - const resumeWaveIndex = 1; // Resume at wave 1 where T-dep lives - - let uncountedBlocked = 0; - for (let wi = resumeWaveIndex; wi < wavePlan.length; wi++) { - for (const taskId of wavePlan[wi]) { - if (persistedBlockedTaskIds.has(taskId)) { - uncountedBlocked++; + // 2.14: Blocked counter — all blocked tasks in visited waves → no uncounted + { + console.log(" ▸ blocked counter: all blocked tasks in already-visited waves → uncounted = 0"); + const wavePlan = [["T1", "T-fail"], ["T-dep"]]; + const persistedBlockedTaskIds = new Set(["T-dep"]); + const resumeWaveIndex = 1; // Resume at wave 1 where T-dep lives + + let uncountedBlocked = 0; + for (let wi = resumeWaveIndex; wi < wavePlan.length; wi++) { + for (const taskId of wavePlan[wi]) { + if (persistedBlockedTaskIds.has(taskId)) { + uncountedBlocked++; + } } } - } - - // T-dep IS in wave 1 which is >= resumeWaveIndex, so it's counted here. - // But it was also counted in the prior run's wave loop. The key is: was the wave entered? - // If resumeWaveIndex = 1, it means wave 1 had incomplete tasks. The blocked counter - // for T-dep may or may not have been incremented. If T-dep was blocked DURING wave 1 - // execution, engine.ts counted it. If T-dep was blocked BEFORE wave 1 entered (from - // reconciliation), the old code would have missed it. - // - // The fix counts ALL persisted-blocked in unvisited waves. Wave 1 IS the resume wave, - // so T-dep at index 1 is counted. This is correct because if T-dep was already counted - // in the prior run, it wouldn't be in resumeWaveIndex's wave — it would have been - // skipped and the resume would start at wave 2. - assertEqual(uncountedBlocked, 1, "blocked-visited: T-dep counted at resume init"); -} -// 2.15: Re-exec merge indexing — sentinel waveIndex -1 produces valid persistence -{ - console.log(" ▸ re-exec merge: sentinel waveIndex -1 produces waveIndex 0 in persisted state"); - const state: MinimalBatchState = { - phase: "executing", batchId: "B-reexec", baseBranch: "main", mode: "repo", - startedAt: Date.now(), endedAt: null, currentWaveIndex: 0, totalWaves: 2, - totalTasks: 3, succeededTasks: 1, failedTasks: 0, skippedTasks: 0, - blockedTasks: 0, blockedTaskIds: new Set(), errors: [], - mergeResults: [ - // Re-exec merge with sentinel - { waveIndex: -1, status: "succeeded", failedLane: null, failureReason: null, laneResults: [], totalDurationMs: 100 }, - // Normal wave 1 merge - { waveIndex: 1, status: "succeeded", failedLane: null, failureReason: null, laneResults: [], totalDurationMs: 200 }, - // Normal wave 2 merge - { waveIndex: 2, status: "succeeded", failedLane: null, failureReason: null, laneResults: [], totalDurationMs: 300 }, - ], - }; + // T-dep IS in wave 1 which is >= resumeWaveIndex, so it's counted here. + // But it was also counted in the prior run's wave loop. The key is: was the wave entered? + // If resumeWaveIndex = 1, it means wave 1 had incomplete tasks. The blocked counter + // for T-dep may or may not have been incremented. If T-dep was blocked DURING wave 1 + // execution, engine.ts counted it. If T-dep was blocked BEFORE wave 1 entered (from + // reconciliation), the old code would have missed it. + // + // The fix counts ALL persisted-blocked in unvisited waves. Wave 1 IS the resume wave, + // so T-dep at index 1 is counted. This is correct because if T-dep was already counted + // in the prior run, it wouldn't be in resumeWaveIndex's wave — it would have been + // skipped and the resume would start at wave 2. + assertEqual(uncountedBlocked, 1, "blocked-visited: T-dep counted at resume init"); + } + + // 2.15: Re-exec merge indexing — sentinel waveIndex -1 produces valid persistence + { + console.log(" ▸ re-exec merge: sentinel waveIndex -1 produces waveIndex 0 in persisted state"); + const state: MinimalBatchState = { + phase: "executing", + batchId: "B-reexec", + baseBranch: "main", + mode: "repo", + startedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 2, + totalTasks: 3, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [ + // Re-exec merge with sentinel + { + waveIndex: -1, + status: "succeeded", + failedLane: null, + failureReason: null, + laneResults: [], + totalDurationMs: 100, + }, + // Normal wave 1 merge + { + waveIndex: 1, + status: "succeeded", + failedLane: null, + failureReason: null, + laneResults: [], + totalDurationMs: 200, + }, + // Normal wave 2 merge + { + waveIndex: 2, + status: "succeeded", + failedLane: null, + failureReason: null, + laneResults: [], + totalDurationMs: 300, + }, + ], + }; - const json = serializeBatchState(state, [["T1"], ["T2"], ["T3"]], [], []); - const parsed = JSON.parse(json); + const json = serializeBatchState(state, [["T1"], ["T2"], ["T3"]], [], []); + const parsed = JSON.parse(json); - assertEqual(parsed.mergeResults.length, 3, "re-exec-merge: 3 merge results"); - assertEqual(parsed.mergeResults[0].waveIndex, 0, "re-exec-merge: sentinel -1 clamped to 0"); - assertEqual(parsed.mergeResults[1].waveIndex, 0, "re-exec-merge: wave 1 normalized to 0"); - assertEqual(parsed.mergeResults[2].waveIndex, 1, "re-exec-merge: wave 2 normalized to 1"); + assertEqual(parsed.mergeResults.length, 3, "re-exec-merge: 3 merge results"); + assertEqual(parsed.mergeResults[0].waveIndex, 0, "re-exec-merge: sentinel -1 clamped to 0"); + assertEqual(parsed.mergeResults[1].waveIndex, 0, "re-exec-merge: wave 1 normalized to 0"); + assertEqual(parsed.mergeResults[2].waveIndex, 1, "re-exec-merge: wave 2 normalized to 1"); - // All waveIndex values are valid (>= 0) - for (const mr of parsed.mergeResults) { - assert(mr.waveIndex >= 0, `re-exec-merge: waveIndex ${mr.waveIndex} is non-negative`); + // All waveIndex values are valid (>= 0) + for (const mr of parsed.mergeResults) { + assert(mr.waveIndex >= 0, `re-exec-merge: waveIndex ${mr.waveIndex} is non-negative`); + } } -} -// 2.16: Re-exec merge — old waveIndex=0 backward compat -{ - console.log(" ▸ re-exec merge: old waveIndex=0 (pre-fix) also clamps to 0"); - const state: MinimalBatchState = { - phase: "executing", batchId: "B-old", baseBranch: "main", mode: "repo", - startedAt: Date.now(), endedAt: null, currentWaveIndex: 0, totalWaves: 1, - totalTasks: 1, succeededTasks: 1, failedTasks: 0, skippedTasks: 0, - blockedTasks: 0, blockedTaskIds: new Set(), errors: [], - mergeResults: [ - { waveIndex: 0, status: "succeeded", failedLane: null, failureReason: null, laneResults: [], totalDurationMs: 50 }, - ], - }; + // 2.16: Re-exec merge — old waveIndex=0 backward compat + { + console.log(" ▸ re-exec merge: old waveIndex=0 (pre-fix) also clamps to 0"); + const state: MinimalBatchState = { + phase: "executing", + batchId: "B-old", + baseBranch: "main", + mode: "repo", + startedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + totalTasks: 1, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [ + { + waveIndex: 0, + status: "succeeded", + failedLane: null, + failureReason: null, + laneResults: [], + totalDurationMs: 50, + }, + ], + }; - const json = serializeBatchState(state, [["T1"]], [], []); - const parsed = JSON.parse(json); - assertEqual(parsed.mergeResults[0].waveIndex, 0, "old-reexec: 0 → Math.max(0, -1) = 0"); - assert(parsed.mergeResults[0].waveIndex >= 0, "old-reexec: waveIndex is non-negative"); -} + const json = serializeBatchState(state, [["T1"]], [], []); + const parsed = JSON.parse(json); + assertEqual(parsed.mergeResults[0].waveIndex, 0, "old-reexec: 0 → Math.max(0, -1) = 0"); + assert(parsed.mergeResults[0].waveIndex >= 0, "old-reexec: waveIndex is non-negative"); + } -// 2.17: Mixed-repo checkpoint: tasks from different repos preserve attribution -{ - console.log(" ▸ mixed-repo checkpoint: tasks from 2 repos preserve attribution through serialize"); - const persistedLanes = [ - { - laneNumber: 1, laneId: "l-1", laneSessionId: "s-1", - worktreePath: "/wt/api-1", branch: "b-1", taskIds: ["TA"], repoId: "api", - }, - { - laneNumber: 2, laneId: "l-2", laneSessionId: "s-2", - worktreePath: "/wt/fe-1", branch: "b-2", taskIds: ["TF"], repoId: "frontend", - }, - ]; - const persistedTasks = [ - { taskId: "TA", repoId: "api", resolvedRepoId: "api", taskFolder: "/tasks/TA" }, - { taskId: "TF", repoId: "frontend", resolvedRepoId: "frontend", taskFolder: "/tasks/TF" }, - ]; - - const allocated = reconstructAllocatedLanes(persistedLanes, persistedTasks); - const state: MinimalBatchState = { - phase: "executing", batchId: "B-mixed", baseBranch: "main", mode: "workspace", - startedAt: Date.now(), endedAt: null, currentWaveIndex: 0, totalWaves: 1, - totalTasks: 2, succeededTasks: 0, failedTasks: 0, skippedTasks: 0, - blockedTasks: 0, blockedTaskIds: new Set(), errors: [], mergeResults: [], - }; - const outcomes = [ - { taskId: "TA", status: "succeeded", startTime: 1000, endTime: 2000, exitReason: "done", sessionName: "s-1", doneFileFound: true }, - { taskId: "TF", status: "failed", startTime: 1000, endTime: 2000, exitReason: "crash", sessionName: "s-2", doneFileFound: false }, - ]; - - const json = serializeBatchState(state, [["TA", "TF"]], allocated, outcomes); - const parsed = JSON.parse(json); - - // Both lanes preserved - assertEqual(parsed.lanes.length, 2, "mixed-repo: 2 lanes"); - assertEqual(parsed.lanes[0].repoId, "api", "mixed-repo: lane 1 is api"); - assertEqual(parsed.lanes[1].repoId, "frontend", "mixed-repo: lane 2 is frontend"); - - // Both tasks have repo attribution - const ta = parsed.tasks.find((t: any) => t.taskId === "TA"); - const tf = parsed.tasks.find((t: any) => t.taskId === "TF"); - assertEqual(ta.repoId, "api", "mixed-repo: TA repoId"); - assertEqual(ta.resolvedRepoId, "api", "mixed-repo: TA resolvedRepoId"); - assertEqual(tf.repoId, "frontend", "mixed-repo: TF repoId"); - assertEqual(tf.resolvedRepoId, "frontend", "mixed-repo: TF resolvedRepoId"); -} + // 2.17: Mixed-repo checkpoint: tasks from different repos preserve attribution + { + console.log( + " ▸ mixed-repo checkpoint: tasks from 2 repos preserve attribution through serialize", + ); + const persistedLanes = [ + { + laneNumber: 1, + laneId: "l-1", + laneSessionId: "s-1", + worktreePath: "/wt/api-1", + branch: "b-1", + taskIds: ["TA"], + repoId: "api", + }, + { + laneNumber: 2, + laneId: "l-2", + laneSessionId: "s-2", + worktreePath: "/wt/fe-1", + branch: "b-2", + taskIds: ["TF"], + repoId: "frontend", + }, + ]; + const persistedTasks = [ + { taskId: "TA", repoId: "api", resolvedRepoId: "api", taskFolder: "/tasks/TA" }, + { taskId: "TF", repoId: "frontend", resolvedRepoId: "frontend", taskFolder: "/tasks/TF" }, + ]; -// ═══════════════════════════════════════════════════════════════════════ -// Summary -// ═══════════════════════════════════════════════════════════════════════ + const allocated = reconstructAllocatedLanes(persistedLanes, persistedTasks); + const state: MinimalBatchState = { + phase: "executing", + batchId: "B-mixed", + baseBranch: "main", + mode: "workspace", + startedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + totalTasks: 2, + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: new Set(), + errors: [], + mergeResults: [], + }; + const outcomes = [ + { + taskId: "TA", + status: "succeeded", + startTime: 1000, + endTime: 2000, + exitReason: "done", + sessionName: "s-1", + doneFileFound: true, + }, + { + taskId: "TF", + status: "failed", + startTime: 1000, + endTime: 2000, + exitReason: "crash", + sessionName: "s-2", + doneFileFound: false, + }, + ]; -console.log("\n══════════════════════════════════════"); -console.log(` Results: ${passed} passed, ${failed} failed`); -if (failures.length > 0) { - console.log("\n Failed:"); - for (const f of failures) { - console.log(` • ${f}`); - } -} -console.log("══════════════════════════════════════\n"); + const json = serializeBatchState(state, [["TA", "TF"]], allocated, outcomes); + const parsed = JSON.parse(json); -if (failed > 0) throw new Error(`${failed} test(s) failed`); + // Both lanes preserved + assertEqual(parsed.lanes.length, 2, "mixed-repo: 2 lanes"); + assertEqual(parsed.lanes[0].repoId, "api", "mixed-repo: lane 1 is api"); + assertEqual(parsed.lanes[1].repoId, "frontend", "mixed-repo: lane 2 is frontend"); + + // Both tasks have repo attribution + const ta = parsed.tasks.find((t: any) => t.taskId === "TA"); + const tf = parsed.tasks.find((t: any) => t.taskId === "TF"); + assertEqual(ta.repoId, "api", "mixed-repo: TA repoId"); + assertEqual(ta.resolvedRepoId, "api", "mixed-repo: TA resolvedRepoId"); + assertEqual(tf.repoId, "frontend", "mixed-repo: TF repoId"); + assertEqual(tf.resolvedRepoId, "frontend", "mixed-repo: TF resolvedRepoId"); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Summary + // ═══════════════════════════════════════════════════════════════════════ + + console.log("\n══════════════════════════════════════"); + console.log(` Results: ${passed} passed, ${failed} failed`); + if (failures.length > 0) { + console.log("\n Failed:"); + for (const f of failures) { + console.log(` • ${f}`); + } + } + console.log("══════════════════════════════════════\n"); + if (failed > 0) throw new Error(`${failed} test(s) failed`); } // end runAllTests // ── Dual-mode execution ────────────────────────────────────────────── diff --git a/extensions/tests/orch-supervisor-recovery-tools.test.ts b/extensions/tests/orch-supervisor-recovery-tools.test.ts index 2c565595..82700048 100644 --- a/extensions/tests/orch-supervisor-recovery-tools.test.ts +++ b/extensions/tests/orch-supervisor-recovery-tools.test.ts @@ -21,16 +21,10 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Read extension.ts source for structural verification -const extensionSource = readFileSync( - join(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", -); +const extensionSource = readFileSync(join(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); // Read dashboard server source for telemetry verification -const serverSource = readFileSync( - join(__dirname, "..", "..", "dashboard", "server.cjs"), - "utf-8", -); +const serverSource = readFileSync(join(__dirname, "..", "..", "dashboard", "server.cjs"), "utf-8"); // Read dashboard client source for UI verification const appSource = readFileSync( @@ -65,7 +59,7 @@ describe("1.x: read_agent_status tool", () => { it("1.2: has optional lane number parameter", () => { const block = getToolBlock("read_agent_status"); expect(block).toContain("lane:"); - expect(block).toContain("Type.Optional(Type.Number("); + expect(block).toContainNormalized("Type.Optional(Type.Number("); }); it("1.3: has description and promptSnippet", () => { @@ -353,7 +347,12 @@ describe("6.x: Dashboard client merge telemetry rendering", () => { describe("7.x: All recovery tools are registered", () => { it("7.1: exactly 4 new supervisor recovery tools registered", () => { - const toolNames = ["read_agent_status", "trigger_wrap_up", "read_lane_logs", "list_active_agents"]; + const toolNames = [ + "read_agent_status", + "trigger_wrap_up", + "read_lane_logs", + "list_active_agents", + ]; for (const name of toolNames) { const regex = new RegExp(`name:\\s*"${name}"`, "g"); const matches = extensionSource.match(regex); @@ -362,7 +361,12 @@ describe("7.x: All recovery tools are registered", () => { }); it("7.2: all tools have execute handlers with error handling", () => { - const toolNames = ["read_agent_status", "trigger_wrap_up", "read_lane_logs", "list_active_agents"]; + const toolNames = [ + "read_agent_status", + "trigger_wrap_up", + "read_lane_logs", + "list_active_agents", + ]; for (const name of toolNames) { const block = getToolBlock(name); expect(block, `${name} should have try/catch`).toContain("} catch (err)"); diff --git a/extensions/tests/orch-supervisor-tools.test.ts b/extensions/tests/orch-supervisor-tools.test.ts index 1aedda40..e0787cf3 100644 --- a/extensions/tests/orch-supervisor-tools.test.ts +++ b/extensions/tests/orch-supervisor-tools.test.ts @@ -28,10 +28,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Read extension.ts source for structural verification -const extensionSource = readFileSync( - join(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", -); +const extensionSource = readFileSync(join(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); // ══════════════════════════════════════════════════════════════════════ // 1.x — Tool registration @@ -64,7 +61,14 @@ describe("1.x: Orchestrator tools are registered", () => { }); it("1.7: exactly 6 orchestrator tools registered (no duplicates)", () => { - const toolNames = ["orch_status", "orch_pause", "orch_resume", "orch_abort", "orch_integrate", "orch_start"]; + const toolNames = [ + "orch_status", + "orch_pause", + "orch_resume", + "orch_abort", + "orch_integrate", + "orch_start", + ]; for (const name of toolNames) { const regex = new RegExp(`name:\\s*"${name}"`, "g"); const matches = extensionSource.match(regex); @@ -73,7 +77,14 @@ describe("1.x: Orchestrator tools are registered", () => { }); it("1.8: all tools have description, promptSnippet, and promptGuidelines", () => { - const toolNames = ["orch_status", "orch_pause", "orch_resume", "orch_abort", "orch_integrate", "orch_start"]; + const toolNames = [ + "orch_status", + "orch_pause", + "orch_resume", + "orch_abort", + "orch_integrate", + "orch_start", + ]; for (const name of toolNames) { // Find the tool registration block const idx = extensionSource.indexOf(`name: "${name}"`); @@ -131,13 +142,13 @@ describe("2.x: Tool parameter schemas are correct", () => { it("2.3: orch_resume has optional force boolean parameter", () => { const block = getToolBlock("orch_resume"); expect(block).toContain("force:"); - expect(block).toContain("Type.Optional(Type.Boolean("); + expect(block).toContainNormalized("Type.Optional(Type.Boolean("); }); it("2.4: orch_abort has optional hard boolean parameter", () => { const block = getToolBlock("orch_abort"); expect(block).toContain("hard:"); - expect(block).toContain("Type.Optional(Type.Boolean("); + expect(block).toContainNormalized("Type.Optional(Type.Boolean("); }); it("2.5: orch_integrate has mode, force, and branch parameters", () => { @@ -158,7 +169,14 @@ describe("2.x: Tool parameter schemas are correct", () => { }); it("2.7: all tool execute handlers catch errors and return text results", () => { - const toolNames = ["orch_status", "orch_pause", "orch_resume", "orch_abort", "orch_integrate", "orch_start"]; + const toolNames = [ + "orch_status", + "orch_pause", + "orch_resume", + "orch_abort", + "orch_integrate", + "orch_start", + ]; for (const name of toolNames) { const block = getToolBlock(name); expect(block, `${name} should have try/catch`).toContain("} catch (err)"); diff --git a/extensions/tests/orchestrator-startup-uxv2.test.ts b/extensions/tests/orchestrator-startup-uxv2.test.ts index e33ac22d..03fda756 100644 --- a/extensions/tests/orchestrator-startup-uxv2.test.ts +++ b/extensions/tests/orchestrator-startup-uxv2.test.ts @@ -38,10 +38,7 @@ import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const extensionSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", -); +const extensionSrc = readFileSync(resolve(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); /** * Extract the body of the `WorkspaceConfigError` catch branch in the diff --git a/extensions/tests/outcome-embedded-telemetry.test.ts b/extensions/tests/outcome-embedded-telemetry.test.ts index 0936425c..17924476 100644 --- a/extensions/tests/outcome-embedded-telemetry.test.ts +++ b/extensions/tests/outcome-embedded-telemetry.test.ts @@ -31,20 +31,30 @@ describe("TP-116: outcome-embedded telemetry for batch history", () => { durationMs: 9_000, }, }); - const v2Fallback = new Map([[2, { - input: 9, - output: 9, - cacheRead: 9, - cacheWrite: 9, - costUsd: 9, - }]]); - const legacyFallback = new Map([["orch-op-lane-2", { - input: 8, - output: 8, - cacheRead: 8, - cacheWrite: 8, - costUsd: 8, - }]]); + const v2Fallback = new Map([ + [ + 2, + { + input: 9, + output: 9, + cacheRead: 9, + cacheWrite: 9, + costUsd: 9, + }, + ], + ]); + const legacyFallback = new Map([ + [ + "orch-op-lane-2", + { + input: 8, + output: 8, + cacheRead: 8, + cacheWrite: 8, + costUsd: 8, + }, + ], + ]); const tokens = resolveBatchHistoryTaskTokens(outcome, 2, v2Fallback, legacyFallback); expect(tokens).toEqual({ @@ -57,14 +67,23 @@ describe("TP-116: outcome-embedded telemetry for batch history", () => { }); it("falls back to V2 lane snapshot tokens when telemetry is absent", () => { - const outcome = makeOutcome({ telemetry: undefined, laneNumber: 3, sessionName: "orch-op-lane-3-worker" }); - const v2Fallback = new Map([[3, { - input: 10, - output: 20, - cacheRead: 30, - cacheWrite: 40, - costUsd: 0.12, - }]]); + const outcome = makeOutcome({ + telemetry: undefined, + laneNumber: 3, + sessionName: "orch-op-lane-3-worker", + }); + const v2Fallback = new Map([ + [ + 3, + { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + costUsd: 0.12, + }, + ], + ]); const tokens = resolveBatchHistoryTaskTokens(outcome, 3, v2Fallback, new Map()); expect(tokens).toEqual({ @@ -86,13 +105,18 @@ describe("TP-116: outcome-embedded telemetry for batch history", () => { laneNumber: 4, sessionName: "orch-op-lane-4-worker", }); - const v2Fallback = new Map([[4, { - input: 999, - output: 999, - cacheRead: 999, - cacheWrite: 999, - costUsd: 9.99, - }]]); + const v2Fallback = new Map([ + [ + 4, + { + input: 999, + output: 999, + cacheRead: 999, + cacheWrite: 999, + costUsd: 9.99, + }, + ], + ]); const tokens = resolveBatchHistoryTaskTokens(outcome, 4, v2Fallback, new Map()); expect(tokens).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }); diff --git a/extensions/tests/packet-home-contract.test.ts b/extensions/tests/packet-home-contract.test.ts index 561fc212..885ed731 100644 --- a/extensions/tests/packet-home-contract.test.ts +++ b/extensions/tests/packet-home-contract.test.ts @@ -78,7 +78,10 @@ const mockOrchConfig = { }; beforeEach(() => { - testRoot = join(tmpdir(), `tp-packet-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + testRoot = join( + tmpdir(), + `tp-packet-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(testRoot, { recursive: true }); counter = 0; }); @@ -99,9 +102,10 @@ describe("workspace routing.task_packet_repo contract", () => { const tasks = join(repo, "taskplane-tasks"); mkdirSync(tasks, { recursive: true }); - writeWorkspaceConfig(wsRoot, + writeWorkspaceConfig( + wsRoot, `repos:\n api:\n path: ${repo}\n` + - `routing:\n tasks_root: ${tasks}\n default_repo: api\n task_packet_repo: api\n` + `routing:\n tasks_root: ${tasks}\n default_repo: api\n task_packet_repo: api\n`, ); const config = loadWorkspaceConfig(wsRoot); @@ -116,9 +120,10 @@ describe("workspace routing.task_packet_repo contract", () => { const tasks = join(repo, "taskplane-tasks"); mkdirSync(tasks, { recursive: true }); - writeWorkspaceConfig(wsRoot, + writeWorkspaceConfig( + wsRoot, `repos:\n api:\n path: ${repo}\n` + - `routing:\n tasks_root: ${tasks}\n default_repo: api\n` + `routing:\n tasks_root: ${tasks}\n default_repo: api\n`, ); const config = loadWorkspaceConfig(wsRoot); @@ -133,9 +138,10 @@ describe("workspace routing.task_packet_repo contract", () => { const tasks = join(repo, "taskplane-tasks"); mkdirSync(tasks, { recursive: true }); - writeWorkspaceConfig(wsRoot, + writeWorkspaceConfig( + wsRoot, `repos:\n api:\n path: ${repo}\n` + - `routing:\n tasks_root: ${tasks}\n default_repo: api\n task_packet_repo: missing\n` + `routing:\n tasks_root: ${tasks}\n default_repo: api\n task_packet_repo: missing\n`, ); try { @@ -157,9 +163,10 @@ describe("workspace routing.task_packet_repo contract", () => { const tasksInRepoA = join(repoA, "taskplane-tasks"); mkdirSync(tasksInRepoA, { recursive: true }); - writeWorkspaceConfig(wsRoot, + writeWorkspaceConfig( + wsRoot, `repos:\n api:\n path: ${repoA}\n docs:\n path: ${repoB}\n` + - `routing:\n tasks_root: ${tasksInRepoA}\n default_repo: api\n task_packet_repo: docs\n` + `routing:\n tasks_root: ${tasksInRepoA}\n default_repo: api\n task_packet_repo: docs\n`, ); try { @@ -185,9 +192,10 @@ describe("cross-config task-area containment", () => { const tasksRoot = join(repo, "taskplane-tasks"); mkdirSync(tasksRoot, { recursive: true }); - writeWorkspaceConfig(wsRoot, + writeWorkspaceConfig( + wsRoot, `repos:\n api:\n path: ${repo}\n` + - `routing:\n tasks_root: ${tasksRoot}\n default_repo: api\n task_packet_repo: api\n` + `routing:\n tasks_root: ${tasksRoot}\n default_repo: api\n task_packet_repo: api\n`, ); const loadTaskConfig = () => ({ @@ -220,9 +228,10 @@ describe("cross-config task-area containment", () => { const areaPath = join(tasksRoot, "general"); mkdirSync(areaPath, { recursive: true }); - writeWorkspaceConfig(wsRoot, + writeWorkspaceConfig( + wsRoot, `repos:\n api:\n path: ${repo}\n` + - `routing:\n tasks_root: ${tasksRoot}\n default_repo: api\n task_packet_repo: api\n` + `routing:\n tasks_root: ${tasksRoot}\n default_repo: api\n task_packet_repo: api\n`, ); const loadTaskConfig = () => ({ diff --git a/extensions/tests/partial-progress.integration.test.ts b/extensions/tests/partial-progress.integration.test.ts index 428a739f..a55fede8 100644 --- a/extensions/tests/partial-progress.integration.test.ts +++ b/extensions/tests/partial-progress.integration.test.ts @@ -144,7 +144,9 @@ function makeRuntimeState(overrides?: Partial): OrchBatch } /** Build a minimal valid PersistedBatchState for validation tests */ -function makePersistedState(taskOverrides?: Array>): Record { +function makePersistedState( + taskOverrides?: Array>, +): Record { const defaultTasks = taskOverrides ?? [ { taskId: "TP-001", @@ -172,14 +174,16 @@ function makePersistedState(taskOverrides?: Array>): Rec currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/worktrees/lane-1", - branch: "task/test-lane-1-20260319T140000", - taskIds: ["TP-001"], - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/worktrees/lane-1", + branch: "task/test-lane-1-20260319T140000", + taskIds: ["TP-001"], + }, + ], tasks: defaultTasks, mergeResults: [], totalTasks: 1, @@ -190,13 +194,17 @@ function makePersistedState(taskOverrides?: Array>): Rec blockedTaskIds: [], lastError: null, errors: [], - resilience: { resumeForced: false, retryCountByScope: {}, lastFailureClass: null, repairHistory: [] }, + resilience: { + resumeForced: false, + retryCountByScope: {}, + lastFailureClass: null, + repairHistory: [], + }, diagnostics: { taskExits: {}, batchCost: 0 }, segments: [], }; } - // ═══════════════════════════════════════════════════════════════════════ // 1 — Branch Preservation Behavior Tests (Pure Functions) // ═══════════════════════════════════════════════════════════════════════ @@ -255,7 +263,10 @@ describe("resolveSavedBranchCollision", () => { it("different SHA → create-suffixed with timestamp", () => { const result = resolveSavedBranchCollision( - savedName, "abc123", "def456", "2026-03-19T14-00-00-000Z", + savedName, + "abc123", + "def456", + "2026-03-19T14-00-00-000Z", ); expect(result.action).toBe("create-suffixed"); expect(result.savedName).toBe(`${savedName}-2026-03-19T14-00-00-000Z`); @@ -268,7 +279,6 @@ describe("resolveSavedBranchCollision", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 2 — preserveFailedLaneProgress Behavior (mocked git) // ═══════════════════════════════════════════════════════════════════════ @@ -304,14 +314,10 @@ describe("applyPartialProgressToOutcomes", () => { }); it("skips unsaved results (no commits)", () => { - const outcomes: LaneTaskOutcome[] = [ - makeOutcome("TP-001", "failed"), - ]; + const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "failed")]; const ppResult: PreserveFailedLaneProgressResult = { - results: [ - { saved: false, commitCount: 0, taskId: "TP-001" }, - ], + results: [{ saved: false, commitCount: 0, taskId: "TP-001" }], preservedBranches: new Set(), unsafeBranches: new Set(), }; @@ -322,14 +328,10 @@ describe("applyPartialProgressToOutcomes", () => { }); it("skips results where save failed but commits existed (unsafe)", () => { - const outcomes: LaneTaskOutcome[] = [ - makeOutcome("TP-001", "failed"), - ]; + const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "failed")]; const ppResult: PreserveFailedLaneProgressResult = { - results: [ - { saved: false, commitCount: 5, taskId: "TP-001", error: "branch create failed" }, - ], + results: [{ saved: false, commitCount: 5, taskId: "TP-001", error: "branch create failed" }], preservedBranches: new Set(), unsafeBranches: new Set(["task/test-lane-1-batch1"]), }; @@ -364,16 +366,13 @@ describe("applyPartialProgressToOutcomes", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 3 — upsertTaskOutcome Change Detection for Partial Progress Fields // ═══════════════════════════════════════════════════════════════════════ describe("upsertTaskOutcome — partialProgress change detection", () => { it("detects change when partialProgressCommits is added", () => { - const outcomes: LaneTaskOutcome[] = [ - makeOutcome("TP-001", "failed"), - ]; + const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "failed")]; const updated = makeOutcome("TP-001", "failed", { partialProgressCommits: 3, @@ -427,7 +426,6 @@ describe("upsertTaskOutcome — partialProgress change detection", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 4 — State Contract Tests: Serialization & Validation Round-Trip // ═══════════════════════════════════════════════════════════════════════ @@ -456,9 +454,7 @@ describe("serializeBatchState — partialProgress fields", () => { const state = makeRuntimeState(); const wavePlan = [["TP-001"]]; const lanes = [makeLane(1, "task/test-lane-1-batch1", ["TP-001"])]; - const outcomes: LaneTaskOutcome[] = [ - makeOutcome("TP-001", "succeeded"), - ]; + const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "succeeded")]; const json = serializeBatchState(state, wavePlan, lanes, outcomes); const parsed = JSON.parse(json); @@ -505,9 +501,7 @@ describe("serializeBatchState — partialProgress fields", () => { const state = makeRuntimeState({ phase: "completed", endedAt: Date.now() }); const wavePlan = [["TP-001"]]; const lanes = [makeLane(1, "task/test-lane-1-batch1", ["TP-001"])]; - const outcomes: LaneTaskOutcome[] = [ - makeOutcome("TP-001", "succeeded"), - ]; + const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "succeeded")]; const json = serializeBatchState(state, wavePlan, lanes, outcomes); const parsed = JSON.parse(json); @@ -522,19 +516,21 @@ describe("serializeBatchState — partialProgress fields", () => { describe("validatePersistedState — partialProgress field validation", () => { it("accepts task with valid partialProgress fields", () => { - const state = makePersistedState([{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "failed", - taskFolder: "/tasks/TP-001", - startedAt: 1000, - endedAt: 2000, - doneFileFound: false, - exitReason: "Task failed", - partialProgressCommits: 5, - partialProgressBranch: "saved/henry-TP-001-20260319T140000", - }]); + const state = makePersistedState([ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "failed", + taskFolder: "/tasks/TP-001", + startedAt: 1000, + endedAt: 2000, + doneFileFound: false, + exitReason: "Task failed", + partialProgressCommits: 5, + partialProgressBranch: "saved/henry-TP-001-20260319T140000", + }, + ]); expect(() => validatePersistedState(state)).not.toThrow(); const validated = validatePersistedState(state); @@ -543,91 +539,100 @@ describe("validatePersistedState — partialProgress field validation", () => { }); it("accepts task without partialProgress fields (backward compat)", () => { - const state = makePersistedState([{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "succeeded", - taskFolder: "/tasks/TP-001", - startedAt: 1000, - endedAt: 2000, - doneFileFound: true, - exitReason: "Completed", - }]); + const state = makePersistedState([ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + taskFolder: "/tasks/TP-001", + startedAt: 1000, + endedAt: 2000, + doneFileFound: true, + exitReason: "Completed", + }, + ]); expect(() => validatePersistedState(state)).not.toThrow(); }); it("rejects partialProgressCommits when not a number", () => { - const state = makePersistedState([{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "failed", - taskFolder: "/tasks/TP-001", - startedAt: 1000, - endedAt: 2000, - doneFileFound: false, - exitReason: "Task failed", - partialProgressCommits: "five", - }]); + const state = makePersistedState([ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "failed", + taskFolder: "/tasks/TP-001", + startedAt: 1000, + endedAt: 2000, + doneFileFound: false, + exitReason: "Task failed", + partialProgressCommits: "five", + }, + ]); expect(() => validatePersistedState(state)).toThrow(/partialProgressCommits/); }); it("rejects partialProgressBranch when not a string", () => { - const state = makePersistedState([{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "failed", - taskFolder: "/tasks/TP-001", - startedAt: 1000, - endedAt: 2000, - doneFileFound: false, - exitReason: "Task failed", - partialProgressBranch: 42, - }]); + const state = makePersistedState([ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "failed", + taskFolder: "/tasks/TP-001", + startedAt: 1000, + endedAt: 2000, + doneFileFound: false, + exitReason: "Task failed", + partialProgressBranch: 42, + }, + ]); expect(() => validatePersistedState(state)).toThrow(/partialProgressBranch/); }); it("rejects partialProgressCommits when null", () => { - const state = makePersistedState([{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "failed", - taskFolder: "/tasks/TP-001", - startedAt: 1000, - endedAt: 2000, - doneFileFound: false, - exitReason: "Task failed", - partialProgressCommits: null, - }]); + const state = makePersistedState([ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "failed", + taskFolder: "/tasks/TP-001", + startedAt: 1000, + endedAt: 2000, + doneFileFound: false, + exitReason: "Task failed", + partialProgressCommits: null, + }, + ]); expect(() => validatePersistedState(state)).toThrow(/partialProgressCommits/); }); it("rejects partialProgressBranch when null", () => { - const state = makePersistedState([{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "failed", - taskFolder: "/tasks/TP-001", - startedAt: 1000, - endedAt: 2000, - doneFileFound: false, - exitReason: "Task failed", - partialProgressBranch: null, - }]); + const state = makePersistedState([ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "failed", + taskFolder: "/tasks/TP-001", + startedAt: 1000, + endedAt: 2000, + doneFileFound: false, + exitReason: "Task failed", + partialProgressBranch: null, + }, + ]); expect(() => validatePersistedState(state)).toThrow(/partialProgressBranch/); }); }); - // ═══════════════════════════════════════════════════════════════════════ // 5 — Unsafe Branch Tracking Contract // ═══════════════════════════════════════════════════════════════════════ @@ -664,7 +669,6 @@ describe("PreserveFailedLaneProgressResult — unsafeBranches contract", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 6 — End-to-End: Outcome → Serialize → Validate → Reconstruct // ═══════════════════════════════════════════════════════════════════════ @@ -681,10 +685,23 @@ describe("end-to-end partial progress flow", () => { // Step 2: Apply partial progress (simulating preserveFailedLaneProgress result) const ppResult: PreserveFailedLaneProgressResult = { results: [ - { saved: true, savedBranch: "saved/henry-TP-001-20260319T140000", commitCount: 3, taskId: "TP-001" }, - { saved: true, savedBranch: "saved/henry-TP-003-20260319T140000", commitCount: 1, taskId: "TP-003" }, + { + saved: true, + savedBranch: "saved/henry-TP-001-20260319T140000", + commitCount: 3, + taskId: "TP-001", + }, + { + saved: true, + savedBranch: "saved/henry-TP-003-20260319T140000", + commitCount: 1, + taskId: "TP-003", + }, ], - preservedBranches: new Set(["saved/henry-TP-001-20260319T140000", "saved/henry-TP-003-20260319T140000"]), + preservedBranches: new Set([ + "saved/henry-TP-001-20260319T140000", + "saved/henry-TP-003-20260319T140000", + ]), unsafeBranches: new Set(), }; applyPartialProgressToOutcomes(ppResult, outcomes); @@ -748,7 +765,6 @@ describe("end-to-end partial progress flow", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 7 — Integration Tests: savePartialProgress with Disposable Git Repos // ═══════════════════════════════════════════════════════════════════════ @@ -762,7 +778,11 @@ function initTestRepo(name: string): string { const repoDir = join(tempBase, name); execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); // Create initial commit on main @@ -771,7 +791,9 @@ function initTestRepo(name: string): string { execSync('git commit -m "initial commit"', { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - } catch { /* might already be main */ } + } catch { + /* might already be main */ + } return repoDir; } @@ -781,13 +803,22 @@ function cleanupTestRepo(repoDir: string): void { const parentDir = resolve(repoDir, ".."); try { rmSync(parentDir, { recursive: true, force: true }); - } catch { /* Windows may need a moment */ } + } catch { + /* Windows may need a moment */ + } } /** Add a commit to a branch in a test repo. Returns the SHA. */ -function addCommitToRepo(repoDir: string, branch: string, filename: string, content: string): string { +function addCommitToRepo( + repoDir: string, + branch: string, + filename: string, + content: string, +): string { const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }).trim(); if (currentBranch !== branch) { @@ -798,7 +829,11 @@ function addCommitToRepo(repoDir: string, branch: string, filename: string, cont execSync(`git add "${filename}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync(`git commit -m "add ${filename}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const sha = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const sha = execSync("git rev-parse HEAD", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); if (currentBranch !== branch) { execSync(`git checkout ${currentBranch}`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -818,13 +853,22 @@ describe("savePartialProgress — integration with real git", () => { repoDir = initTestRepo("spp-commits"); // Create a lane branch with 2 commits ahead of main - execSync("git checkout -b task/test-lane-1-batch1 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch1 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch1", "file1.txt", "content1"); addCommitToRepo(repoDir, "task/test-lane-1-batch1", "file2.txt", "content2"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); const result = savePartialProgress( - "task/test-lane-1-batch1", "main", "henry", "TP-001", "20260319T140000", repoDir, + "task/test-lane-1-batch1", + "main", + "henry", + "TP-001", + "20260319T140000", + repoDir, ); expect(result.saved).toBe(true); @@ -837,7 +881,10 @@ describe("savePartialProgress — integration with real git", () => { expect(check.ok).toBe(true); // Verify it points to the same SHA as the lane branch - const laneSha = runGit(["rev-parse", "refs/heads/task/test-lane-1-batch1"], repoDir).stdout.trim(); + const laneSha = runGit( + ["rev-parse", "refs/heads/task/test-lane-1-batch1"], + repoDir, + ).stdout.trim(); const savedSha = runGit(["rev-parse", `refs/heads/${result.savedBranch}`], repoDir).stdout.trim(); expect(savedSha).toBe(laneSha); }); @@ -846,10 +893,19 @@ describe("savePartialProgress — integration with real git", () => { repoDir = initTestRepo("spp-no-commits"); // Create a lane branch at same commit as main (no commits ahead) - execSync("git branch task/test-lane-1-batch2 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git branch task/test-lane-1-batch2 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const result = savePartialProgress( - "task/test-lane-1-batch2", "main", "henry", "TP-002", "20260319T140000", repoDir, + "task/test-lane-1-batch2", + "main", + "henry", + "TP-002", + "20260319T140000", + repoDir, ); expect(result.saved).toBe(false); @@ -857,19 +913,32 @@ describe("savePartialProgress — integration with real git", () => { expect(result.savedBranch).toBeUndefined(); // Verify no saved branch was created - const check = runGit(["rev-parse", "--verify", "refs/heads/saved/henry-TP-002-20260319T140000"], repoDir); + const check = runGit( + ["rev-parse", "--verify", "refs/heads/saved/henry-TP-002-20260319T140000"], + repoDir, + ); expect(check.ok).toBe(false); }); it("workspace mode includes repoId in saved branch name", () => { repoDir = initTestRepo("spp-workspace"); - execSync("git checkout -b task/test-lane-1-batch3 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch3 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch3", "ws-file.txt", "workspace content"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); const result = savePartialProgress( - "task/test-lane-1-batch3", "main", "henry", "TP-003", "20260319T140000", repoDir, "api", + "task/test-lane-1-batch3", + "main", + "henry", + "TP-003", + "20260319T140000", + repoDir, + "api", ); expect(result.saved).toBe(true); @@ -884,27 +953,47 @@ describe("savePartialProgress — integration with real git", () => { it("collision same-SHA → idempotent keep-existing", () => { repoDir = initTestRepo("spp-collision-same"); - execSync("git checkout -b task/test-lane-1-batch4 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch4 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch4", "file.txt", "content"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); // First save const result1 = savePartialProgress( - "task/test-lane-1-batch4", "main", "henry", "TP-004", "20260319T140000", repoDir, + "task/test-lane-1-batch4", + "main", + "henry", + "TP-004", + "20260319T140000", + repoDir, ); expect(result1.saved).toBe(true); expect(result1.savedBranch).toBe("saved/henry-TP-004-20260319T140000"); // Second save at same SHA → should keep existing const result2 = savePartialProgress( - "task/test-lane-1-batch4", "main", "henry", "TP-004", "20260319T140000", repoDir, + "task/test-lane-1-batch4", + "main", + "henry", + "TP-004", + "20260319T140000", + repoDir, ); expect(result2.saved).toBe(true); expect(result2.savedBranch).toBe("saved/henry-TP-004-20260319T140000"); // Both point to the same SHA - const savedSha = runGit(["rev-parse", `refs/heads/${result2.savedBranch}`], repoDir).stdout.trim(); - const laneSha = runGit(["rev-parse", "refs/heads/task/test-lane-1-batch4"], repoDir).stdout.trim(); + const savedSha = runGit( + ["rev-parse", `refs/heads/${result2.savedBranch}`], + repoDir, + ).stdout.trim(); + const laneSha = runGit( + ["rev-parse", "refs/heads/task/test-lane-1-batch4"], + repoDir, + ).stdout.trim(); expect(savedSha).toBe(laneSha); }); @@ -912,30 +1001,50 @@ describe("savePartialProgress — integration with real git", () => { repoDir = initTestRepo("spp-collision-diff"); // Create lane branch with 1 commit - execSync("git checkout -b task/test-lane-1-batch5 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch5 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch5", "file-v1.txt", "v1"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); // First save const result1 = savePartialProgress( - "task/test-lane-1-batch5", "main", "henry", "TP-005", "20260319T140000", repoDir, + "task/test-lane-1-batch5", + "main", + "henry", + "TP-005", + "20260319T140000", + repoDir, ); expect(result1.saved).toBe(true); - const firstSavedSha = runGit(["rev-parse", `refs/heads/${result1.savedBranch}`], repoDir).stdout.trim(); + const firstSavedSha = runGit( + ["rev-parse", `refs/heads/${result1.savedBranch}`], + repoDir, + ).stdout.trim(); // Add another commit to lane (changes SHA) addCommitToRepo(repoDir, "task/test-lane-1-batch5", "file-v2.txt", "v2"); // Second save at different SHA → should create suffixed branch const result2 = savePartialProgress( - "task/test-lane-1-batch5", "main", "henry", "TP-005", "20260319T140000", repoDir, + "task/test-lane-1-batch5", + "main", + "henry", + "TP-005", + "20260319T140000", + repoDir, ); expect(result2.saved).toBe(true); expect(result2.savedBranch).not.toBe(result1.savedBranch); expect(result2.savedBranch!).toMatch(/^saved\/henry-TP-005-20260319T140000-/); // Verify both saved branches exist and point to different SHAs - const secondSavedSha = runGit(["rev-parse", `refs/heads/${result2.savedBranch}`], repoDir).stdout.trim(); + const secondSavedSha = runGit( + ["rev-parse", `refs/heads/${result2.savedBranch}`], + repoDir, + ).stdout.trim(); expect(firstSavedSha).not.toBe(secondSavedSha); }); @@ -943,7 +1052,12 @@ describe("savePartialProgress — integration with real git", () => { repoDir = initTestRepo("spp-no-branch"); const result = savePartialProgress( - "nonexistent-branch", "main", "henry", "TP-006", "20260319T140000", repoDir, + "nonexistent-branch", + "main", + "henry", + "TP-006", + "20260319T140000", + repoDir, ); expect(result.saved).toBe(false); @@ -973,12 +1087,20 @@ describe("preserveFailedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pflp-happy"); // Create lane 1 branch with commits (for TP-001 which will fail) - execSync("git checkout -b task/test-lane-1-batch1 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch1 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch1", "work.txt", "partial work"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); // Create lane 2 branch at same commit (for TP-002 which succeeded) - execSync("git branch task/test-lane-2-batch1 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git branch task/test-lane-2-batch1 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const lanes: AllocatedLane[] = [ makeRealLane(1, "task/test-lane-1-batch1", ["TP-001"]), @@ -1012,7 +1134,11 @@ describe("preserveFailedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pflp-skip-success"); // Create lane branch with commits but task succeeded - execSync("git checkout -b task/test-lane-1-batch2 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch2 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch2", "merged.txt", "will be merged"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -1032,7 +1158,11 @@ describe("preserveFailedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pflp-no-commits"); // Create lane branch at same commit as main (no progress) - execSync("git branch task/test-lane-1-batch3 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git branch task/test-lane-1-batch3 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const lanes: AllocatedLane[] = [makeRealLane(1, "task/test-lane-1-batch3", ["TP-001"])]; const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "failed")]; @@ -1050,7 +1180,11 @@ describe("preserveFailedLaneProgress — integration with real git", () => { it("processes stalled tasks the same as failed", () => { repoDir = initTestRepo("pflp-stalled"); - execSync("git checkout -b task/test-lane-1-batch4 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch4 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch4", "stalled.txt", "stalled work"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -1069,14 +1203,16 @@ describe("preserveFailedLaneProgress — integration with real git", () => { it("deduplicates: multiple failed tasks sharing a lane only save once", () => { repoDir = initTestRepo("pflp-dedup"); - execSync("git checkout -b task/test-lane-1-batch5 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch5 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch5", "shared.txt", "shared work"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); // Both tasks on the same lane branch - const lanes: AllocatedLane[] = [ - makeRealLane(1, "task/test-lane-1-batch5", ["TP-001", "TP-002"]), - ]; + const lanes: AllocatedLane[] = [makeRealLane(1, "task/test-lane-1-batch5", ["TP-001", "TP-002"])]; const outcomes: LaneTaskOutcome[] = [ makeOutcome("TP-001", "failed"), makeOutcome("TP-002", "failed"), @@ -1091,7 +1227,6 @@ describe("preserveFailedLaneProgress — integration with real git", () => { }); }); - // ── TP-147: preserveSkippedLaneProgress Tests ────────────────────── describe("preserveSkippedLaneProgress — integration with real git", () => { @@ -1105,17 +1240,17 @@ describe("preserveSkippedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pslp-happy"); // Create lane 1 branch with commits (for TP-001 which will be skipped) - execSync("git checkout -b task/test-lane-1-batch1 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch1 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch1", "status.md", "partial work"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const lanes: AllocatedLane[] = [ - makeLane(1, "task/test-lane-1-batch1", ["TP-001"]), - ]; + const lanes: AllocatedLane[] = [makeLane(1, "task/test-lane-1-batch1", ["TP-001"])]; - const outcomes: LaneTaskOutcome[] = [ - makeOutcome("TP-001", "skipped"), - ]; + const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "skipped")]; const resolveRepo: ResolveRepoContext = () => ({ repoRoot: repoDir, targetBranch: "main" }); @@ -1139,7 +1274,11 @@ describe("preserveSkippedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pslp-no-commits"); // Create lane branch at same commit as main (no progress) - execSync("git branch task/test-lane-1-batch2 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git branch task/test-lane-1-batch2 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const lanes: AllocatedLane[] = [makeLane(1, "task/test-lane-1-batch2", ["TP-001"])]; const outcomes: LaneTaskOutcome[] = [makeOutcome("TP-001", "skipped")]; @@ -1158,11 +1297,19 @@ describe("preserveSkippedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pslp-filter"); // Create lane branches with commits - execSync("git checkout -b task/test-lane-1-batch3 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch3 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch3", "work1.txt", "failed task work"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - execSync("git checkout -b task/test-lane-2-batch3 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-2-batch3 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-2-batch3", "work2.txt", "succeeded task work"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); @@ -1190,13 +1337,15 @@ describe("preserveSkippedLaneProgress — integration with real git", () => { repoDir = initTestRepo("pslp-dedup"); // Create lane branch with commits for 2 skipped tasks on same lane - execSync("git checkout -b task/test-lane-1-batch4 main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git checkout -b task/test-lane-1-batch4 main", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); addCommitToRepo(repoDir, "task/test-lane-1-batch4", "shared-work.txt", "shared progress"); execSync("git checkout main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const lanes: AllocatedLane[] = [ - makeLane(1, "task/test-lane-1-batch4", ["TP-001", "TP-002"]), - ]; + const lanes: AllocatedLane[] = [makeLane(1, "task/test-lane-1-batch4", ["TP-001", "TP-002"])]; const outcomes: LaneTaskOutcome[] = [ makeOutcome("TP-001", "skipped"), @@ -1214,7 +1363,6 @@ describe("preserveSkippedLaneProgress — integration with real git", () => { }); }); - // ── TP-147: BatchTaskSummary "pending" status in persisted state ─────── describe("TP-147 — pending task status accepted in persisted state", () => { @@ -1261,16 +1409,43 @@ describe("TP-147 — pending task status accepted in persisted state", () => { // are included in the history. Verify the type contract by creating a // BatchHistorySummary-compatible object with the correct structure. const tasks: import("../taskplane/types.ts").BatchTaskSummary[] = [ - { taskId: "TP-001", taskName: "TP-001", status: "succeeded", wave: 1, lane: 1, durationMs: 5000, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, exitReason: null }, - { taskId: "TP-002", taskName: "TP-002", status: "blocked", wave: 2, lane: 0, durationMs: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, exitReason: "Blocked by upstream failure" }, - { taskId: "TP-003", taskName: "TP-003", status: "pending", wave: 2, lane: 0, durationMs: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, exitReason: null }, + { + taskId: "TP-001", + taskName: "TP-001", + status: "succeeded", + wave: 1, + lane: 1, + durationMs: 5000, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, + exitReason: null, + }, + { + taskId: "TP-002", + taskName: "TP-002", + status: "blocked", + wave: 2, + lane: 0, + durationMs: 0, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, + exitReason: "Blocked by upstream failure", + }, + { + taskId: "TP-003", + taskName: "TP-003", + status: "pending", + wave: 2, + lane: 0, + durationMs: 0, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, + exitReason: null, + }, ]; // totalTasks should equal tasks array length const totalTasks = tasks.length; expect(totalTasks).toBe(3); - expect(tasks.filter(t => t.status === "blocked").length).toBe(1); - expect(tasks.filter(t => t.status === "pending").length).toBe(1); - expect(tasks.filter(t => t.status === "succeeded").length).toBe(1); + expect(tasks.filter((t) => t.status === "blocked").length).toBe(1); + expect(tasks.filter((t) => t.status === "pending").length).toBe(1); + expect(tasks.filter((t) => t.status === "succeeded").length).toBe(1); }); }); diff --git a/extensions/tests/path-resolver-pi-scope.test.ts b/extensions/tests/path-resolver-pi-scope.test.ts index 67a99d11..8014547a 100644 --- a/extensions/tests/path-resolver-pi-scope.test.ts +++ b/extensions/tests/path-resolver-pi-scope.test.ts @@ -83,7 +83,9 @@ function makeNpmRootWithScopes(scopes: ReadonlyArray<"@earendil-works" | "@mario * `npm_config_prefix` redirecting `npm root -g`. Returns the resolved path * or throws (capturing stderr) so test assertions can match either outcome. */ -function probeResolveInChild(npmConfigPrefix: string | null): { ok: true; resolved: string } | { ok: false; stderr: string } { +function probeResolveInChild( + npmConfigPrefix: string | null, +): { ok: true; resolved: string } | { ok: false; stderr: string } { const probeScript = ` import("${pathToFileUrl(join(repoRoot, "taskplane", "path-resolver.ts"))}").then((m) => { try { diff --git a/extensions/tests/polyrepo-fixture.test.ts b/extensions/tests/polyrepo-fixture.test.ts index d790075d..1983eb67 100644 --- a/extensions/tests/polyrepo-fixture.test.ts +++ b/extensions/tests/polyrepo-fixture.test.ts @@ -33,22 +33,14 @@ import { type PolyrepoFixture, } from "./fixtures/polyrepo-builder.ts"; -import { - resolveTaskRouting, - runDiscovery, -} from "../taskplane/discovery.ts"; +import { resolveTaskRouting, runDiscovery } from "../taskplane/discovery.ts"; -import { - buildDependencyGraph, - computeWaves, - groupTasksByRepo, -} from "../taskplane/waves.ts"; +import { buildDependencyGraph, computeWaves, groupTasksByRepo } from "../taskplane/waves.ts"; import type { ParsedTask } from "../taskplane/types.ts"; // ── Shared Fixture ─────────────────────────────────────────────────── - const __dirname = dirname(fileURLToPath(import.meta.url)); let fixture: PolyrepoFixture; @@ -145,7 +137,7 @@ describe("3.x: Task discovery and routing", () => { workspaceConfig: fixture.workspaceConfig, }); - expect(result.errors.filter(e => e.code !== "DEP_SOURCE_FALLBACK")).toHaveLength(0); + expect(result.errors.filter((e) => e.code !== "DEP_SOURCE_FALLBACK")).toHaveLength(0); expect(result.pending.size).toBe(6); for (const taskId of FIXTURE_TASK_IDS) { @@ -258,7 +250,7 @@ describe("4.x: Dependency graph and wave shape", () => { const wave1Groups = groupTasksByRepo(["SH-001", "AP-001", "UI-001"], pending); expect(wave1Groups.length).toBe(3); // 3 repos - const repoIds = wave1Groups.map(g => g.repoId).sort(); + const repoIds = wave1Groups.map((g) => g.repoId).sort(); expect(repoIds).toEqual(["api", "docs", "frontend"]); // Each group has exactly 1 task in wave 1 @@ -269,7 +261,7 @@ describe("4.x: Dependency graph and wave shape", () => { // Group wave 2 tasks const wave2Groups = groupTasksByRepo(["AP-002", "UI-002"], pending); expect(wave2Groups.length).toBe(2); // api and frontend - const wave2RepoIds = wave2Groups.map(g => g.repoId).sort(); + const wave2RepoIds = wave2Groups.map((g) => g.repoId).sort(); expect(wave2RepoIds).toEqual(["api", "frontend"]); }); }); @@ -361,7 +353,7 @@ describe("5.x: Static batch-state fixture (v2-polyrepo)", () => { for (const lane of fixtureData.lanes) { expect(typeof lane.laneNumber).toBe("number"); expect(typeof lane.laneId).toBe("string"); - expect(typeof (lane.laneSessionId)).toBe("string"); + expect(typeof lane.laneSessionId).toBe("string"); expect(typeof lane.worktreePath).toBe("string"); expect(typeof lane.branch).toBe("string"); expect(Array.isArray(lane.taskIds)).toBe(true); diff --git a/extensions/tests/polyrepo-regression.test.ts b/extensions/tests/polyrepo-regression.test.ts index 5d5eda4f..5bcebbf7 100644 --- a/extensions/tests/polyrepo-regression.test.ts +++ b/extensions/tests/polyrepo-regression.test.ts @@ -138,7 +138,9 @@ function makeAllocatedLane( return { laneNumber, laneId: opts.laneId ?? (opts.repoId ? `${opts.repoId}/lane-${laneNumber}` : `lane-${laneNumber}`), - laneSessionId: opts.laneSessionId ?? (opts.repoId ? `orch-op-${opts.repoId}-lane-${laneNumber}` : `orch-op-lane-${laneNumber}`), + laneSessionId: + opts.laneSessionId ?? + (opts.repoId ? `orch-op-${opts.repoId}-lane-${laneNumber}` : `orch-op-lane-${laneNumber}`), worktreePath: opts.worktreePath ?? `/worktrees/wt-${laneNumber}`, branch: opts.branch ?? `task/op-lane-${laneNumber}-20260316T120000`, tasks, @@ -195,7 +197,7 @@ describe("1.x: /task routing — polyrepo discovery", () => { }); // No fatal errors (allow DEP_SOURCE_FALLBACK) - expect(result.errors.filter(e => e.code !== "DEP_SOURCE_FALLBACK")).toHaveLength(0); + expect(result.errors.filter((e) => e.code !== "DEP_SOURCE_FALLBACK")).toHaveLength(0); expect(result.pending.size).toBe(6); // Every task has resolvedRepoId set @@ -252,7 +254,6 @@ describe("1.x: /task routing — polyrepo discovery", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 2.x — /orch-plan: wave computation and lane allocation // ═══════════════════════════════════════════════════════════════════════ @@ -263,7 +264,7 @@ describe("2.x: /orch-plan — wave computation and lane allocation", () => { const groups = groupTasksByRepo(["SH-001", "AP-001", "UI-001"], pending); expect(groups).toHaveLength(3); - const repoIds = groups.map(g => g.repoId).sort(); + const repoIds = groups.map((g) => g.repoId).sort(); expect(repoIds).toEqual(["api", "docs", "frontend"]); // Each group has exactly 1 task @@ -277,7 +278,7 @@ describe("2.x: /orch-plan — wave computation and lane allocation", () => { const groups = groupTasksByRepo(["AP-002", "UI-002"], pending); expect(groups).toHaveLength(2); - const repoIds = groups.map(g => g.repoId).sort(); + const repoIds = groups.map((g) => g.repoId).sort(); expect(repoIds).toEqual(["api", "frontend"]); }); @@ -296,13 +297,11 @@ describe("2.x: /orch-plan — wave computation and lane allocation", () => { // Process each repo group independently (matches allocateLanes behavior) const groups = groupTasksByRepo(["SH-001", "AP-001", "UI-001"], pending); for (const group of groups) { - const assignments = assignTasksToLanes( - group.taskIds, - pending, - 3, - "affinity-first", - { S: 1, M: 2, L: 4 }, - ); + const assignments = assignTasksToLanes(group.taskIds, pending, 3, "affinity-first", { + S: 1, + M: 2, + L: 4, + }); // Each repo group has 1 task → 1 lane expect(assignments).toHaveLength(1); expect(assignments[0].lane).toBe(1); // local lane 1 within each group @@ -333,11 +332,7 @@ describe("2.x: /orch-plan — wave computation and lane allocation", () => { const pending = buildFixtureParsedTasks(fixture); const completed = new Set(); - const result = computeWaveAssignments( - pending, - completed, - DEFAULT_ORCHESTRATOR_CONFIG, - ); + const result = computeWaveAssignments(pending, completed, DEFAULT_ORCHESTRATOR_CONFIG); expect(result.errors).toHaveLength(0); expect(result.waves).toHaveLength(3); @@ -350,7 +345,6 @@ describe("2.x: /orch-plan — wave computation and lane allocation", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 3.x — Serialization: repo-aware persisted state // ═══════════════════════════════════════════════════════════════════════ @@ -390,7 +384,7 @@ describe("3.x: Serialization — repo-aware persisted state", () => { // Task records for allocated tasks have resolvedRepoId for (const task of parsed.tasks) { - if (lanes.some(l => l.tasks.some(t => t.taskId === task.taskId))) { + if (lanes.some((l) => l.tasks.some((t) => t.taskId === task.taskId))) { const expectedRepo = fixture.expectedRouting[task.taskId]; expect(task.resolvedRepoId).toBe(expectedRepo); } @@ -450,13 +444,13 @@ describe("3.x: Serialization — repo-aware persisted state", () => { const parsed = JSON.parse(json) as PersistedBatchState; // UI-001 has promptRepoId = "frontend" - const ui001Record = parsed.tasks.find(t => t.taskId === "UI-001"); + const ui001Record = parsed.tasks.find((t) => t.taskId === "UI-001"); expect(ui001Record).toBeDefined(); - expect(ui001Record!.repoId).toBe("frontend"); // serialized from promptRepoId + expect(ui001Record!.repoId).toBe("frontend"); // serialized from promptRepoId expect(ui001Record!.resolvedRepoId).toBe("frontend"); // AP-001 has no promptRepoId (uses area fallback) - const ap001Record = parsed.tasks.find(t => t.taskId === "AP-001"); + const ap001Record = parsed.tasks.find((t) => t.taskId === "AP-001"); expect(ap001Record).toBeDefined(); expect(ap001Record!.resolvedRepoId).toBe("api"); }); @@ -484,18 +478,17 @@ describe("3.x: Serialization — repo-aware persisted state", () => { // All 6 tasks should be present (from wavePlan), even future wave tasks expect(parsed.tasks).toHaveLength(6); - const taskIds = parsed.tasks.map(t => t.taskId).sort(); + const taskIds = parsed.tasks.map((t) => t.taskId).sort(); expect(taskIds).toEqual(["AP-001", "AP-002", "SH-001", "SH-002", "UI-001", "UI-002"]); // Future wave tasks are pending with no lane assignment - const sh002 = parsed.tasks.find(t => t.taskId === "SH-002"); + const sh002 = parsed.tasks.find((t) => t.taskId === "SH-002"); expect(sh002).toBeDefined(); expect(sh002!.status).toBe("pending"); expect(sh002!.laneNumber).toBe(0); // no lane assigned yet }); }); - // ═══════════════════════════════════════════════════════════════════════ // 4.x — Per-repo merge outcomes // ═══════════════════════════════════════════════════════════════════════ @@ -508,7 +501,7 @@ describe("4.x: Per-repo merge outcomes", () => { const groups = groupLanesByRepo(lanes); expect(groups).toHaveLength(3); - const repoIds = groups.map(g => g.repoId).sort(); + const repoIds = groups.map((g) => g.repoId).sort(); expect(repoIds).toEqual(["api", "docs", "frontend"]); // Each group has 1 lane @@ -525,7 +518,7 @@ describe("4.x: Per-repo merge outcomes", () => { const mergeResult: MergeWaveResult = { waveIndex: 1, // 1-based from merge module status: "succeeded", - laneResults: lanes.map(lane => ({ + laneResults: lanes.map((lane) => ({ laneNumber: lane.laneNumber, laneId: lane.laneId, sourceBranch: lane.branch, @@ -586,7 +579,7 @@ describe("4.x: Per-repo merge outcomes", () => { expect(mr.waveIndex).toBe(0); // normalized: 1-based → 0-based expect(mr.repoResults).toBeDefined(); expect(mr.repoResults!).toHaveLength(3); - const mrRepoIds = mr.repoResults!.map(r => r.repoId).sort(); + const mrRepoIds = mr.repoResults!.map((r) => r.repoId).sort(); expect(mrRepoIds).toEqual(["api", "docs", "frontend"]); }); @@ -658,18 +651,17 @@ describe("4.x: Per-repo merge outcomes", () => { expect(mr.status).toBe("partial"); expect(mr.repoResults).toBeDefined(); - const apiResult = mr.repoResults!.find(r => r.repoId === "api"); + const apiResult = mr.repoResults!.find((r) => r.repoId === "api"); expect(apiResult).toBeDefined(); expect(apiResult!.status).toBe("failed"); expect(apiResult!.failedLane).toBe(2); expect(apiResult!.failureReason).toContain("Conflict"); - const docsResult = mr.repoResults!.find(r => r.repoId === "docs"); + const docsResult = mr.repoResults!.find((r) => r.repoId === "docs"); expect(docsResult!.status).toBe("succeeded"); }); }); - // ═══════════════════════════════════════════════════════════════════════ // 5.x — Resume: reconciliation and resume-point computation // ═══════════════════════════════════════════════════════════════════════ @@ -696,28 +688,28 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { expect(reconciled).toHaveLength(6); // Wave 1 tasks: .DONE found → mark-complete - const sh001 = reconciled.find(t => t.taskId === "SH-001")!; + const sh001 = reconciled.find((t) => t.taskId === "SH-001")!; expect(sh001.action).toBe("mark-complete"); expect(sh001.doneFileFound).toBe(true); - const ap001 = reconciled.find(t => t.taskId === "AP-001")!; + const ap001 = reconciled.find((t) => t.taskId === "AP-001")!; expect(ap001.action).toBe("mark-complete"); - const ui001 = reconciled.find(t => t.taskId === "UI-001")!; + const ui001 = reconciled.find((t) => t.taskId === "UI-001")!; expect(ui001.action).toBe("mark-complete"); // Wave 2 tasks: no .DONE, no alive session, was running → mark-failed - const ap002 = reconciled.find(t => t.taskId === "AP-002")!; + const ap002 = reconciled.find((t) => t.taskId === "AP-002")!; expect(ap002.action).toBe("mark-failed"); expect(ap002.persistedStatus).toBe("running"); - const ui002 = reconciled.find(t => t.taskId === "UI-002")!; + const ui002 = reconciled.find((t) => t.taskId === "UI-002")!; expect(ui002.action).toBe("mark-failed"); expect(ui002.persistedStatus).toBe("running"); // Wave 3 task: pending, was never started, has session name from seeding // Since it has a sessionName but status is pending, dead session → mark-failed - const sh002 = reconciled.find(t => t.taskId === "SH-002")!; + const sh002 = reconciled.find((t) => t.taskId === "SH-002")!; // SH-002 has sessionName "orch-op-docs-lane-1" but status pending // With no alive session and no .DONE → mark-failed expect(["mark-failed", "pending"]).toContain(sh002.action); @@ -749,11 +741,11 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { const reconciled = reconcileTaskStates(fixtureState, aliveSessions, doneTaskIds); - const ap002 = reconciled.find(t => t.taskId === "AP-002")!; + const ap002 = reconciled.find((t) => t.taskId === "AP-002")!; expect(ap002.action).toBe("reconnect"); expect(ap002.sessionAlive).toBe(true); - const ui002 = reconciled.find(t => t.taskId === "UI-002")!; + const ui002 = reconciled.find((t) => t.taskId === "UI-002")!; expect(ui002.action).toBe("reconnect"); expect(ui002.sessionAlive).toBe(true); }); @@ -776,15 +768,15 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { expect(lanes).toHaveLength(3); - const docsLane = lanes.find(l => l.repoId === "docs")!; + const docsLane = lanes.find((l) => l.repoId === "docs")!; expect(docsLane).toBeDefined(); expect(docsLane.laneId).toBe("docs/lane-1"); - const apiLane = lanes.find(l => l.repoId === "api")!; + const apiLane = lanes.find((l) => l.repoId === "api")!; expect(apiLane).toBeDefined(); expect(apiLane.laneId).toContain("api"); - const frontendLane = lanes.find(l => l.repoId === "frontend")!; + const frontendLane = lanes.find((l) => l.repoId === "frontend")!; expect(frontendLane).toBeDefined(); }); @@ -792,8 +784,8 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { const lanes = reconstructAllocatedLanes(fixtureState.lanes, fixtureState.tasks); // Find the lane with UI-001 — should carry resolvedRepoId from persisted task - const frontendLane = lanes.find(l => l.repoId === "frontend")!; - const ui001Task = frontendLane.tasks.find(t => t.taskId === "UI-001"); + const frontendLane = lanes.find((l) => l.repoId === "frontend")!; + const ui001Task = frontendLane.tasks.find((t) => t.taskId === "UI-001"); expect(ui001Task).toBeDefined(); expect(ui001Task!.task?.resolvedRepoId).toBe("frontend"); }); @@ -829,15 +821,15 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { const resumePoint = computeResumePoint(fixtureState, reconciled); // AP-002 completed → mark-complete - const ap002 = reconciled.find(t => t.taskId === "AP-002")!; + const ap002 = reconciled.find((t) => t.taskId === "AP-002")!; expect(ap002.action).toBe("mark-complete"); // UI-002 failed → mark-failed - const ui002 = reconciled.find(t => t.taskId === "UI-002")!; + const ui002 = reconciled.find((t) => t.taskId === "UI-002")!; expect(ui002.action).toBe("mark-failed"); // TP-037 (Bug #102b): SH-002 had session seeded but never started → stays pending - const sh002 = reconciled.find(t => t.taskId === "SH-002")!; + const sh002 = reconciled.find((t) => t.taskId === "SH-002")!; expect(sh002.action).toBe("pending"); // TP-037: Wave 1 has succeeded task (AP-002) but no merge result → flagged for merge retry @@ -859,7 +851,7 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { const resumePoint = computeResumePoint(fixtureState, reconciled); // AP-002 has alive session → reconnect (NOT terminal) - const ap002 = reconciled.find(t => t.taskId === "AP-002")!; + const ap002 = reconciled.find((t) => t.taskId === "AP-002")!; expect(ap002.action).toBe("reconnect"); // Wave 1 has a non-terminal task (reconnect) → resume here @@ -869,7 +861,6 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 6.x — Collision-safe naming // ═══════════════════════════════════════════════════════════════════════ @@ -877,9 +868,7 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { describe("6.x: Collision-safe naming — polyrepo artifacts", () => { it("6.1: TMUX session names are unique across repos for same operator+lane", () => { const opId = "testop"; - const sessions = FIXTURE_REPO_IDS.map(repoId => - generateLaneSessionId("orch", 1, opId, repoId), - ); + const sessions = FIXTURE_REPO_IDS.map((repoId) => generateLaneSessionId("orch", 1, opId, repoId)); // All 3 sessions should be distinct expect(new Set(sessions).size).toBe(3); @@ -889,9 +878,7 @@ describe("6.x: Collision-safe naming — polyrepo artifacts", () => { }); it("6.2: lane IDs are unique across repos for same lane number", () => { - const laneIds = FIXTURE_REPO_IDS.map(repoId => - generateLaneId(1, repoId), - ); + const laneIds = FIXTURE_REPO_IDS.map((repoId) => generateLaneId(1, repoId)); expect(new Set(laneIds).size).toBe(3); expect(laneIds).toContain("docs/lane-1"); @@ -902,7 +889,7 @@ describe("6.x: Collision-safe naming — polyrepo artifacts", () => { it("6.3: branch names are unique across repos for same operator+lane", () => { const opId = "testop"; const batchId = "20260316T120000"; - const branches = FIXTURE_REPO_IDS.map(repoId => { + const branches = FIXTURE_REPO_IDS.map((repoId) => { // Branch name uses repoId-scoped laneId const laneId = generateLaneId(1, repoId); // Simulate generateBranchName pattern: task/{opId}-{laneId}-{batchId} @@ -954,7 +941,6 @@ describe("6.x: Collision-safe naming — polyrepo artifacts", () => { }); }); - // ═══════════════════════════════════════════════════════════════════════ // 7.x — Repo-aware persisted state validation and upconversion // ═══════════════════════════════════════════════════════════════════════ @@ -968,8 +954,8 @@ describe("7.x: Repo-aware persisted state — validation and upconversion", () = expect(validated.schemaVersion).toBe(BATCH_STATE_SCHEMA_VERSION); expect(validated.mode).toBe("workspace"); - expect(validated.tasks.every(t => t.resolvedRepoId !== undefined)).toBe(true); - expect(validated.lanes.every(l => l.repoId !== undefined)).toBe(true); + expect(validated.tasks.every((t) => t.resolvedRepoId !== undefined)).toBe(true); + expect(validated.lanes.every((l) => l.repoId !== undefined)).toBe(true); }); it("7.2: v1→v2 upconversion adds mode=repo and preserves fields", () => { @@ -1105,11 +1091,7 @@ describe("7.x: Repo-aware persisted state — validation and upconversion", () = endedAt: 5000, currentWaveIndex: 2, totalWaves: 3, - wavePlan: [ - ["SH-001", "AP-001", "UI-001"], - ["AP-002", "UI-002"], - ["SH-002"], - ], + wavePlan: [["SH-001", "AP-001", "UI-001"], ["AP-002", "UI-002"], ["SH-002"]], lanes: [ { laneNumber: 1, diff --git a/extensions/tests/process-registry.test.ts b/extensions/tests/process-registry.test.ts index 344a2011..56c10ecb 100644 --- a/extensions/tests/process-registry.test.ts +++ b/extensions/tests/process-registry.test.ts @@ -55,7 +55,11 @@ beforeEach(() => { }); afterEach(() => { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); // ── 1. Manifest CRUD ──────────────────────────────────────────────── @@ -129,14 +133,36 @@ describe("2.x: Registry snapshots", () => { const batchId = "20260330T120000"; it("2.1: buildRegistrySnapshot discovers all manifests", () => { - writeManifest(tmpDir, createManifest({ - batchId, agentId: "agent-1", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: 1234, parentPid: 1000, cwd: tmpDir, packet: null, - })); - writeManifest(tmpDir, createManifest({ - batchId, agentId: "agent-2", role: "reviewer", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: 1235, parentPid: 1000, cwd: tmpDir, packet: null, - })); + writeManifest( + tmpDir, + createManifest({ + batchId, + agentId: "agent-1", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: 1234, + parentPid: 1000, + cwd: tmpDir, + packet: null, + }), + ); + writeManifest( + tmpDir, + createManifest({ + batchId, + agentId: "agent-2", + role: "reviewer", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: 1235, + parentPid: 1000, + cwd: tmpDir, + packet: null, + }), + ); const reg = buildRegistrySnapshot(tmpDir, batchId); expect(Object.keys(reg.agents).length).toBe(2); expect(reg.agents["agent-1"]).not.toBe(undefined); @@ -198,15 +224,31 @@ describe("4.x: Agent queries", () => { function seedAgents() { const m1 = createManifest({ - batchId, agentId: "worker-1", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: process.pid, parentPid: 1000, cwd: tmpDir, packet: null, + batchId, + agentId: "worker-1", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: process.pid, + parentPid: 1000, + cwd: tmpDir, + packet: null, }); m1.status = "running"; writeManifest(tmpDir, m1); const m2 = createManifest({ - batchId, agentId: "reviewer-1", role: "reviewer", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: process.pid, parentPid: 1000, cwd: tmpDir, packet: null, + batchId, + agentId: "reviewer-1", + role: "reviewer", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: process.pid, + parentPid: 1000, + cwd: tmpDir, + packet: null, }); m2.status = "exited"; writeManifest(tmpDir, m2); @@ -236,8 +278,16 @@ describe("5.x: Orphan detection", () => { it("5.1: detects dead agents as orphans", () => { const m = createManifest({ - batchId, agentId: "dead-worker", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: 999999999, parentPid: 1000, cwd: tmpDir, packet: null, + batchId, + agentId: "dead-worker", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: 999999999, + parentPid: 1000, + cwd: tmpDir, + packet: null, }); m.status = "running"; writeManifest(tmpDir, m); @@ -248,8 +298,16 @@ describe("5.x: Orphan detection", () => { it("5.2: does not flag live agents as orphans", () => { const m = createManifest({ - batchId, agentId: "live-worker", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: process.pid, parentPid: 1000, cwd: tmpDir, packet: null, + batchId, + agentId: "live-worker", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: process.pid, + parentPid: 1000, + cwd: tmpDir, + packet: null, }); m.status = "running"; writeManifest(tmpDir, m); @@ -259,8 +317,16 @@ describe("5.x: Orphan detection", () => { it("5.3: does not flag terminal agents as orphans", () => { const m = createManifest({ - batchId, agentId: "done-worker", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: 999999999, parentPid: 1000, cwd: tmpDir, packet: null, + batchId, + agentId: "done-worker", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: 999999999, + parentPid: 1000, + cwd: tmpDir, + packet: null, }); m.status = "exited"; writeManifest(tmpDir, m); @@ -270,8 +336,16 @@ describe("5.x: Orphan detection", () => { it("5.4: markOrphansCrashed updates manifests", () => { const m = createManifest({ - batchId, agentId: "orphan-1", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: 999999999, parentPid: 1000, cwd: tmpDir, packet: null, + batchId, + agentId: "orphan-1", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: 999999999, + parentPid: 1000, + cwd: tmpDir, + packet: null, }); m.status = "running"; writeManifest(tmpDir, m); @@ -287,10 +361,21 @@ describe("6.x: Cleanup", () => { const batchId = "20260330T120000"; it("6.1: cleanupBatchRuntime removes runtime directory", () => { - writeManifest(tmpDir, createManifest({ - batchId, agentId: "cleanup-agent", role: "worker", laneNumber: 1, - taskId: "TP-1", repoId: "default", pid: 1234, parentPid: 1000, cwd: tmpDir, packet: null, - })); + writeManifest( + tmpDir, + createManifest({ + batchId, + agentId: "cleanup-agent", + role: "worker", + laneNumber: 1, + taskId: "TP-1", + repoId: "default", + pid: 1234, + parentPid: 1000, + cwd: tmpDir, + packet: null, + }), + ); const result = cleanupBatchRuntime(tmpDir, batchId); expect(result.removed).toBe(true); const path = runtimeManifestPath(tmpDir, batchId, "cleanup-agent"); @@ -417,8 +502,10 @@ describe("9.x: Agent-host option and event attribution contract", () => { it("9.8: get_session_stats is requested immediately then on bounded cadence", () => { expect(hostSrc).toContain("const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5"); expect(hostSrc).toContain("assistantMessageEnds += 1"); - expect(hostSrc).toContain("assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0"); - expect(hostSrc).toContain("{ type: \"get_session_stats\" }"); + expect(hostSrc).toContainNormalized( + "assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0", + ); + expect(hostSrc).toContain('{ type: "get_session_stats" }'); }); it("9.9: --model and --thinking flags are omitted for empty inherit values", () => { diff --git a/extensions/tests/project-config-loader.test.ts b/extensions/tests/project-config-loader.test.ts index e3498467..3feb77c3 100644 --- a/extensions/tests/project-config-loader.test.ts +++ b/extensions/tests/project-config-loader.test.ts @@ -18,12 +18,7 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import { expect } from "./expect.ts"; import assert from "node:assert"; -import { - mkdirSync, - writeFileSync, - readFileSync, - rmSync, -} from "fs"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "fs"; import { execSync } from "child_process"; import { join } from "path"; import { tmpdir } from "os"; @@ -41,10 +36,7 @@ import { DEFAULT_TASK_RUNNER_SECTION, DEFAULT_ORCHESTRATOR_SECTION, } from "../taskplane/config-schema.ts"; -import { - loadOrchestratorConfig, - loadTaskRunnerConfig, -} from "../taskplane/config.ts"; +import { loadOrchestratorConfig, loadTaskRunnerConfig } from "../taskplane/config.ts"; import { loadConfig as taskRunnerLoadConfig } from "../taskplane/config-loader.ts"; // ── Fixture Helpers ────────────────────────────────────────────────── @@ -132,7 +124,9 @@ describe("loadProjectConfig precedence/error matrix", () => { expect(config.taskRunner.worker.tools).toBe(DEFAULT_TASK_RUNNER_SECTION.worker.tools); expect(config.orchestrator.orchestrator.maxLanes).toBe(5); // Other orchestrator defaults preserved - expect(config.orchestrator.failure.stallTimeout).toBe(DEFAULT_ORCHESTRATOR_SECTION.failure.stallTimeout); + expect(config.orchestrator.failure.stallTimeout).toBe( + DEFAULT_ORCHESTRATOR_SECTION.failure.stallTimeout, + ); }); it("1.2: malformed JSON throws CONFIG_JSON_MALFORMED", () => { @@ -224,7 +218,9 @@ describe("loadProjectConfig precedence/error matrix", () => { const config = loadProjectConfig(dir); expect(config.configVersion).toBe(CONFIG_VERSION); expect(config.taskRunner.project.name).toBe(DEFAULT_TASK_RUNNER_SECTION.project.name); - expect(config.orchestrator.orchestrator.maxLanes).toBe(DEFAULT_ORCHESTRATOR_SECTION.orchestrator.maxLanes); + expect(config.orchestrator.orchestrator.maxLanes).toBe( + DEFAULT_ORCHESTRATOR_SECTION.orchestrator.maxLanes, + ); }); it("1.8: JSON with null configVersion throws CONFIG_VERSION_MISSING", () => { @@ -277,15 +273,23 @@ describe("loadProjectConfig precedence/error matrix", () => { it("1.12: sparse project config falls through to global preferences, then defaults", () => { const dir = makeTestDir("sparse-fallthrough"); const agentDir = process.env.PI_CODING_AGENT_DIR!; - writeFileSync(join(agentDir, "taskplane", "preferences.json"), JSON.stringify({ - taskRunner: { - worker: { model: "global-worker" }, - reviewer: { tools: "read,bash" }, - }, - orchestrator: { - orchestrator: { maxLanes: 9 }, - }, - }, null, 2), "utf-8"); + writeFileSync( + join(agentDir, "taskplane", "preferences.json"), + JSON.stringify( + { + taskRunner: { + worker: { model: "global-worker" }, + reviewer: { tools: "read,bash" }, + }, + orchestrator: { + orchestrator: { maxLanes: 9 }, + }, + }, + null, + 2, + ), + "utf-8", + ); writeJsonConfig(dir, { configVersion: 1, @@ -304,15 +308,23 @@ describe("loadProjectConfig precedence/error matrix", () => { it("1.13: project overrides win over global preferences with deep merge semantics", () => { const dir = makeTestDir("project-wins-over-global"); const agentDir = process.env.PI_CODING_AGENT_DIR!; - writeFileSync(join(agentDir, "taskplane", "preferences.json"), JSON.stringify({ - taskRunner: { - worker: { model: "global-worker", tools: "read,bash" }, - reviewer: { thinking: "off", tools: "read,bash" }, - }, - orchestrator: { - orchestrator: { maxLanes: 9 }, - }, - }, null, 2), "utf-8"); + writeFileSync( + join(agentDir, "taskplane", "preferences.json"), + JSON.stringify( + { + taskRunner: { + worker: { model: "global-worker", tools: "read,bash" }, + reviewer: { thinking: "off", tools: "read,bash" }, + }, + orchestrator: { + orchestrator: { maxLanes: 9 }, + }, + }, + null, + 2, + ), + "utf-8", + ); writeJsonConfig(dir, { configVersion: 1, @@ -410,19 +422,25 @@ describe("workspace root resolution", () => { describe("key preservation and adapter regression", () => { it("3.1: sizeWeights preserves user-defined keys (S, M, L, XL)", () => { const dir = makeTestDir("size-weights"); - writeOrchestratorYaml(dir, [ - "assignment:", - " strategy: round-robin", - " size_weights:", - " S: 1", - " M: 2", - " L: 4", - " XL: 8", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "assignment:", + " strategy: round-robin", + " size_weights:", + " S: 1", + " M: 2", + " L: 4", + " XL: 8", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.orchestrator.assignment.sizeWeights).toEqual({ - S: 1, M: 2, L: 4, XL: 8, + S: 1, + M: 2, + L: 4, + XL: 8, }); expect(config.orchestrator.assignment.sizeWeights).not.toHaveProperty("s"); expect(config.orchestrator.assignment.sizeWeights).not.toHaveProperty("xl"); @@ -430,33 +448,35 @@ describe("key preservation and adapter regression", () => { it("3.2: sizeWeights round-trips correctly through toOrchestratorConfig adapter", () => { const dir = makeTestDir("size-weights-adapter"); - writeOrchestratorYaml(dir, [ - "assignment:", - " size_weights:", - " S: 1", - " M: 2", - " L: 4", - " XL: 8", - ].join("\n")); + writeOrchestratorYaml( + dir, + ["assignment:", " size_weights:", " S: 1", " M: 2", " L: 4", " XL: 8"].join("\n"), + ); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); expect(legacy.assignment.size_weights).toEqual({ - S: 1, M: 2, L: 4, XL: 8, + S: 1, + M: 2, + L: 4, + XL: 8, }); }); it("3.3: preWarm.commands preserves user-defined command keys", () => { const dir = makeTestDir("prewarm-cmds"); - writeOrchestratorYaml(dir, [ - "pre_warm:", - " auto_detect: true", - " commands:", - " install_deps: npm ci", - " build_project: npm run build", - " always:", - " - npm ci", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "pre_warm:", + " auto_detect: true", + " commands:", + " install_deps: npm ci", + " build_project: npm run build", + " always:", + " - npm ci", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.orchestrator.preWarm.commands).toEqual({ @@ -469,11 +489,7 @@ describe("key preservation and adapter regression", () => { it("3.4: preWarm.commands round-trips through toOrchestratorConfig adapter", () => { const dir = makeTestDir("prewarm-adapter"); - writeOrchestratorYaml(dir, [ - "pre_warm:", - " commands:", - " my_cmd: echo hello", - ].join("\n")); + writeOrchestratorYaml(dir, ["pre_warm:", " commands:", " my_cmd: echo hello"].join("\n")); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); @@ -482,18 +498,21 @@ describe("key preservation and adapter regression", () => { it("3.5: taskAreas preserves user-defined area IDs and inner fields", () => { const dir = makeTestDir("task-areas"); - writeTaskRunnerYaml(dir, [ - "task_areas:", - " backend-api:", - " path: taskplane-tasks", - " prefix: TP", - " context: taskplane-tasks/CONTEXT.md", - " repo_id: api-service", - " frontend-web:", - " path: frontend-tasks", - " prefix: FE", - " context: frontend-tasks/CONTEXT.md", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "task_areas:", + " backend-api:", + " path: taskplane-tasks", + " prefix: TP", + " context: taskplane-tasks/CONTEXT.md", + " repo_id: api-service", + " frontend-web:", + " path: frontend-tasks", + " prefix: FE", + " context: frontend-tasks/CONTEXT.md", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(Object.keys(config.taskRunner.taskAreas)).toEqual(["backend-api", "frontend-web"]); @@ -506,19 +525,22 @@ describe("key preservation and adapter regression", () => { it("3.6: taskAreas repoId: whitespace-only is dropped, non-empty is trimmed", () => { const dir = makeTestDir("repo-id-trim"); - writeTaskRunnerYaml(dir, [ - "task_areas:", - " area1:", - " path: tasks", - " prefix: A", - " context: tasks/CONTEXT.md", - " repo_id: \" api \"", - " area2:", - " path: tasks2", - " prefix: B", - " context: tasks2/CONTEXT.md", - " repo_id: \" \"", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "task_areas:", + " area1:", + " path: tasks", + " prefix: A", + " context: tasks/CONTEXT.md", + ' repo_id: " api "', + " area2:", + " path: tasks2", + " prefix: B", + " context: tasks2/CONTEXT.md", + ' repo_id: " "', + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.taskRunner.taskAreas.area1.repoId).toBe("api"); @@ -527,17 +549,20 @@ describe("key preservation and adapter regression", () => { it("3.7: toTaskRunnerConfig adapter preserves task area IDs and repoId behavior", () => { const dir = makeTestDir("task-runner-adapter"); - writeTaskRunnerYaml(dir, [ - "task_areas:", - " myArea:", - " path: tasks", - " prefix: MY", - " context: tasks/CONTEXT.md", - " repo_id: myrepo", - "reference_docs:", - " arch: docs/arch.md", - " design: docs/design.md", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "task_areas:", + " myArea:", + " path: tasks", + " prefix: MY", + " context: tasks/CONTEXT.md", + " repo_id: myrepo", + "reference_docs:", + " arch: docs/arch.md", + " design: docs/design.md", + ].join("\n"), + ); const config = loadProjectConfig(dir); const legacy = toTaskRunnerConfig(config); @@ -552,32 +577,47 @@ describe("key preservation and adapter regression", () => { it("3.8: standardsOverrides preserves user-defined area keys", () => { const dir = makeTestDir("standards-overrides"); - writeTaskRunnerYaml(dir, [ - "standards_overrides:", - " backend-api:", - " docs:", - " - docs/backend-standards.md", - " rules:", - " - Always use async/await", - " frontend-web:", - " docs:", - " - docs/frontend-standards.md", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "standards_overrides:", + " backend-api:", + " docs:", + " - docs/backend-standards.md", + " rules:", + " - Always use async/await", + " frontend-web:", + " docs:", + " - docs/frontend-standards.md", + ].join("\n"), + ); const config = loadProjectConfig(dir); - expect(Object.keys(config.taskRunner.standardsOverrides)).toEqual(["backend-api", "frontend-web"]); - expect(config.taskRunner.standardsOverrides["backend-api"].docs).toEqual(["docs/backend-standards.md"]); - expect(config.taskRunner.standardsOverrides["backend-api"].rules).toEqual(["Always use async/await"]); - expect(config.taskRunner.standardsOverrides["frontend-web"].docs).toEqual(["docs/frontend-standards.md"]); + expect(Object.keys(config.taskRunner.standardsOverrides)).toEqual([ + "backend-api", + "frontend-web", + ]); + expect(config.taskRunner.standardsOverrides["backend-api"].docs).toEqual([ + "docs/backend-standards.md", + ]); + expect(config.taskRunner.standardsOverrides["backend-api"].rules).toEqual([ + "Always use async/await", + ]); + expect(config.taskRunner.standardsOverrides["frontend-web"].docs).toEqual([ + "docs/frontend-standards.md", + ]); }); it("3.9: referenceDocs preserves user-defined keys", () => { const dir = makeTestDir("ref-docs"); - writeTaskRunnerYaml(dir, [ - "reference_docs:", - " architecture: docs/architecture.md", - " api_spec: docs/api-spec.yaml", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "reference_docs:", + " architecture: docs/architecture.md", + " api_spec: docs/api-spec.yaml", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.taskRunner.referenceDocs).toEqual({ @@ -588,11 +628,14 @@ describe("key preservation and adapter regression", () => { it("3.10: selfDocTargets preserves user-defined keys", () => { const dir = makeTestDir("self-doc"); - writeTaskRunnerYaml(dir, [ - "self_doc_targets:", - " context_file: taskplane-tasks/CONTEXT.md", - " tech_debt: docs/TECH-DEBT.md", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "self_doc_targets:", + " context_file: taskplane-tasks/CONTEXT.md", + " tech_debt: docs/TECH-DEBT.md", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.taskRunner.selfDocTargets).toEqual({ @@ -603,46 +646,49 @@ describe("key preservation and adapter regression", () => { it("3.11: toTaskConfig adapter produces correct snake_case shape", () => { const dir = makeTestDir("task-config-adapter"); - writeTaskRunnerYaml(dir, [ - "project:", - " name: MyProject", - " description: My project desc", - "paths:", - " tasks: my-tasks", - " architecture: docs/arch.md", - "testing:", - " commands:", - " test: npm test", - " lint: npm run lint", - "standards:", - " docs:", - " - STANDARDS.md", - " rules:", - " - Use TypeScript", - "worker:", - " model: openai/gpt-4", - " tools: read,write", - " thinking: on", - " spawn_mode: subprocess", - "reviewer:", - " model: openai/gpt-4", - " tools: read", - " thinking: on", - "context:", - " worker_context_window: 100000", - " warn_percent: 60", - " kill_percent: 80", - " max_worker_iterations: 10", - " max_review_cycles: 3", - " no_progress_limit: 5", - " max_worker_minutes: 45", - "task_areas:", - " main:", - " path: tasks", - " prefix: T", - " context: tasks/CONTEXT.md", - " repo_id: main-repo", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "project:", + " name: MyProject", + " description: My project desc", + "paths:", + " tasks: my-tasks", + " architecture: docs/arch.md", + "testing:", + " commands:", + " test: npm test", + " lint: npm run lint", + "standards:", + " docs:", + " - STANDARDS.md", + " rules:", + " - Use TypeScript", + "worker:", + " model: openai/gpt-4", + " tools: read,write", + " thinking: on", + " spawn_mode: subprocess", + "reviewer:", + " model: openai/gpt-4", + " tools: read", + " thinking: on", + "context:", + " worker_context_window: 100000", + " warn_percent: 60", + " kill_percent: 80", + " max_worker_iterations: 10", + " max_review_cycles: 3", + " no_progress_limit: 5", + " max_worker_minutes: 45", + "task_areas:", + " main:", + " path: tasks", + " prefix: T", + " context: tasks/CONTEXT.md", + " repo_id: main-repo", + ].join("\n"), + ); const config = loadProjectConfig(dir); const taskConfig = toTaskConfig(config); @@ -672,40 +718,43 @@ describe("key preservation and adapter regression", () => { it("3.12: toOrchestratorConfig adapter produces correct full runtime shape", () => { const dir = makeTestDir("orch-adapter-full"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 5", - " worktree_location: sibling", - " worktree_prefix: my-wt", - " batch_id_format: sequential", - " spawn_mode: subprocess", - " session_prefix: myorch", - " operator_id: testuser", - " integration: auto", - "dependencies:", - " source: agent", - " cache: false", - "assignment:", - " strategy: round-robin", - " size_weights:", - " S: 2", - " M: 4", - " L: 8", - "merge:", - " model: openai/gpt-4", - " tools: read,write", - " verify:", - " - npm test", - " order: sequential", - "failure:", - " on_task_failure: stop-all", - " on_merge_failure: abort", - " stall_timeout: 60", - " max_worker_minutes: 45", - " abort_grace_period: 120", - "monitoring:", - " poll_interval: 10", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "orchestrator:", + " max_lanes: 5", + " worktree_location: sibling", + " worktree_prefix: my-wt", + " batch_id_format: sequential", + " spawn_mode: subprocess", + " session_prefix: myorch", + " operator_id: testuser", + " integration: auto", + "dependencies:", + " source: agent", + " cache: false", + "assignment:", + " strategy: round-robin", + " size_weights:", + " S: 2", + " M: 4", + " L: 8", + "merge:", + " model: openai/gpt-4", + " tools: read,write", + " verify:", + " - npm test", + " order: sequential", + "failure:", + " on_task_failure: stop-all", + " on_merge_failure: abort", + " stall_timeout: 60", + " max_worker_minutes: 45", + " abort_grace_period: 120", + "monitoring:", + " poll_interval: 10", + ].join("\n"), + ); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); @@ -736,10 +785,7 @@ describe("key preservation and adapter regression", () => { it("3.13: integration defaults to 'manual' when omitted from YAML", () => { const dir = makeTestDir("integration-default"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - ].join("\n")); + writeOrchestratorYaml(dir, ["orchestrator:", " max_lanes: 2"].join("\n")); const config = loadProjectConfig(dir); // Unified config should have the default @@ -774,10 +820,17 @@ describe("key preservation and adapter regression", () => { const dir = makeTestDir("both-prefix-keys"); // JSON config with both keys — sessionPrefix should take priority mkdirSync(join(dir, ".pi"), { recursive: true }); - writeFileSync(join(dir, ".pi", "taskplane-config.json"), JSON.stringify({ - configVersion: 1, - orchestrator: { orchestrator: { sessionPrefix: "new-prefix", tmuxPrefix: "old-prefix" } }, - }, null, 2)); + writeFileSync( + join(dir, ".pi", "taskplane-config.json"), + JSON.stringify( + { + configVersion: 1, + orchestrator: { orchestrator: { sessionPrefix: "new-prefix", tmuxPrefix: "old-prefix" } }, + }, + null, + 2, + ), + ); const config = loadProjectConfig(dir); expect(config.orchestrator.orchestrator.sessionPrefix).toBe("new-prefix"); // tmuxPrefix should be removed from disk @@ -835,14 +888,17 @@ describe("defaults, cloning, non-mutation, and backward-compat wrappers", () => it("4.3: loadOrchestratorConfig wrapper returns correct snake_case shape", () => { const dir = makeTestDir("orch-wrapper"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 4", - "assignment:", - " size_weights:", - " S: 1", - " M: 3", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "orchestrator:", + " max_lanes: 4", + "assignment:", + " size_weights:", + " S: 1", + " M: 3", + ].join("\n"), + ); const legacy = loadOrchestratorConfig(dir); expect(legacy.orchestrator.max_lanes).toBe(4); @@ -851,15 +907,18 @@ describe("defaults, cloning, non-mutation, and backward-compat wrappers", () => it("4.4: loadTaskRunnerConfig wrapper returns correct snake_case shape", () => { const dir = makeTestDir("runner-wrapper"); - writeTaskRunnerYaml(dir, [ - "task_areas:", - " main:", - " path: my-tasks", - " prefix: MT", - " context: my-tasks/CONTEXT.md", - "reference_docs:", - " readme: README.md", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "task_areas:", + " main:", + " path: my-tasks", + " prefix: MT", + " context: my-tasks/CONTEXT.md", + "reference_docs:", + " readme: README.md", + ].join("\n"), + ); const legacy = loadTaskRunnerConfig(dir); expect(legacy.task_areas.main.path).toBe("my-tasks"); @@ -915,7 +974,11 @@ describe("defaults, cloning, non-mutation, and backward-compat wrappers", () => const prevAgentDir = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = agentDir; mkdirSync(join(agentDir, "taskplane"), { recursive: true }); - writeFileSync(join(agentDir, "taskplane", "preferences.json"), JSON.stringify({ tmuxPrefix: "legacy-pref" }), "utf-8"); + writeFileSync( + join(agentDir, "taskplane", "preferences.json"), + JSON.stringify({ tmuxPrefix: "legacy-pref" }), + "utf-8", + ); try { // Should not throw — auto-migration handles legacy field @@ -950,15 +1013,18 @@ describe("defaults, cloning, non-mutation, and backward-compat wrappers", () => it("4.7: YAML array sections are preserved verbatim (neverLoad, protectedDocs)", () => { const dir = makeTestDir("arrays"); - writeTaskRunnerYaml(dir, [ - "never_load:", - " - node_modules/", - " - dist/", - " - .git/", - "protected_docs:", - " - AGENTS.md", - " - docs/arch.md", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "never_load:", + " - node_modules/", + " - dist/", + " - .git/", + "protected_docs:", + " - AGENTS.md", + " - docs/arch.md", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.taskRunner.neverLoad).toEqual(["node_modules/", "dist/", ".git/"]); @@ -967,13 +1033,16 @@ describe("defaults, cloning, non-mutation, and backward-compat wrappers", () => it("4.8: testing.commands preserves user-defined command keys from YAML", () => { const dir = makeTestDir("testing-cmds"); - writeTaskRunnerYaml(dir, [ - "testing:", - " commands:", - " unit_test: npm test", - " e2e_test: npm run e2e", - " type_check: npx tsc --noEmit", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "testing:", + " commands:", + " unit_test: npm test", + " e2e_test: npm run e2e", + " type_check: npx tsc --noEmit", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.taskRunner.testing.commands).toEqual({ @@ -989,10 +1058,7 @@ describe("defaults, cloning, non-mutation, and backward-compat wrappers", () => describe("quality gate config defaults and adapter mapping (TP-034)", () => { it("4.9: quality gate defaults are correct when not specified in config", () => { const dir = makeTestDir("qg-defaults"); - writeTaskRunnerYaml(dir, [ - "project:", - " name: QGTest", - ].join("\n")); + writeTaskRunnerYaml(dir, ["project:", " name: QGTest"].join("\n")); const config = loadProjectConfig(dir); expect(config.taskRunner.qualityGate).toEqual({ @@ -1006,16 +1072,19 @@ describe("quality gate config defaults and adapter mapping (TP-034)", () => { it("4.10: quality gate config from YAML maps correctly to TaskConfig snake_case", () => { const dir = makeTestDir("qg-yaml-adapter"); - writeTaskRunnerYaml(dir, [ - "project:", - " name: QGYaml", - "quality_gate:", - " enabled: true", - " review_model: openai/gpt-5", - " max_review_cycles: 3", - " max_fix_cycles: 2", - " pass_threshold: no_important", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "project:", + " name: QGYaml", + "quality_gate:", + " enabled: true", + " review_model: openai/gpt-5", + " max_review_cycles: 3", + " max_fix_cycles: 2", + " pass_threshold: no_important", + ].join("\n"), + ); const config = loadProjectConfig(dir); const taskConfig = toTaskConfig(config); @@ -1058,10 +1127,7 @@ describe("quality gate config defaults and adapter mapping (TP-034)", () => { it("4.12: quality gate defaults propagate through toTaskConfig when not configured", () => { const dir = makeTestDir("qg-defaults-adapter"); - writeTaskRunnerYaml(dir, [ - "project:", - " name: DefaultQG", - ].join("\n")); + writeTaskRunnerYaml(dir, ["project:", " name: DefaultQG"].join("\n")); const config = loadProjectConfig(dir); const taskConfig = toTaskConfig(config); @@ -1077,10 +1143,7 @@ describe("quality gate config defaults and adapter mapping (TP-034)", () => { it("4.13: task-runner loadConfig includes quality_gate defaults", () => { const dir = makeTestDir("qg-task-runner-defaults"); - writeTaskRunnerYaml(dir, [ - "project:", - " name: TaskRunnerQG", - ].join("\n")); + writeTaskRunnerYaml(dir, ["project:", " name: TaskRunnerQG"].join("\n")); const result = taskRunnerLoadConfig(dir); expect(result.quality_gate).toEqual({ @@ -1098,10 +1161,7 @@ describe("quality gate config defaults and adapter mapping (TP-034)", () => { describe("verification config defaults and adapter mapping (TP-032)", () => { it("4.14: verification defaults are correct when not specified in config", () => { const dir = makeTestDir("verify-defaults"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - ].join("\n")); + writeOrchestratorYaml(dir, ["orchestrator:", " max_lanes: 2"].join("\n")); const config = loadProjectConfig(dir); expect(config.orchestrator.verification).toEqual({ @@ -1113,14 +1173,17 @@ describe("verification config defaults and adapter mapping (TP-032)", () => { it("4.15: verification config from YAML (snake_case) maps to camelCase", () => { const dir = makeTestDir("verify-yaml"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - "verification:", - " enabled: true", - " mode: strict", - " flaky_reruns: 3", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "orchestrator:", + " max_lanes: 2", + "verification:", + " enabled: true", + " mode: strict", + " flaky_reruns: 3", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.orchestrator.verification).toEqual({ @@ -1176,10 +1239,7 @@ describe("verification config defaults and adapter mapping (TP-032)", () => { it("4.18: verification defaults propagate through toOrchestratorConfig when not configured", () => { const dir = makeTestDir("verify-defaults-adapter"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - ].join("\n")); + writeOrchestratorYaml(dir, ["orchestrator:", " max_lanes: 2"].join("\n")); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); @@ -1193,10 +1253,7 @@ describe("verification config defaults and adapter mapping (TP-032)", () => { it("4.19: partial verification YAML config merges with defaults", () => { const dir = makeTestDir("verify-partial"); - writeOrchestratorYaml(dir, [ - "verification:", - " enabled: true", - ].join("\n")); + writeOrchestratorYaml(dir, ["verification:", " enabled: true"].join("\n")); const config = loadProjectConfig(dir); // enabled is overridden, mode and flakyReruns should come from defaults @@ -1207,11 +1264,7 @@ describe("verification config defaults and adapter mapping (TP-032)", () => { it("4.20: verification flaky_reruns=0 round-trips through YAML→adapter", () => { const dir = makeTestDir("verify-zero-reruns"); - writeOrchestratorYaml(dir, [ - "verification:", - " enabled: true", - " flaky_reruns: 0", - ].join("\n")); + writeOrchestratorYaml(dir, ["verification:", " enabled: true", " flaky_reruns: 0"].join("\n")); const config = loadProjectConfig(dir); expect(config.orchestrator.verification.flakyReruns).toBe(0); @@ -1225,14 +1278,17 @@ describe("verification config defaults and adapter mapping (TP-032)", () => { // if present — this test explicitly checks that verification fields // appear alongside other orchestrator adapter output const dir = makeTestDir("verify-full-adapter"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 5", - "verification:", - " enabled: true", - " mode: strict", - " flaky_reruns: 2", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "orchestrator:", + " max_lanes: 5", + "verification:", + " enabled: true", + " mode: strict", + " flaky_reruns: 2", + ].join("\n"), + ); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); @@ -1519,7 +1575,10 @@ describe("agent resolution precedence with pointer (TP-016)", () => { } /** Create a valid agent file with frontmatter. */ - function agentContent(label: string, opts?: { standalone?: boolean; tools?: string; model?: string }): string { + function agentContent( + label: string, + opts?: { standalone?: boolean; tools?: string; model?: string }, + ): string { const lines = ["---", `name: test-agent`]; if (opts?.tools) lines.push(`tools: ${opts.tools}`); if (opts?.model) lines.push(`model: ${opts.model}`); @@ -1594,7 +1653,8 @@ describe("pointer warning surfacing (TP-016)", () => { taskRunnerLoadConfig(cwdDir); const pointerWarnings = consoleErrorSpy.mock.calls.filter( - (call: any) => typeof call.arguments[0] === "string" && call.arguments[0].includes("[task-runner] pointer:"), + (call: any) => + typeof call.arguments[0] === "string" && call.arguments[0].includes("[task-runner] pointer:"), ); expect(pointerWarnings.length).toBe(0); }); @@ -1645,7 +1705,8 @@ describe("pointer warning surfacing (TP-016)", () => { // Warning should be logged exactly once (dedup via _pointerWarningLogged) const pointerWarnings = consoleErrorSpy.mock.calls.filter( - (call: any) => typeof call.arguments[0] === "string" && call.arguments[0].includes("[task-runner] pointer:"), + (call: any) => + typeof call.arguments[0] === "string" && call.arguments[0].includes("[task-runner] pointer:"), ); expect(pointerWarnings.length).toBe(1); expect(pointerWarnings[0].arguments[0]).toContain("Pointer file not found"); @@ -1659,7 +1720,8 @@ describe("pointer warning surfacing (TP-016)", () => { taskRunnerLoadConfig(cwdDir); const pointerWarnings = consoleErrorSpy.mock.calls.filter( - (call: any) => typeof call.arguments[0] === "string" && call.arguments[0].includes("[task-runner] pointer:"), + (call: any) => + typeof call.arguments[0] === "string" && call.arguments[0].includes("[task-runner] pointer:"), ); expect(pointerWarnings.length).toBe(0); }); @@ -1670,10 +1732,7 @@ describe("pointer warning surfacing (TP-016)", () => { describe("verification config defaults, mapping, and adapter (TP-032)", () => { it("7.1: verification defaults are correct when not specified in config", () => { const dir = makeTestDir("verify-defaults"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - ].join("\n")); + writeOrchestratorYaml(dir, ["orchestrator:", " max_lanes: 2"].join("\n")); const config = loadProjectConfig(dir); expect(config.orchestrator.verification).toEqual({ @@ -1685,14 +1744,17 @@ describe("verification config defaults, mapping, and adapter (TP-032)", () => { it("7.2: verification YAML snake_case maps to camelCase in unified config", () => { const dir = makeTestDir("verify-yaml-map"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - "verification:", - " enabled: true", - " mode: strict", - " flaky_reruns: 3", - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "orchestrator:", + " max_lanes: 2", + "verification:", + " enabled: true", + " mode: strict", + " flaky_reruns: 3", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.orchestrator.verification.enabled).toBe(true); @@ -1721,12 +1783,10 @@ describe("verification config defaults, mapping, and adapter (TP-032)", () => { it("7.4: toOrchestratorConfig round-trips verification to snake_case", () => { const dir = makeTestDir("verify-adapter"); - writeOrchestratorYaml(dir, [ - "verification:", - " enabled: true", - " mode: strict", - " flaky_reruns: 2", - ].join("\n")); + writeOrchestratorYaml( + dir, + ["verification:", " enabled: true", " mode: strict", " flaky_reruns: 2"].join("\n"), + ); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); @@ -1741,10 +1801,7 @@ describe("verification config defaults, mapping, and adapter (TP-032)", () => { it("7.5: toOrchestratorConfig defaults produce correct snake_case verification", () => { const dir = makeTestDir("verify-adapter-defaults"); // No verification section at all - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 2", - ].join("\n")); + writeOrchestratorYaml(dir, ["orchestrator:", " max_lanes: 2"].join("\n")); const config = loadProjectConfig(dir); const legacy = toOrchestratorConfig(config); @@ -1758,25 +1815,24 @@ describe("verification config defaults, mapping, and adapter (TP-032)", () => { it("7.6: partial verification YAML merges with defaults", () => { const dir = makeTestDir("verify-partial"); - writeOrchestratorYaml(dir, [ - "verification:", - " enabled: true", - // mode and flaky_reruns omitted — should use defaults - ].join("\n")); + writeOrchestratorYaml( + dir, + [ + "verification:", + " enabled: true", + // mode and flaky_reruns omitted — should use defaults + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.orchestrator.verification.enabled).toBe(true); expect(config.orchestrator.verification.mode).toBe("permissive"); // default - expect(config.orchestrator.verification.flakyReruns).toBe(1); // default + expect(config.orchestrator.verification.flakyReruns).toBe(1); // default }); it("7.7: flakyReruns: 0 disables flaky re-runs and round-trips correctly", () => { const dir = makeTestDir("verify-no-reruns"); - writeOrchestratorYaml(dir, [ - "verification:", - " enabled: true", - " flaky_reruns: 0", - ].join("\n")); + writeOrchestratorYaml(dir, ["verification:", " enabled: true", " flaky_reruns: 0"].join("\n")); const config = loadProjectConfig(dir); expect(config.orchestrator.verification.flakyReruns).toBe(0); @@ -1787,10 +1843,7 @@ describe("verification config defaults, mapping, and adapter (TP-032)", () => { it("7.8: loadOrchestratorConfig wrapper includes verification defaults", () => { const dir = makeTestDir("verify-orch-wrapper"); - writeOrchestratorYaml(dir, [ - "orchestrator:", - " max_lanes: 3", - ].join("\n")); + writeOrchestratorYaml(dir, ["orchestrator:", " max_lanes: 3"].join("\n")); const legacy = loadOrchestratorConfig(dir); expect(legacy.verification).toEqual({ @@ -1849,17 +1902,21 @@ describe("workspace section threading (TP-079)", () => { it("8.3: legacy taskplane-workspace.yaml maps snake_case fields to workspace section", () => { const dir = makeTestDir("workspace-yaml-explicit"); - writePiFile(dir, "taskplane-workspace.yaml", [ - "repos:", - " api:", - " path: ../api-repo", - " default_branch: develop", - "routing:", - " tasks_root: api-repo/taskplane-tasks", - " default_repo: api", - " task_packet_repo: api", - " strict: true", - ].join("\n")); + writePiFile( + dir, + "taskplane-workspace.yaml", + [ + "repos:", + " api:", + " path: ../api-repo", + " default_branch: develop", + "routing:", + " tasks_root: api-repo/taskplane-tasks", + " default_repo: api", + " task_packet_repo: api", + " strict: true", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); @@ -1872,14 +1929,18 @@ describe("workspace section threading (TP-079)", () => { it("8.4: legacy workspace YAML missing task_packet_repo falls back to default_repo", () => { const dir = makeTestDir("workspace-yaml-fallback"); - writePiFile(dir, "taskplane-workspace.yaml", [ - "repos:", - " infra:", - " path: ../infra-repo", - "routing:", - " tasks_root: infra-repo/taskplane-tasks", - " default_repo: infra", - ].join("\n")); + writePiFile( + dir, + "taskplane-workspace.yaml", + [ + "repos:", + " infra:", + " path: ../infra-repo", + "routing:", + " tasks_root: infra-repo/taskplane-tasks", + " default_repo: infra", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); @@ -1889,15 +1950,19 @@ describe("workspace section threading (TP-079)", () => { it("8.5: JSON workspace section takes precedence over legacy workspace YAML", () => { const dir = makeTestDir("workspace-json-precedence"); - writePiFile(dir, "taskplane-workspace.yaml", [ - "repos:", - " yamlrepo:", - " path: ../yaml-repo", - "routing:", - " tasks_root: yaml-repo/taskplane-tasks", - " default_repo: yamlrepo", - " task_packet_repo: yamlrepo", - ].join("\n")); + writePiFile( + dir, + "taskplane-workspace.yaml", + [ + "repos:", + " yamlrepo:", + " path: ../yaml-repo", + "routing:", + " tasks_root: yaml-repo/taskplane-tasks", + " default_repo: yamlrepo", + " task_packet_repo: yamlrepo", + ].join("\n"), + ); writeJsonConfig(dir, { configVersion: CONFIG_VERSION, workspace: { diff --git a/extensions/tests/quality-gate.test.ts b/extensions/tests/quality-gate.test.ts index 3b568ff5..7cbd4d35 100644 --- a/extensions/tests/quality-gate.test.ts +++ b/extensions/tests/quality-gate.test.ts @@ -80,7 +80,11 @@ beforeEach(() => { }); afterEach(() => { - try { rmSync(testRoot, { recursive: true, force: true }); } catch { /* ignore */ } + try { + rmSync(testRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); // ── Helper: make a minimal valid verdict JSON ──────────────────────── @@ -163,11 +167,13 @@ describe("1.x: parseVerdict", () => { }); it("1.8: valid PASS verdict parsed correctly", () => { - const v = parseVerdict(makeVerdictJson({ - verdict: "PASS", - confidence: "high", - summary: "Looks good", - })); + const v = parseVerdict( + makeVerdictJson({ + verdict: "PASS", + confidence: "high", + summary: "Looks good", + }), + ); expect(v.verdict).toBe("PASS"); expect(v.confidence).toBe("high"); expect(v.summary).toBe("Looks good"); @@ -175,13 +181,27 @@ describe("1.x: parseVerdict", () => { }); it("1.9: valid NEEDS_FIXES verdict parsed with findings", () => { - const v = parseVerdict(makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", description: "Bug found", file: "foo.ts", remediation: "fix it" }, - { severity: "suggestion", category: "incomplete_work", description: "Style issue", file: "bar.ts", remediation: "" }, - ], - })); + const v = parseVerdict( + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Bug found", + file: "foo.ts", + remediation: "fix it", + }, + { + severity: "suggestion", + category: "incomplete_work", + description: "Style issue", + file: "bar.ts", + remediation: "", + }, + ], + }), + ); expect(v.verdict).toBe("NEEDS_FIXES"); expect(v.findings).toHaveLength(2); expect(v.findings[0].severity).toBe("critical"); @@ -190,22 +210,44 @@ describe("1.x: parseVerdict", () => { }); it("1.10: findings with invalid severity are dropped", () => { - const v = parseVerdict(makeVerdictJson({ - findings: [ - { severity: "critical", category: "incorrect_implementation", description: "valid", file: "", remediation: "" }, - { severity: "banana", category: "incorrect_implementation", description: "invalid severity", file: "", remediation: "" }, - ], - })); + const v = parseVerdict( + makeVerdictJson({ + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "valid", + file: "", + remediation: "", + }, + { + severity: "banana", + category: "incorrect_implementation", + description: "invalid severity", + file: "", + remediation: "", + }, + ], + }), + ); expect(v.findings).toHaveLength(1); expect(v.findings[0].severity).toBe("critical"); }); it("1.11: findings with invalid category are dropped", () => { - const v = parseVerdict(makeVerdictJson({ - findings: [ - { severity: "important", category: "weird_cat", description: "unknown cat", file: "", remediation: "" }, - ], - })); + const v = parseVerdict( + makeVerdictJson({ + findings: [ + { + severity: "important", + category: "weird_cat", + description: "unknown cat", + file: "", + remediation: "", + }, + ], + }), + ); expect(v.findings).toHaveLength(0); }); @@ -215,22 +257,24 @@ describe("1.x: parseVerdict", () => { }); it("1.13: statusReconciliation entries parsed", () => { - const v = parseVerdict(makeVerdictJson({ - statusReconciliation: [ - { checkbox: "Step 2 checkbox", actualState: "not_done", evidence: "tests failing" }, - ], - })); + const v = parseVerdict( + makeVerdictJson({ + statusReconciliation: [ + { checkbox: "Step 2 checkbox", actualState: "not_done", evidence: "tests failing" }, + ], + }), + ); expect(v.statusReconciliation).toHaveLength(1); expect(v.statusReconciliation[0].checkbox).toBe("Step 2 checkbox"); expect(v.statusReconciliation[0].actualState).toBe("not_done"); }); it("1.14: statusReconciliation entry with invalid actualState is dropped", () => { - const v = parseVerdict(makeVerdictJson({ - statusReconciliation: [ - { checkbox: "Step 1", actualState: "unknown_state", evidence: "n/a" }, - ], - })); + const v = parseVerdict( + makeVerdictJson({ + statusReconciliation: [{ checkbox: "Step 1", actualState: "unknown_state", evidence: "n/a" }], + }), + ); expect(v.statusReconciliation).toHaveLength(0); }); }); @@ -248,7 +292,9 @@ describe("2.x: applyVerdictRules", () => { it("2.2: any critical finding → fail", () => { const result = applyVerdictRules( - makeVerdict({ findings: [makeFinding({ severity: "critical", category: "incorrect_implementation" })] }), + makeVerdict({ + findings: [makeFinding({ severity: "critical", category: "incorrect_implementation" })], + }), "no_critical", ); expect(result.pass).toBe(false); @@ -300,7 +346,9 @@ describe("2.x: applyVerdictRules", () => { it("2.9: status_mismatch category in findings → fail", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "suggestion", category: "status_mismatch", description: "mismatch" })], + findings: [ + makeFinding({ severity: "suggestion", category: "status_mismatch", description: "mismatch" }), + ], }); const result = applyVerdictRules(v, "no_critical"); expect(result.pass).toBe(false); @@ -367,14 +415,17 @@ describe("3.x: Quality gate config", () => { it("3.4: quality gate YAML settings are loaded and mapped", () => { const dir = makeTestDir("qg-yaml"); - writeTaskRunnerYaml(dir, [ - "quality_gate:", - " enabled: true", - " review_model: anthropic/claude-4-sonnet", - " max_review_cycles: 3", - " max_fix_cycles: 2", - " pass_threshold: no_important", - ].join("\n")); + writeTaskRunnerYaml( + dir, + [ + "quality_gate:", + " enabled: true", + " review_model: anthropic/claude-4-sonnet", + " max_review_cycles: 3", + " max_fix_cycles: 2", + " pass_threshold: no_important", + ].join("\n"), + ); const config = loadProjectConfig(dir); expect(config.taskRunner.qualityGate.enabled).toBe(true); @@ -416,10 +467,7 @@ describe("3.x: Quality gate config", () => { it("3.6: partial quality gate YAML merges with defaults", () => { const dir = makeTestDir("qg-partial-yaml"); - writeTaskRunnerYaml(dir, [ - "quality_gate:", - " enabled: true", - ].join("\n")); + writeTaskRunnerYaml(dir, ["quality_gate:", " enabled: true"].join("\n")); const config = loadProjectConfig(dir); expect(config.taskRunner.qualityGate.enabled).toBe(true); @@ -473,21 +521,32 @@ describe("4.x: readAndEvaluateVerdict fail-open", () => { it("4.5: verdict file with NEEDS_FIXES and critical finding → fail evaluation", () => { const dir = makeTestDir("needs-fixes-critical"); - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", description: "broken", file: "a.ts", remediation: "fix" }, - ], - }), "utf-8"); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "broken", + file: "a.ts", + remediation: "fix", + }, + ], + }), + "utf-8", + ); const { verdict, evaluation } = readAndEvaluateVerdict(dir, "no_critical"); expect(verdict.verdict).toBe("NEEDS_FIXES"); expect(evaluation.pass).toBe(false); - expect(evaluation.failReasons.some(r => r.rule === "critical_finding")).toBe(true); + expect(evaluation.failReasons.some((r) => r.rule === "critical_finding")).toBe(true); }); it("4.6: non-existent directory → synthetic PASS (no crash)", () => { const { verdict, evaluation } = readAndEvaluateVerdict( - join(testRoot, "completely-nonexistent-directory"), "no_critical", + join(testRoot, "completely-nonexistent-directory"), + "no_critical", ); expect(verdict.verdict).toBe("PASS"); expect(evaluation.pass).toBe(true); @@ -495,24 +554,44 @@ describe("4.x: readAndEvaluateVerdict fail-open", () => { it("4.7: verdict file with only suggestions under no_critical → pass", () => { const dir = makeTestDir("suggestions-only"); - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - findings: [ - { severity: "suggestion", category: "incomplete_work", description: "minor", file: "", remediation: "" }, - ], - }), "utf-8"); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + findings: [ + { + severity: "suggestion", + category: "incomplete_work", + description: "minor", + file: "", + remediation: "", + }, + ], + }), + "utf-8", + ); const { evaluation } = readAndEvaluateVerdict(dir, "no_critical"); expect(evaluation.pass).toBe(true); }); it("4.8: verdict file with suggestions under all_clear → fail", () => { const dir = makeTestDir("suggestions-all-clear"); - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - findings: [ - { severity: "suggestion", category: "incomplete_work", description: "minor", file: "", remediation: "" }, - ], - }), "utf-8"); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + findings: [ + { + severity: "suggestion", + category: "incomplete_work", + description: "minor", + file: "", + remediation: "", + }, + ], + }), + "utf-8", + ); const { evaluation } = readAndEvaluateVerdict(dir, "all_clear"); expect(evaluation.pass).toBe(false); }); @@ -529,8 +608,16 @@ describe("5.x: generateFeedbackMd", () => { confidence: "high", summary: "Issues found", findings: [ - makeFinding({ severity: "critical", category: "incorrect_implementation", description: "Critical bug" }), - makeFinding({ severity: "important", category: "missing_requirement", description: "Missing feature" }), + makeFinding({ + severity: "critical", + category: "incorrect_implementation", + description: "Critical bug", + }), + makeFinding({ + severity: "important", + category: "missing_requirement", + description: "Missing feature", + }), makeFinding({ severity: "suggestion", category: "incomplete_work", description: "Style nit" }), ], }); @@ -550,8 +637,16 @@ describe("5.x: generateFeedbackMd", () => { confidence: "medium", summary: "Not perfect", findings: [ - makeFinding({ severity: "suggestion", category: "incomplete_work", description: "Consider renaming" }), - makeFinding({ severity: "suggestion", category: "incomplete_work", description: "Add a comment" }), + makeFinding({ + severity: "suggestion", + category: "incomplete_work", + description: "Consider renaming", + }), + makeFinding({ + severity: "suggestion", + category: "incomplete_work", + description: "Add a comment", + }), ], }); const md = generateFeedbackMd(v, 1, 2, "all_clear"); @@ -566,8 +661,16 @@ describe("5.x: generateFeedbackMd", () => { const v = makeVerdict({ verdict: "NEEDS_FIXES", findings: [ - makeFinding({ severity: "important", category: "missing_requirement", description: "Must fix" }), - makeFinding({ severity: "suggestion", category: "incomplete_work", description: "Nice to have" }), + makeFinding({ + severity: "important", + category: "missing_requirement", + description: "Must fix", + }), + makeFinding({ + severity: "suggestion", + category: "incomplete_work", + description: "Nice to have", + }), ], }); const md = generateFeedbackMd(v, 1, 2, "no_important"); @@ -585,7 +688,9 @@ describe("5.x: generateFeedbackMd", () => { it("5.5: includes STATUS reconciliation issues", () => { const v = makeVerdict({ verdict: "NEEDS_FIXES", - findings: [makeFinding({ severity: "critical", category: "status_mismatch", description: "mismatch" })], + findings: [ + makeFinding({ severity: "critical", category: "status_mismatch", description: "mismatch" }), + ], statusReconciliation: [ { checkbox: "Step 1 done", actualState: "not_done" as const, evidence: "No code changes" }, ], @@ -725,15 +830,21 @@ describe("7.x: Verdict rules threshold matrix", () => { }); it("7.2: no_critical: 1 critical → FAIL", () => { - const v = makeVerdict({ findings: [makeFinding({ severity: "critical", category: "incorrect_implementation" })] }); + const v = makeVerdict({ + findings: [makeFinding({ severity: "critical", category: "incorrect_implementation" })], + }); const result = applyVerdictRules(v, "no_critical"); expect(result.pass).toBe(false); - expect(result.failReasons.some(r => r.rule === "critical_finding")).toBe(true); + expect(result.failReasons.some((r) => r.rule === "critical_finding")).toBe(true); }); it("7.3: no_critical: 5 important → PASS (important not blocked at this threshold)", () => { const findings = Array.from({ length: 5 }, (_, i) => - makeFinding({ severity: "important", category: "missing_requirement", description: `issue ${i}` }), + makeFinding({ + severity: "important", + category: "missing_requirement", + description: `issue ${i}`, + }), ); const result = applyVerdictRules(makeVerdict({ findings }), "no_critical"); expect(result.pass).toBe(true); @@ -749,11 +860,13 @@ describe("7.x: Verdict rules threshold matrix", () => { it("7.5: no_critical: status_mismatch → FAIL regardless", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "suggestion", category: "status_mismatch", description: "mismatch" })], + findings: [ + makeFinding({ severity: "suggestion", category: "status_mismatch", description: "mismatch" }), + ], }); const result = applyVerdictRules(v, "no_critical"); expect(result.pass).toBe(false); - expect(result.failReasons.some(r => r.rule === "status_mismatch")).toBe(true); + expect(result.failReasons.some((r) => r.rule === "status_mismatch")).toBe(true); }); // ── no_important threshold ─────────────────────────────────────── @@ -769,16 +882,24 @@ describe("7.x: Verdict rules threshold matrix", () => { it("7.7: no_important: 3 important → FAIL (at threshold)", () => { const findings = Array.from({ length: 3 }, (_, i) => - makeFinding({ severity: "important", category: "missing_requirement", description: `issue ${i}` }), + makeFinding({ + severity: "important", + category: "missing_requirement", + description: `issue ${i}`, + }), ); const result = applyVerdictRules(makeVerdict({ findings }), "no_important"); expect(result.pass).toBe(false); - expect(result.failReasons.some(r => r.rule === "important_threshold")).toBe(true); + expect(result.failReasons.some((r) => r.rule === "important_threshold")).toBe(true); }); it("7.8: no_important: 4 important → FAIL (above threshold)", () => { const findings = Array.from({ length: 4 }, (_, i) => - makeFinding({ severity: "important", category: "missing_requirement", description: `issue ${i}` }), + makeFinding({ + severity: "important", + category: "missing_requirement", + description: `issue ${i}`, + }), ); const result = applyVerdictRules(makeVerdict({ findings }), "no_important"); expect(result.pass).toBe(false); @@ -790,13 +911,11 @@ describe("7.x: Verdict rules threshold matrix", () => { }); const result = applyVerdictRules(v, "no_important"); expect(result.pass).toBe(false); - expect(result.failReasons.some(r => r.rule === "critical_finding")).toBe(true); + expect(result.failReasons.some((r) => r.rule === "critical_finding")).toBe(true); }); it("7.10: no_important: suggestions only → PASS", () => { - const findings = Array.from({ length: 5 }, () => - makeFinding({ severity: "suggestion" }), - ); + const findings = Array.from({ length: 5 }, () => makeFinding({ severity: "suggestion" })); const result = applyVerdictRules(makeVerdict({ findings }), "no_important"); expect(result.pass).toBe(true); }); @@ -818,7 +937,9 @@ describe("7.x: Verdict rules threshold matrix", () => { it("7.13: all_clear: 1 important → FAIL", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "important", category: "missing_requirement", description: "missing" })], + findings: [ + makeFinding({ severity: "important", category: "missing_requirement", description: "missing" }), + ], }); const result = applyVerdictRules(v, "all_clear"); expect(result.pass).toBe(false); @@ -826,7 +947,13 @@ describe("7.x: Verdict rules threshold matrix", () => { it("7.14: all_clear: 1 critical → FAIL", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "critical", category: "incorrect_implementation", description: "broken" })], + findings: [ + makeFinding({ + severity: "critical", + category: "incorrect_implementation", + description: "broken", + }), + ], }); const result = applyVerdictRules(v, "all_clear"); expect(result.pass).toBe(false); @@ -842,28 +969,34 @@ describe("7.x: Verdict rules threshold matrix", () => { }); const result = applyVerdictRules(v, "all_clear"); expect(result.pass).toBe(false); - expect(result.failReasons.some(r => r.rule === "critical_finding")).toBe(true); + expect(result.failReasons.some((r) => r.rule === "critical_finding")).toBe(true); }); // ── Cross-threshold: status_mismatch always blocks ─────────────── it("7.16: status_mismatch blocks at no_critical", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "suggestion", category: "status_mismatch", description: "x" })], + findings: [ + makeFinding({ severity: "suggestion", category: "status_mismatch", description: "x" }), + ], }); expect(applyVerdictRules(v, "no_critical").pass).toBe(false); }); it("7.17: status_mismatch blocks at no_important", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "suggestion", category: "status_mismatch", description: "x" })], + findings: [ + makeFinding({ severity: "suggestion", category: "status_mismatch", description: "x" }), + ], }); expect(applyVerdictRules(v, "no_important").pass).toBe(false); }); it("7.18: status_mismatch blocks at all_clear", () => { const v = makeVerdict({ - findings: [makeFinding({ severity: "suggestion", category: "status_mismatch", description: "x" })], + findings: [ + makeFinding({ severity: "suggestion", category: "status_mismatch", description: "x" }), + ], }); expect(applyVerdictRules(v, "all_clear").pass).toBe(false); }); @@ -875,7 +1008,7 @@ describe("7.x: Verdict rules threshold matrix", () => { const v = makeVerdict({ verdict: "NEEDS_FIXES", findings: [] }); const result = applyVerdictRules(v, threshold); expect(result.pass).toBe(false); - expect(result.failReasons.some(r => r.rule === "verdict_says_needs_fixes")).toBe(true); + expect(result.failReasons.some((r) => r.rule === "verdict_says_needs_fixes")).toBe(true); } }); }); @@ -901,10 +1034,14 @@ describe("8.x: Gate decision logic (unit)", () => { it("8.2: enabled + PASS verdict → evaluation.pass is true (gate would create .DONE)", () => { const dir = makeTestDir("pass-done"); - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - findings: [], - }), "utf-8"); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + findings: [], + }), + "utf-8", + ); const { evaluation } = readAndEvaluateVerdict(dir, "no_critical"); expect(evaluation.pass).toBe(true); // In the task-runner, pass=true → writeFileSync(donePath, ...) with quality gate metadata @@ -912,12 +1049,22 @@ describe("8.x: Gate decision logic (unit)", () => { it("8.3: enabled + NEEDS_FIXES with critical → evaluation.pass is false (.DONE NOT created)", () => { const dir = makeTestDir("fail-no-done"); - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", description: "bug", file: "", remediation: "" }, - ], - }), "utf-8"); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "bug", + file: "", + remediation: "", + }, + ], + }), + "utf-8", + ); const { evaluation } = readAndEvaluateVerdict(dir, "no_critical"); expect(evaluation.pass).toBe(false); // .DONE should NOT be created when evaluation.pass is false @@ -927,11 +1074,15 @@ describe("8.x: Gate decision logic (unit)", () => { it("8.4: PASS verdict includes quality gate metadata expectations", () => { // Verify the verdict structure that task-runner uses to populate .DONE content const dir = makeTestDir("pass-metadata"); - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - confidence: "high", - summary: "All requirements met", - }), "utf-8"); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + confidence: "high", + summary: "All requirements met", + }), + "utf-8", + ); const { verdict } = readAndEvaluateVerdict(dir, "no_critical"); expect(verdict.verdict).toBe("PASS"); expect(verdict.confidence).toBe("high"); @@ -946,8 +1097,16 @@ describe("8.x: Gate decision logic (unit)", () => { verdict: "NEEDS_FIXES", summary: "Multiple issues remain after remediation", findings: [ - makeFinding({ severity: "critical", category: "incorrect_implementation", description: "Broken parser" }), - makeFinding({ severity: "important", category: "missing_requirement", description: "Missing validation" }), + makeFinding({ + severity: "critical", + category: "incorrect_implementation", + description: "Broken parser", + }), + makeFinding({ + severity: "important", + category: "missing_requirement", + description: "Missing validation", + }), makeFinding({ severity: "important", category: "incomplete_work", description: "No tests" }), ], }); @@ -955,8 +1114,8 @@ describe("8.x: Gate decision logic (unit)", () => { expect(evaluation.pass).toBe(false); // Verify the findings summary the task-runner would log - const criticals = verdict.findings.filter(f => f.severity === "critical"); - const importants = verdict.findings.filter(f => f.severity === "important"); + const criticals = verdict.findings.filter((f) => f.severity === "critical"); + const importants = verdict.findings.filter((f) => f.severity === "important"); expect(criticals).toHaveLength(1); expect(importants).toHaveLength(2); }); @@ -978,7 +1137,11 @@ describe("9.x: Remediation cycle determinism (unit)", () => { confidence: "high", summary: "Critical bugs found", findings: [ - makeFinding({ severity: "critical", category: "incorrect_implementation", description: "Buffer overflow in parser" }), + makeFinding({ + severity: "critical", + category: "incorrect_implementation", + description: "Buffer overflow in parser", + }), ], }); const feedback = generateFeedbackMd(v, 1, 2, "no_critical"); @@ -1017,7 +1180,11 @@ describe("9.x: Remediation cycle determinism (unit)", () => { const failVerdict = makeVerdict({ verdict: "NEEDS_FIXES", findings: [ - makeFinding({ severity: "critical", category: "incorrect_implementation", description: "still broken" }), + makeFinding({ + severity: "critical", + category: "incorrect_implementation", + description: "still broken", + }), ], }); @@ -1073,9 +1240,9 @@ describe("9.x: Remediation cycle determinism (unit)", () => { }); // Under no_critical threshold, only critical/important are counted in summary - const criticals = v.findings.filter(f => f.severity === "critical"); - const importants = v.findings.filter(f => f.severity === "important"); - const suggestions = v.findings.filter(f => f.severity === "suggestion"); + const criticals = v.findings.filter((f) => f.severity === "critical"); + const importants = v.findings.filter((f) => f.severity === "important"); + const suggestions = v.findings.filter((f) => f.severity === "suggestion"); expect(criticals).toHaveLength(2); expect(importants).toHaveLength(1); expect(suggestions).toHaveLength(1); @@ -1100,7 +1267,11 @@ describe("9.x: Remediation cycle determinism (unit)", () => { const v = makeVerdict({ verdict: "NEEDS_FIXES", findings: [ - makeFinding({ severity: "important", category: "incomplete_work", description: "Still incomplete" }), + makeFinding({ + severity: "important", + category: "incomplete_work", + description: "Still incomplete", + }), ], }); const feedbackCycle2 = generateFeedbackMd(v, 2, 3, "no_critical"); @@ -1343,7 +1514,11 @@ describe("11.x: Composed gate decision flow", () => { */ function deleteVerdictFile(taskFolder: string): void { const verdictPath = join(taskFolder, VERDICT_FILENAME); - try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch { /* ignore */ } + try { + if (existsSync(verdictPath)) unlinkSync(verdictPath); + } catch { + /* ignore */ + } } // ── 11.1: Full PASS flow — verdict → .DONE created ────────────── @@ -1353,12 +1528,15 @@ describe("11.x: Composed gate decision flow", () => { const taskId = "TP-FLOW-PASS"; // Agent writes a PASS verdict - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - confidence: "high", - summary: "All requirements met, tests pass", - findings: [], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + confidence: "high", + summary: "All requirements met, tests pass", + findings: [], + }), + ); // Gate reads and evaluates (same call as task-runner) const { verdict, evaluation } = readAndEvaluateVerdict(dir, "no_critical"); @@ -1385,15 +1563,23 @@ describe("11.x: Composed gate decision flow", () => { const maxReviewCycles = 2; // Agent writes a NEEDS_FIXES verdict - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - confidence: "high", - summary: "Critical bug in parser", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Buffer overflow", file: "parser.ts", remediation: "Add bounds check" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + confidence: "high", + summary: "Critical bug in parser", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Buffer overflow", + file: "parser.ts", + remediation: "Add bounds check", + }, + ], + }), + ); // Gate reads and evaluates const { verdict, evaluation } = readAndEvaluateVerdict(dir, threshold); @@ -1441,13 +1627,21 @@ describe("11.x: Composed gate decision flow", () => { // ── Cycle 1: Review fails ──────────────────────────────── reviewCycle++; - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "OOB read", file: "parser.ts", remediation: "Add length check" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "OOB read", + file: "parser.ts", + remediation: "Add length check", + }, + ], + }), + ); const result1 = readAndEvaluateVerdict(dir, threshold); expect(result1.evaluation.pass).toBe(false); @@ -1472,12 +1666,15 @@ describe("11.x: Composed gate decision flow", () => { // ── Cycle 2: Review passes ─────────────────────────────── reviewCycle++; // Agent writes a PASS verdict after fix - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - confidence: "high", - summary: "Fix verified, all requirements met", - findings: [], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + confidence: "high", + summary: "Fix verified, all requirements met", + findings: [], + }), + ); const result2 = readAndEvaluateVerdict(dir, threshold); expect(result2.evaluation.pass).toBe(true); @@ -1517,16 +1714,29 @@ describe("11.x: Composed gate decision flow", () => { // ── Cycle 1: fails ─────────────────────────────────────── reviewCycle++; - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - summary: "Critical bugs", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Memory leak", file: "pool.ts", remediation: "Free buffer" }, - { severity: "important", category: "missing_requirement", - description: "No error handling", file: "pool.ts", remediation: "Add try/catch" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + summary: "Critical bugs", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Memory leak", + file: "pool.ts", + remediation: "Free buffer", + }, + { + severity: "important", + category: "missing_requirement", + description: "No error handling", + file: "pool.ts", + remediation: "Add try/catch", + }, + ], + }), + ); const r1 = readAndEvaluateVerdict(dir, threshold); lastVerdict = r1.verdict; @@ -1540,14 +1750,22 @@ describe("11.x: Composed gate decision flow", () => { // ── Cycle 2: still fails ───────────────────────────────── reviewCycle++; - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - summary: "Memory leak partially fixed but new issue", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Double free", file: "pool.ts", remediation: "Track allocation state" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + summary: "Memory leak partially fixed but new issue", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Double free", + file: "pool.ts", + remediation: "Track allocation state", + }, + ], + }), + ); const r2 = readAndEvaluateVerdict(dir, threshold); lastVerdict = r2.verdict; @@ -1560,8 +1778,8 @@ describe("11.x: Composed gate decision flow", () => { expect(existsSync(join(dir, ".DONE"))).toBe(false); // Verify findings summary for logging (mirrors task-runner terminal failure logic) - const criticals = lastVerdict!.findings.filter(f => f.severity === "critical"); - const importants = lastVerdict!.findings.filter(f => f.severity === "important"); + const criticals = lastVerdict!.findings.filter((f) => f.severity === "critical"); + const importants = lastVerdict!.findings.filter((f) => f.severity === "important"); const summaryParts = [ criticals.length > 0 ? `${criticals.length} critical` : "", importants.length > 0 ? `${importants.length} important` : "", @@ -1577,13 +1795,21 @@ describe("11.x: Composed gate decision flow", () => { const threshold: PassThreshold = "no_critical"; // Cycle 1: review fails - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Bug", file: "a.ts", remediation: "fix" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Bug", + file: "a.ts", + remediation: "fix", + }, + ], + }), + ); const r1 = readAndEvaluateVerdict(dir, threshold); expect(r1.evaluation.pass).toBe(false); @@ -1642,13 +1868,21 @@ describe("11.x: Composed gate decision flow", () => { // ── Cycle 1: fails ─────────────────────────────────────── reviewCycle++; - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Bug", file: "a.ts", remediation: "fix" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Bug", + file: "a.ts", + remediation: "fix", + }, + ], + }), + ); const r1 = readAndEvaluateVerdict(dir, threshold); expect(r1.evaluation.pass).toBe(false); @@ -1660,13 +1894,21 @@ describe("11.x: Composed gate decision flow", () => { // ── Cycle 2: still fails ───────────────────────────────── reviewCycle++; - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Still broken", file: "a.ts", remediation: "try again" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Still broken", + file: "a.ts", + remediation: "try again", + }, + ], + }), + ); const r2 = readAndEvaluateVerdict(dir, threshold); expect(r2.evaluation.pass).toBe(false); @@ -1689,15 +1931,23 @@ describe("11.x: Composed gate decision flow", () => { const threshold: PassThreshold = "all_clear"; // Agent writes verdict with only suggestions - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - confidence: "medium", - summary: "Minor issues remain", - findings: [ - { severity: "suggestion", category: "incomplete_work", - description: "Variable naming", file: "utils.ts", remediation: "Rename to be descriptive" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + confidence: "medium", + summary: "Minor issues remain", + findings: [ + { + severity: "suggestion", + category: "incomplete_work", + description: "Variable naming", + file: "utils.ts", + remediation: "Rename to be descriptive", + }, + ], + }), + ); const { verdict, evaluation } = readAndEvaluateVerdict(dir, threshold); expect(evaluation.pass).toBe(false); @@ -1714,10 +1964,13 @@ describe("11.x: Composed gate decision flow", () => { // Same findings under no_critical would PASS (suggestions don't block) // Note: can't re-read the same file because verdict value "NEEDS_FIXES" // triggers verdict_says_needs_fixes rule. Test via applyVerdictRules directly. - const noCritEval = applyVerdictRules(makeVerdict({ - verdict: "PASS", - findings: verdict.findings, - }), "no_critical"); + const noCritEval = applyVerdictRules( + makeVerdict({ + verdict: "PASS", + findings: verdict.findings, + }), + "no_critical", + ); expect(noCritEval.pass).toBe(true); }); @@ -1727,13 +1980,21 @@ describe("11.x: Composed gate decision flow", () => { const dir = makeTestDir("flow-verdict-delete"); // Write a NEEDS_FIXES verdict - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "NEEDS_FIXES", - findings: [ - { severity: "critical", category: "incorrect_implementation", - description: "Bug", file: "a.ts", remediation: "fix" }, - ], - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "NEEDS_FIXES", + findings: [ + { + severity: "critical", + category: "incorrect_implementation", + description: "Bug", + file: "a.ts", + remediation: "fix", + }, + ], + }), + ); // Read and evaluate — NEEDS_FIXES const r1 = readAndEvaluateVerdict(dir, "no_critical"); @@ -1749,10 +2010,13 @@ describe("11.x: Composed gate decision flow", () => { expect(r2.verdict.summary).toContain("fail-open"); // Write a new PASS verdict (normal case: agent succeeds) - writeFileSync(join(dir, VERDICT_FILENAME), makeVerdictJson({ - verdict: "PASS", - summary: "Fixed", - })); + writeFileSync( + join(dir, VERDICT_FILENAME), + makeVerdictJson({ + verdict: "PASS", + summary: "Fixed", + }), + ); const r3 = readAndEvaluateVerdict(dir, "no_critical"); expect(r3.evaluation.pass).toBe(true); diff --git a/extensions/tests/resume-bug-fixes.test.ts b/extensions/tests/resume-bug-fixes.test.ts index e6dfa40a..b0f76186 100644 --- a/extensions/tests/resume-bug-fixes.test.ts +++ b/extensions/tests/resume-bug-fixes.test.ts @@ -46,9 +46,36 @@ function makeState(overrides?: Partial): PersistedBatchStat wavePlan: [["task-1", "task-2"], ["task-3"]], lanes: [], tasks: [ - { taskId: "task-1", status: "succeeded" as LaneTaskStatus, sessionName: "sess-1", laneNumber: 1, taskFolder: "/tasks/task-1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "succeeded" as LaneTaskStatus, sessionName: "sess-2", laneNumber: 1, taskFolder: "/tasks/task-2", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-3", status: "pending" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/tasks/task-3", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "succeeded" as LaneTaskStatus, + sessionName: "sess-1", + laneNumber: 1, + taskFolder: "/tasks/task-1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "succeeded" as LaneTaskStatus, + sessionName: "sess-2", + laneNumber: 1, + taskFolder: "/tasks/task-2", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-3", + status: "pending" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/tasks/task-3", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [], totalTasks: 3, @@ -94,23 +121,17 @@ describe("1.x: getMergeStatusForWave", () => { }); it("1.2: returns 'succeeded' when wave merge succeeded", () => { - const mergeResults = [ - { waveIndex: 0, status: "succeeded" as const }, - ]; + const mergeResults = [{ waveIndex: 0, status: "succeeded" as const }]; expect(getMergeStatusForWave(mergeResults, 0)).toBe("succeeded"); }); it("1.3: returns 'failed' when wave merge failed", () => { - const mergeResults = [ - { waveIndex: 0, status: "failed" as const }, - ]; + const mergeResults = [{ waveIndex: 0, status: "failed" as const }]; expect(getMergeStatusForWave(mergeResults, 0)).toBe("failed"); }); it("1.4: returns 'partial' when wave merge was partial", () => { - const mergeResults = [ - { waveIndex: 0, status: "partial" as const }, - ]; + const mergeResults = [{ waveIndex: 0, status: "partial" as const }]; expect(getMergeStatusForWave(mergeResults, 0)).toBe("partial"); }); @@ -124,9 +145,7 @@ describe("1.x: getMergeStatusForWave", () => { }); it("1.6: returns null for non-matching wave index", () => { - const mergeResults = [ - { waveIndex: 0, status: "succeeded" as const }, - ]; + const mergeResults = [{ waveIndex: 0, status: "succeeded" as const }]; expect(getMergeStatusForWave(mergeResults, 1)).toBeNull(); }); @@ -167,9 +186,7 @@ describe("1.x: computeResumePoint — merge skip detection (Bug #102)", () => { it("1.9: wave with all succeeded tasks + succeeded merge → skipped normally", () => { const state = makeState({ wavePlan: [["task-1", "task-2"], ["task-3"]], - mergeResults: [ - { waveIndex: 0, status: "succeeded" as const }, - ] as any, + mergeResults: [{ waveIndex: 0, status: "succeeded" as const }] as any, }); const reconciled: ReconciledTaskState[] = [ @@ -189,9 +206,7 @@ describe("1.x: computeResumePoint — merge skip detection (Bug #102)", () => { it("1.10: wave with all succeeded tasks + failed merge → flagged for retry", () => { const state = makeState({ wavePlan: [["task-1", "task-2"], ["task-3"]], - mergeResults: [ - { waveIndex: 0, status: "failed" as const }, - ] as any, + mergeResults: [{ waveIndex: 0, status: "failed" as const }] as any, }); const reconciled: ReconciledTaskState[] = [ @@ -231,9 +246,36 @@ describe("1.x: computeResumePoint — merge skip detection (Bug #102)", () => { wavePlan: [["task-1"], ["task-2"], ["task-3"]], totalWaves: 3, tasks: [ - { taskId: "task-1", status: "succeeded" as LaneTaskStatus, sessionName: "s1", laneNumber: 1, taskFolder: "/t/1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "succeeded" as LaneTaskStatus, sessionName: "s2", laneNumber: 1, taskFolder: "/t/2", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-3", status: "pending" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/3", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "succeeded" as LaneTaskStatus, + sessionName: "s1", + laneNumber: 1, + taskFolder: "/t/1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "succeeded" as LaneTaskStatus, + sessionName: "s2", + laneNumber: 1, + taskFolder: "/t/2", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-3", + status: "pending" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/3", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [], // Both wave 0 and wave 1 are missing merges }); @@ -256,9 +298,7 @@ describe("1.x: computeResumePoint — merge skip detection (Bug #102)", () => { it("1.13: partial merge status → flagged for retry", () => { const state = makeState({ wavePlan: [["task-1", "task-2"], ["task-3"]], - mergeResults: [ - { waveIndex: 0, status: "partial" as const }, - ] as any, + mergeResults: [{ waveIndex: 0, status: "partial" as const }] as any, }); const reconciled: ReconciledTaskState[] = [ @@ -336,7 +376,7 @@ describe("2.x: reconcileTaskStates — stale session names (Bug #102b)", () => { }); const aliveSessions = new Set(); // session is dead - const doneTaskIds = new Set(); // no .DONE + const doneTaskIds = new Set(); // no .DONE const existingWorktrees = new Set(); // no worktree const result = reconcileTaskStates(state, aliveSessions, doneTaskIds, existingWorktrees); @@ -439,9 +479,36 @@ describe("2.x: reconcileTaskStates — stale session names (Bug #102b)", () => { it("2.6: multiple pending tasks with stale sessions → all remain pending", () => { const state = makeState({ tasks: [ - { taskId: "task-a", status: "pending" as LaneTaskStatus, sessionName: "stale-1", laneNumber: 1, taskFolder: "/t/a", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-b", status: "pending" as LaneTaskStatus, sessionName: "stale-2", laneNumber: 2, taskFolder: "/t/b", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-c", status: "pending" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/c", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-a", + status: "pending" as LaneTaskStatus, + sessionName: "stale-1", + laneNumber: 1, + taskFolder: "/t/a", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-b", + status: "pending" as LaneTaskStatus, + sessionName: "stale-2", + laneNumber: 2, + taskFolder: "/t/b", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-c", + status: "pending" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/c", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], }); @@ -456,7 +523,16 @@ describe("2.x: reconcileTaskStates — stale session names (Bug #102b)", () => { it("2.7: pending task with .DONE → mark-complete (Precedence 1 wins)", () => { const state = makeState({ tasks: [ - { taskId: "task-done", status: "pending" as LaneTaskStatus, sessionName: "stale", laneNumber: 1, taskFolder: "/t/done", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-done", + status: "pending" as LaneTaskStatus, + sessionName: "stale", + laneNumber: 1, + taskFolder: "/t/done", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], }); @@ -477,9 +553,36 @@ describe("3.x: State coherence — mergeResults alignment", () => { wavePlan: [["task-1"], ["task-2"], ["task-3"]], totalWaves: 3, tasks: [ - { taskId: "task-1", status: "succeeded" as LaneTaskStatus, sessionName: "s1", laneNumber: 1, taskFolder: "/t/1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "succeeded" as LaneTaskStatus, sessionName: "s2", laneNumber: 1, taskFolder: "/t/2", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-3", status: "pending" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/3", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "succeeded" as LaneTaskStatus, + sessionName: "s1", + laneNumber: 1, + taskFolder: "/t/1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "succeeded" as LaneTaskStatus, + sessionName: "s2", + laneNumber: 1, + taskFolder: "/t/2", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-3", + status: "pending" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/3", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [ { waveIndex: 0, status: "succeeded" as const }, @@ -508,9 +611,36 @@ describe("3.x: State coherence — mergeResults alignment", () => { wavePlan: [["task-1"], ["task-2"], ["task-3"]], totalWaves: 3, tasks: [ - { taskId: "task-1", status: "succeeded" as LaneTaskStatus, sessionName: "s1", laneNumber: 1, taskFolder: "/t/1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "succeeded" as LaneTaskStatus, sessionName: "s2", laneNumber: 1, taskFolder: "/t/2", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-3", status: "pending" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/3", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "succeeded" as LaneTaskStatus, + sessionName: "s1", + laneNumber: 1, + taskFolder: "/t/1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "succeeded" as LaneTaskStatus, + sessionName: "s2", + laneNumber: 1, + taskFolder: "/t/2", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-3", + status: "pending" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/3", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [ { waveIndex: 0, status: "succeeded" as const }, @@ -538,8 +668,26 @@ describe("3.x: State coherence — mergeResults alignment", () => { wavePlan: [["task-1"], ["task-2"]], totalWaves: 2, tasks: [ - { taskId: "task-1", status: "succeeded" as LaneTaskStatus, sessionName: "s1", laneNumber: 1, taskFolder: "/t/1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "succeeded" as LaneTaskStatus, sessionName: "s2", laneNumber: 1, taskFolder: "/t/2", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "succeeded" as LaneTaskStatus, + sessionName: "s1", + laneNumber: 1, + taskFolder: "/t/1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "succeeded" as LaneTaskStatus, + sessionName: "s2", + laneNumber: 1, + taskFolder: "/t/2", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [ { waveIndex: 0, status: "succeeded" as const }, @@ -564,9 +712,36 @@ describe("3.x: State coherence — mergeResults alignment", () => { const state = makeState({ wavePlan: [["task-1", "task-2"], ["task-3"]], tasks: [ - { taskId: "task-1", status: "skipped" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "skipped" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/2", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-3", status: "pending" as LaneTaskStatus, sessionName: "", laneNumber: 0, taskFolder: "/t/3", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "skipped" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "skipped" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/2", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-3", + status: "pending" as LaneTaskStatus, + sessionName: "", + laneNumber: 0, + taskFolder: "/t/3", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [], }); @@ -589,9 +764,36 @@ describe("3.x: State coherence — mergeResults alignment", () => { wavePlan: [["task-1"], ["task-2"], ["task-3"]], totalWaves: 3, tasks: [ - { taskId: "task-1", status: "succeeded" as LaneTaskStatus, sessionName: "s1", laneNumber: 1, taskFolder: "/t/1", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-2", status: "succeeded" as LaneTaskStatus, sessionName: "s2", laneNumber: 1, taskFolder: "/t/2", startedAt: null, endedAt: null, exitReason: "" }, - { taskId: "task-3", status: "running" as LaneTaskStatus, sessionName: "s3", laneNumber: 2, taskFolder: "/t/3", startedAt: null, endedAt: null, exitReason: "" }, + { + taskId: "task-1", + status: "succeeded" as LaneTaskStatus, + sessionName: "s1", + laneNumber: 1, + taskFolder: "/t/1", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-2", + status: "succeeded" as LaneTaskStatus, + sessionName: "s2", + laneNumber: 1, + taskFolder: "/t/2", + startedAt: null, + endedAt: null, + exitReason: "", + }, + { + taskId: "task-3", + status: "running" as LaneTaskStatus, + sessionName: "s3", + laneNumber: 2, + taskFolder: "/t/3", + startedAt: null, + endedAt: null, + exitReason: "", + }, ], mergeResults: [ { waveIndex: 0, status: "failed" as const }, // Wave 0 merge failed diff --git a/extensions/tests/resume-segment-frontier.test.ts b/extensions/tests/resume-segment-frontier.test.ts index f403e5c4..4e6b20d2 100644 --- a/extensions/tests/resume-segment-frontier.test.ts +++ b/extensions/tests/resume-segment-frontier.test.ts @@ -29,25 +29,29 @@ function makeState(overrides: Partial = {}): PersistedBatch currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - taskIds: ["TP-001"], - }], - tasks: [{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-001", - startedAt: Date.now() - 900, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + taskIds: ["TP-001"], + }, + ], + tasks: [ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-001", + startedAt: Date.now() - 900, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -92,22 +96,29 @@ describe("TP-135 resume segment fallback behavior", () => { try { const state = makeState({ - tasks: [{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder, - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-001::api", "TP-001::web"], - activeSegmentId: "TP-001::web", - }], + tasks: [ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder, + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-001::api", "TP-001::web"], + activeSegmentId: "TP-001::web", + }, + ], segments: [ makeSegment({ segmentId: "TP-001::api", status: "succeeded", endedAt: Date.now() - 500 }), - makeSegment({ segmentId: "TP-001::web", repoId: "web", status: "running", dependsOnSegmentIds: ["TP-001::api"] }), + makeSegment({ + segmentId: "TP-001::web", + repoId: "web", + status: "running", + dependsOnSegmentIds: ["TP-001::api"], + }), ], }); @@ -132,19 +143,21 @@ describe("TP-135 resume segment fallback behavior", () => { { waveIndex: 0, status: "succeeded" }, { waveIndex: 1, status: "succeeded" }, ] as any, - tasks: [{ - taskId: "TP-010", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "succeeded", - taskFolder: "/tmp/tasks/TP-010", - startedAt: Date.now() - 1000, - endedAt: Date.now() - 100, - doneFileFound: true, - exitReason: "done", - segmentIds: ["TP-010::api", "TP-010::web"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-010", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "succeeded", + taskFolder: "/tmp/tasks/TP-010", + startedAt: Date.now() - 1000, + endedAt: Date.now() - 100, + doneFileFound: true, + exitReason: "done", + segmentIds: ["TP-010::api", "TP-010::web"], + activeSegmentId: null, + }, + ], segments: [], }); @@ -161,22 +174,22 @@ describe("TP-135 resume segment fallback behavior", () => { it("mid-segment crash re-executes the running segment", () => { const state = makeState({ - tasks: [{ - taskId: "TP-020", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-020", - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-020::api"], - activeSegmentId: "TP-020::api", - }], - segments: [ - makeSegment({ taskId: "TP-020", segmentId: "TP-020::api", status: "running" }), + tasks: [ + { + taskId: "TP-020", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-020", + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-020::api"], + activeSegmentId: "TP-020::api", + }, ], + segments: [makeSegment({ taskId: "TP-020", segmentId: "TP-020::api", status: "running" })], }); reconstructSegmentFrontier(state); @@ -190,21 +203,28 @@ describe("TP-135 resume segment fallback behavior", () => { const state = makeState({ wavePlan: [["TP-021"], ["TP-021"]], totalWaves: 2, - tasks: [{ - taskId: "TP-021", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-021", - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-021::api", "TP-021::web"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-021", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-021", + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-021::api", "TP-021::web"], + activeSegmentId: null, + }, + ], segments: [ - makeSegment({ taskId: "TP-021", segmentId: "TP-021::api", status: "succeeded", endedAt: Date.now() - 100 }), + makeSegment({ + taskId: "TP-021", + segmentId: "TP-021::api", + status: "succeeded", + endedAt: Date.now() - 100, + }), ], }); @@ -224,22 +244,36 @@ describe("TP-135 resume segment fallback behavior", () => { { waveIndex: 0, status: "succeeded" }, { waveIndex: 1, status: "succeeded" }, ] as any, - tasks: [{ - taskId: "TP-022", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-022", - startedAt: Date.now() - 2000, - endedAt: Date.now() - 100, - doneFileFound: true, - exitReason: "done", - segmentIds: ["TP-022::api", "TP-022::web"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-022", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-022", + startedAt: Date.now() - 2000, + endedAt: Date.now() - 100, + doneFileFound: true, + exitReason: "done", + segmentIds: ["TP-022::api", "TP-022::web"], + activeSegmentId: null, + }, + ], segments: [ - makeSegment({ taskId: "TP-022", segmentId: "TP-022::api", status: "succeeded", endedAt: Date.now() - 500 }), - makeSegment({ taskId: "TP-022", segmentId: "TP-022::web", repoId: "web", status: "succeeded", dependsOnSegmentIds: ["TP-022::api"], endedAt: Date.now() - 100 }), + makeSegment({ + taskId: "TP-022", + segmentId: "TP-022::api", + status: "succeeded", + endedAt: Date.now() - 500, + }), + makeSegment({ + taskId: "TP-022", + segmentId: "TP-022::web", + repoId: "web", + status: "succeeded", + dependsOnSegmentIds: ["TP-022::api"], + endedAt: Date.now() - 100, + }), ], }); @@ -283,7 +317,12 @@ describe("TP-135 resume segment fallback behavior", () => { }, ], segments: [ - makeSegment({ taskId: "TP-030", segmentId: "TP-030::api", status: "failed", endedAt: Date.now() - 100 }), + makeSegment({ + taskId: "TP-030", + segmentId: "TP-030::api", + status: "failed", + endedAt: Date.now() - 100, + }), ], }); @@ -298,23 +337,44 @@ describe("TP-135 resume segment fallback behavior", () => { const state = makeState({ wavePlan: [["TP-041"], ["TP-041"], ["TP-041"]], totalWaves: 3, - tasks: [{ - taskId: "TP-041", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-041", - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-041::api", "TP-041::ops", "TP-041::web"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-041", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-041", + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-041::api", "TP-041::ops", "TP-041::web"], + activeSegmentId: null, + }, + ], segments: [ - makeSegment({ taskId: "TP-041", segmentId: "TP-041::api", status: "succeeded", endedAt: Date.now() - 500 }), - makeSegment({ taskId: "TP-041", segmentId: "TP-041::ops", repoId: "ops", status: "pending", dependsOnSegmentIds: ["TP-041::api"], expandedFrom: "TP-041::api", expansionRequestId: "exp-041" } as any), - makeSegment({ taskId: "TP-041", segmentId: "TP-041::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-041::ops"] }), + makeSegment({ + taskId: "TP-041", + segmentId: "TP-041::api", + status: "succeeded", + endedAt: Date.now() - 500, + }), + makeSegment({ + taskId: "TP-041", + segmentId: "TP-041::ops", + repoId: "ops", + status: "pending", + dependsOnSegmentIds: ["TP-041::api"], + expandedFrom: "TP-041::api", + expansionRequestId: "exp-041", + } as any), + makeSegment({ + taskId: "TP-041", + segmentId: "TP-041::web", + repoId: "web", + status: "pending", + dependsOnSegmentIds: ["TP-041::ops"], + }), ], }); @@ -330,22 +390,35 @@ describe("TP-135 resume segment fallback behavior", () => { wavePlan: [["TP-050"]], totalWaves: 1, mergeResults: [{ waveIndex: 0, status: "succeeded" }] as any, - tasks: [{ - taskId: "TP-050", - laneNumber: 1, - sessionName: "", - status: "pending", - taskFolder: "/tmp/tasks/TP-050", - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-050::api", "TP-050::web"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-050", + laneNumber: 1, + sessionName: "", + status: "pending", + taskFolder: "/tmp/tasks/TP-050", + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-050::api", "TP-050::web"], + activeSegmentId: null, + }, + ], segments: [ - makeSegment({ taskId: "TP-050", segmentId: "TP-050::api", status: "succeeded", endedAt: Date.now() - 100 }), - makeSegment({ taskId: "TP-050", segmentId: "TP-050::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-050::api"] }), + makeSegment({ + taskId: "TP-050", + segmentId: "TP-050::api", + status: "succeeded", + endedAt: Date.now() - 100, + }), + makeSegment({ + taskId: "TP-050", + segmentId: "TP-050::web", + repoId: "web", + status: "pending", + dependsOnSegmentIds: ["TP-050::api"], + }), ], }); @@ -407,27 +480,25 @@ describe("TP-135 resume segment fallback behavior", () => { }); const runtimeWavePlan = buildResumeRuntimeWavePlan(state); - expect(runtimeWavePlan).toEqual([ - ["TP-060", "TP-061"], - ["TP-060", "TP-061"], - ["TP-062"], - ]); + expect(runtimeWavePlan).toEqual([["TP-060", "TP-061"], ["TP-060", "TP-061"], ["TP-062"]]); }); it("repo-singleton tasks without segment IDs keep legacy resume behavior", () => { const state = makeState({ wavePlan: [["TP-040"]], - tasks: [{ - taskId: "TP-040", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-040", - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + tasks: [ + { + taskId: "TP-040", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-040", + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], segments: [], }); @@ -445,30 +516,47 @@ describe("TP-135 resume segment fallback behavior", () => { describe("TP-169 resume after segment expansion — no crash, taskFolder populated", () => { it("taskFolder is set on task stub even when persisted record has empty taskFolder", () => { const state = makeState({ - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - taskIds: ["TP-070"], - }], - tasks: [{ - taskId: "TP-070", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "", // empty — not enriched from discovery - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-070::api", "TP-070::web"], - activeSegmentId: "TP-070::web", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + taskIds: ["TP-070"], + }, + ], + tasks: [ + { + taskId: "TP-070", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "", // empty — not enriched from discovery + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-070::api", "TP-070::web"], + activeSegmentId: "TP-070::web", + }, + ], segments: [ - makeSegment({ taskId: "TP-070", segmentId: "TP-070::api", status: "succeeded", endedAt: Date.now() - 500 }), - makeSegment({ taskId: "TP-070", segmentId: "TP-070::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-070::api"], expandedFrom: "TP-070::api", expansionRequestId: "exp-070" } as any), + makeSegment({ + taskId: "TP-070", + segmentId: "TP-070::api", + status: "succeeded", + endedAt: Date.now() - 500, + }), + makeSegment({ + taskId: "TP-070", + segmentId: "TP-070::web", + repoId: "web", + status: "pending", + dependsOnSegmentIds: ["TP-070::api"], + expandedFrom: "TP-070::api", + expansionRequestId: "exp-070", + } as any), ], }); @@ -486,30 +574,45 @@ describe("TP-169 resume after segment expansion — no crash, taskFolder populat it("taskFolder is preserved on task stub when persisted record has a valid path", () => { const state = makeState({ - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - taskIds: ["TP-071"], - }], - tasks: [{ - taskId: "TP-071", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-071", - startedAt: Date.now() - 1000, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-071::api", "TP-071::web"], - activeSegmentId: "TP-071::web", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + taskIds: ["TP-071"], + }, + ], + tasks: [ + { + taskId: "TP-071", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-071", + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-071::api", "TP-071::web"], + activeSegmentId: "TP-071::web", + }, + ], segments: [ - makeSegment({ taskId: "TP-071", segmentId: "TP-071::api", status: "succeeded", endedAt: Date.now() - 500 }), - makeSegment({ taskId: "TP-071", segmentId: "TP-071::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-071::api"] }), + makeSegment({ + taskId: "TP-071", + segmentId: "TP-071::api", + status: "succeeded", + endedAt: Date.now() - 500, + }), + makeSegment({ + taskId: "TP-071", + segmentId: "TP-071::web", + repoId: "web", + status: "pending", + dependsOnSegmentIds: ["TP-071::api"], + }), ], }); @@ -521,28 +624,32 @@ describe("TP-169 resume after segment expansion — no crash, taskFolder populat it("task stub is not null when only segment fields are set (no repoId)", () => { const state = makeState({ - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - taskIds: ["TP-072"], - }], - tasks: [{ - taskId: "TP-072", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "pending", - taskFolder: "", - startedAt: null, - endedAt: null, - doneFileFound: false, - exitReason: "", - // Only segment fields, no repoId/resolvedRepoId - segmentIds: ["TP-072::default"], - activeSegmentId: "TP-072::default", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + taskIds: ["TP-072"], + }, + ], + tasks: [ + { + taskId: "TP-072", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "pending", + taskFolder: "", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + // Only segment fields, no repoId/resolvedRepoId + segmentIds: ["TP-072::default"], + activeSegmentId: "TP-072::default", + }, + ], }); const lanes = reconstructAllocatedLanes(state.lanes, state.tasks); diff --git a/extensions/tests/retry-matrix.test.ts b/extensions/tests/retry-matrix.test.ts index 28b2de9a..0ad15011 100644 --- a/extensions/tests/retry-matrix.test.ts +++ b/extensions/tests/retry-matrix.test.ts @@ -31,10 +31,7 @@ import { applyMergeRetryLoop, } from "../taskplane/messages.ts"; import type { MergeRetryCallbacks } from "../taskplane/types.ts"; -import { - MERGE_RETRY_POLICY_MATRIX, - MERGE_FAILURE_CLASSIFICATIONS, -} from "../taskplane/types.ts"; +import { MERGE_RETRY_POLICY_MATRIX, MERGE_FAILURE_CLASSIFICATIONS } from "../taskplane/types.ts"; import type { MergeWaveResult, MergeLaneResult, @@ -77,9 +74,7 @@ function makeWaveResult(overrides: Partial = {}): MergeWaveResu } /** Build mock callbacks for applyMergeRetryLoop testing. */ -function makeMockCallbacks(options: { - performMergeResults?: MergeWaveResult[]; -} = {}): { +function makeMockCallbacks(options: { performMergeResults?: MergeWaveResult[] } = {}): { callbacks: MergeRetryCallbacks; logs: string[]; notifications: Array<{ message: string; level: string }>; @@ -107,7 +102,19 @@ function makeMockCallbacks(options: { sleep: (ms) => sleepCalls.push(ms), }; - return { callbacks, logs, notifications, persistTriggers, sleepCalls, mergeCallCount: 0, ...{ get mergeCallCount() { return tracker.mergeCallCount; } } as any }; + return { + callbacks, + logs, + notifications, + persistTriggers, + sleepCalls, + mergeCallCount: 0, + ...({ + get mergeCallCount() { + return tracker.mergeCallCount; + }, + } as any), + }; } // ══════════════════════════════════════════════════════════════════════ @@ -117,9 +124,7 @@ function makeMockCallbacks(options: { describe("1.x — classifyMergeFailure", () => { it("1.1: verification_new_failure lane error → verification_new_failure", () => { const result = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 3 new failure(s)" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 3 new failure(s)" })], }); expect(classifyMergeFailure(result)).toBe("verification_new_failure"); @@ -179,9 +184,7 @@ describe("1.x — classifyMergeFailure", () => { it("1.7: verification_new_failure takes priority over pattern-matched reason", () => { const result = makeWaveResult({ failureReason: "lock file issue", - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 new failure(s)" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 new failure(s)" })], }); // Lane-level errors are checked first @@ -356,7 +359,11 @@ describe("4.x — Multi-attempt retry: git_lock_file (maxAttempts=2)", () => { const failResult = makeWaveResult({ failureReason: "Unable to create lock file", }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, + }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); @@ -374,7 +381,11 @@ describe("4.x — Multi-attempt retry: git_lock_file (maxAttempts=2)", () => { const failResult2 = makeWaveResult({ failureReason: "Unable to create lock file", }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, + }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [failResult2, successResult] }); @@ -413,7 +424,11 @@ describe("4.x — Multi-attempt retry: git_lock_file (maxAttempts=2)", () => { const failResult = makeWaveResult({ failureReason: "Unable to create lock file", }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, + }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); @@ -454,7 +469,11 @@ describe("5.x — Cooldown delay enforcement", () => { const failResult = makeWaveResult({ failureReason: "Unable to create lock file", }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, + }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); @@ -467,11 +486,13 @@ describe("5.x — Cooldown delay enforcement", () => { it("5.6: applyMergeRetryLoop does NOT call sleep for verification_new_failure (cooldown=0)", async () => { const failResult = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 new failure(s)" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 new failure(s)" })], + }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); @@ -514,11 +535,13 @@ describe("6.x — Retry counter persistence", () => { it("6.3: retry loop increments counter in retryCountByScope", async () => { const failResult = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 new failure" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 new failure" })], + }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); @@ -530,11 +553,13 @@ describe("6.x — Retry counter persistence", () => { it("6.4: retry loop persists state after increment (merge-retry-increment trigger)", async () => { const failResult = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 new failure" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 new failure" })], + }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); @@ -581,14 +606,10 @@ describe("6.x — Retry counter persistence", () => { describe("7.x — Exhaustion forces paused", () => { it("7.1: exhaustion outcome from applyMergeRetryLoop includes classification diagnostics", async () => { const failResult1 = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 2 new failure(s)" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 2 new failure(s)" })], }); const failResult2 = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 2 new failure(s)" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 2 new failure(s)" })], }); const counters: Record = {}; @@ -617,7 +638,9 @@ describe("7.x — Exhaustion forces paused", () => { // emission block inserted before phase assignment in the exhausted branch. // TP-076: Window increased from 2400 to 3200 to accommodate supervisor alert // emission block inserted after onNotify in the exhausted branch. - const afterExhausted = engineSource.substring(exhaustedIdx, exhaustedIdx + 3200); + // TP-193: Window increased from 3200 to 4500 to accommodate vertical re-wrapping + // from the Biome formatter adoption (multi-arg calls split across lines). + const afterExhausted = engineSource.substring(exhaustedIdx, exhaustedIdx + 4500); expect(afterExhausted).toContain('batchState.phase = "paused"'); expect(afterExhausted).toContain("merge-retry-exhausted"); expect(afterExhausted).toContain("preserveWorktreesForResume = true"); @@ -632,7 +655,9 @@ describe("7.x — Exhaustion forces paused", () => { // TP-076: Window increased from 1200 to 2000 to accommodate supervisor alert // emission block inserted after onNotify in the exhausted branch. - const afterExhausted = resumeSource.substring(exhaustedIdx, exhaustedIdx + 2000); + // TP-193: Window increased from 2000 to 3000 to accommodate vertical re-wrapping + // from the Biome formatter adoption (multi-arg calls split across lines). + const afterExhausted = resumeSource.substring(exhaustedIdx, exhaustedIdx + 3000); expect(afterExhausted).toContain('batchState.phase = "paused"'); expect(afterExhausted).toContain("merge-retry-exhausted"); expect(afterExhausted).toContain("preserveWorktreesForResume = true"); @@ -845,9 +870,7 @@ describe("9.x — Workspace-scoped counters (repoId in scope key)", () => { describe("10.x — applyMergeRetryLoop shared loop semantics", () => { it("10.1: safe-stop during retry returns safe_stop outcome", async () => { const failResult = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 failure" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 failure" })], }); const rollbackFailResult = makeWaveResult({ status: "failed", @@ -868,9 +891,7 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { it("10.2: safe-stop with persistence errors includes warning in message", async () => { const failResult = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 failure" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 failure" })], }); const rollbackFailResult = makeWaveResult({ status: "failed", @@ -920,7 +941,11 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { const failResult2 = makeWaveResult({ failureReason: "lock file error", }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, + }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [failResult2, successResult] }); @@ -928,20 +953,22 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { await applyMergeRetryLoop(failResult, 0, counters, mock.callbacks); // Should have received retry notifications (🔄 for each attempt, ✅ for success) - const retryNotifs = mock.notifications.filter(n => n.message.includes("🔄")); + const retryNotifs = mock.notifications.filter((n) => n.message.includes("🔄")); expect(retryNotifs.length).toBeGreaterThanOrEqual(2); - const successNotifs = mock.notifications.filter(n => n.message.includes("✅")); + const successNotifs = mock.notifications.filter((n) => n.message.includes("✅")); expect(successNotifs.length).toBe(1); }); it("10.5: retry loop persists state at correct points (increment, start, complete)", async () => { const failResult = makeWaveResult({ - laneResults: [ - makeLaneResult({ error: "verification_new_failure: 1 failure" }), - ], + laneResults: [makeLaneResult({ error: "verification_new_failure: 1 failure" })], + }); + const successResult = makeWaveResult({ + status: "succeeded", + failedLane: null, + failureReason: null, }); - const successResult = makeWaveResult({ status: "succeeded", failedLane: null, failureReason: null }); const counters: Record = {}; const mock = makeMockCallbacks({ performMergeResults: [successResult] }); diff --git a/extensions/tests/review-step-guard-runtime.test.ts b/extensions/tests/review-step-guard-runtime.test.ts index 81a39663..5e32c9e4 100644 --- a/extensions/tests/review-step-guard-runtime.test.ts +++ b/extensions/tests/review-step-guard-runtime.test.ts @@ -123,7 +123,10 @@ afterEach(() => { } }); -function withTaskFolder(stepNum: number, stepStatus: "✅ Complete" | "🟨 In Progress"): { +function withTaskFolder( + stepNum: number, + stepStatus: "✅ Complete" | "🟨 In Progress", +): { taskFolder: string; statusPath: string; promptPath: string; @@ -205,11 +208,7 @@ describe("TP-189-A2 — review_step death-spiral guard runtime behavior", () => const statusAfter = readFileSync(statusPath, "utf-8"); const rcMatch = statusAfter.match(/\*\*Review Counter:\*\*\s*(\d+)/); assert.ok(rcMatch, "STATUS.md should still have a Review Counter field"); - assert.strictEqual( - rcMatch![1], - "0", - "REFUSED path must not increment the Review Counter", - ); + assert.strictEqual(rcMatch![1], "0", "REFUSED path must not increment the Review Counter"); } finally { cleanupEnv(); } diff --git a/extensions/tests/reviewer-dashboard-visibility.test.ts b/extensions/tests/reviewer-dashboard-visibility.test.ts index 560517e0..61a31a44 100644 --- a/extensions/tests/reviewer-dashboard-visibility.test.ts +++ b/extensions/tests/reviewer-dashboard-visibility.test.ts @@ -31,27 +31,34 @@ describe("TP-121: dashboard reviewer lane-state synthesis", () => { it("maps reviewer snapshot fields to legacy lane-state shape", () => { const serverSrc = readFileSync(join(__dirname, "..", "..", "dashboard", "server.cjs"), "utf-8"); const fnSrc = extractFunction(serverSrc, "function synthesizeLaneStateFromSnapshot("); - const synthesize = new Function(`${fnSrc}; return synthesizeLaneStateFromSnapshot;`)() as - (key: string, snap: any, fallbackBatchId: string) => any; + const synthesize = new Function(`${fnSrc}; return synthesizeLaneStateFromSnapshot;`)() as ( + key: string, + snap: any, + fallbackBatchId: string, + ) => any; - const laneState = synthesize("lane-1", { - batchId: "batch-1", - taskId: "TP-121", - status: "running", - worker: { status: "running", elapsedMs: 1000, toolCalls: 2 }, - reviewer: { + const laneState = synthesize( + "lane-1", + { + batchId: "batch-1", + taskId: "TP-121", status: "running", - elapsedMs: 2500, - toolCalls: 3, - contextPct: 41, - lastTool: "read: STATUS.md", - costUsd: 0.12, - inputTokens: 111, - outputTokens: 222, - cacheReadTokens: 333, - cacheWriteTokens: 444, + worker: { status: "running", elapsedMs: 1000, toolCalls: 2 }, + reviewer: { + status: "running", + elapsedMs: 2500, + toolCalls: 3, + contextPct: 41, + lastTool: "read: STATUS.md", + costUsd: 0.12, + inputTokens: 111, + outputTokens: 222, + cacheReadTokens: 333, + cacheWriteTokens: 444, + }, }, - }, "fallback-batch"); + "fallback-batch", + ); expect(laneState.reviewerStatus).toBe("running"); expect(laneState.reviewerElapsed).toBe(2500); @@ -103,19 +110,33 @@ describe("TP-121: lane-runner reviewer-state ingestion", () => { const { statusPath } = makeTaskDir(root); const result = readReviewerTelemetrySnapshot(makeConfig(root), statusPath); expect(result).toBe(null); - } finally { rmSync(root, { recursive: true, force: true }); } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("returns snapshot when status is running with fresh updatedAt", () => { const root = mkdtempSync(join(tmpdir(), "tp121-")); try { const { taskDir, statusPath } = makeTaskDir(root); - writeFileSync(join(taskDir, ".reviewer-state.json"), JSON.stringify({ - status: "running", elapsedMs: 5000, toolCalls: 3, contextPct: 12, - costUsd: 0.05, lastTool: "read: foo.ts", inputTokens: 100, outputTokens: 50, - cacheReadTokens: 200, cacheWriteTokens: 0, updatedAt: Date.now(), - reviewType: "code", reviewStep: 2, - })); + writeFileSync( + join(taskDir, ".reviewer-state.json"), + JSON.stringify({ + status: "running", + elapsedMs: 5000, + toolCalls: 3, + contextPct: 12, + costUsd: 0.05, + lastTool: "read: foo.ts", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 200, + cacheWriteTokens: 0, + updatedAt: Date.now(), + reviewType: "code", + reviewStep: 2, + }), + ); const result = readReviewerTelemetrySnapshot(makeConfig(root), statusPath); expect(result).not.toBe(null); expect(result!.status).toBe("running"); @@ -123,32 +144,49 @@ describe("TP-121: lane-runner reviewer-state ingestion", () => { expect(result!.costUsd).toBe(0.05); expect((result as any).reviewType).toBe("code"); expect((result as any).reviewStep).toBe(2); - } finally { rmSync(root, { recursive: true, force: true }); } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("returns null when status is done", () => { const root = mkdtempSync(join(tmpdir(), "tp121-")); try { const { taskDir, statusPath } = makeTaskDir(root); - writeFileSync(join(taskDir, ".reviewer-state.json"), JSON.stringify({ - status: "done", elapsedMs: 8000, toolCalls: 5, updatedAt: Date.now(), - })); + writeFileSync( + join(taskDir, ".reviewer-state.json"), + JSON.stringify({ + status: "done", + elapsedMs: 8000, + toolCalls: 5, + updatedAt: Date.now(), + }), + ); const result = readReviewerTelemetrySnapshot(makeConfig(root), statusPath); expect(result).toBe(null); - } finally { rmSync(root, { recursive: true, force: true }); } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("returns null when updatedAt is stale (>2 minutes)", () => { const root = mkdtempSync(join(tmpdir(), "tp121-")); try { const { taskDir, statusPath } = makeTaskDir(root); - writeFileSync(join(taskDir, ".reviewer-state.json"), JSON.stringify({ - status: "running", elapsedMs: 5000, toolCalls: 3, - updatedAt: Date.now() - 180_000, // 3 minutes ago - })); + writeFileSync( + join(taskDir, ".reviewer-state.json"), + JSON.stringify({ + status: "running", + elapsedMs: 5000, + toolCalls: 3, + updatedAt: Date.now() - 180_000, // 3 minutes ago + }), + ); const result = readReviewerTelemetrySnapshot(makeConfig(root), statusPath); expect(result).toBe(null); - } finally { rmSync(root, { recursive: true, force: true }); } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("returns null for malformed JSON", () => { @@ -158,6 +196,8 @@ describe("TP-121: lane-runner reviewer-state ingestion", () => { writeFileSync(join(taskDir, ".reviewer-state.json"), "not json at all"); const result = readReviewerTelemetrySnapshot(makeConfig(root), statusPath); expect(result).toBe(null); - } finally { rmSync(root, { recursive: true, force: true }); } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); }); diff --git a/extensions/tests/reviewer-quality-checks.test.ts b/extensions/tests/reviewer-quality-checks.test.ts index 5d88fefc..33ce031b 100644 --- a/extensions/tests/reviewer-quality-checks.test.ts +++ b/extensions/tests/reviewer-quality-checks.test.ts @@ -121,7 +121,9 @@ describe("TP-188 sub-fix A: reviewer prompt has Quality-check verification secti const sectionStart = reviewerPromptSrc.indexOf("## Quality-check verification"); const sectionEnd = reviewerPromptSrc.indexOf("## Verdict Criteria", sectionStart); const section = reviewerPromptSrc.slice(sectionStart, sectionEnd); - expect(section.toLowerCase()).toMatch(/do not run[^\n]*test suite|not[^\n]*full[^\n]*test|fast static/); + expect(section.toLowerCase()).toMatch( + /do not run[^\n]*test suite|not[^\n]*full[^\n]*test|fast static/, + ); }); it("1.10: skip-silently rule — missing config + missing scripts must not trigger REVISE on its own", () => { diff --git a/extensions/tests/rpc-wrapper.test.ts b/extensions/tests/rpc-wrapper.test.ts index 042d2a35..e7670bfd 100644 --- a/extensions/tests/rpc-wrapper.test.ts +++ b/extensions/tests/rpc-wrapper.test.ts @@ -168,10 +168,7 @@ describe("redactEvent — sidecar event redaction", () => { const event = { type: "tool_execution_start", args: { - list: [ - { DB_SECRET: "dbpass" }, - "normal string", - ], + list: [{ DB_SECRET: "dbpass" }, "normal string"], }, }; const result = redactEvent(event); @@ -311,9 +308,7 @@ describe("redactSummary — exit summary redaction", () => { const { redactSummary } = wrapperModule; const summary = { error: "Bearer sk-abcdef1234567890abcd failed", - retries: [ - { attempt: 1, error: "Bearer sk-abcdef1234567890abcd", delayMs: 0, succeeded: false }, - ], + retries: [{ attempt: 1, error: "Bearer sk-abcdef1234567890abcd", delayMs: 0, succeeded: false }], }; const original = JSON.parse(JSON.stringify(summary)); redactSummary(summary); @@ -445,10 +440,14 @@ describe("parseArgs — CLI argument parsing", () => { it("parses all required arguments", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", ]); expect(result.sidecarPath).toBe("/tmp/sidecar.jsonl"); expect(result.exitSummaryPath).toBe("/tmp/summary.json"); @@ -458,14 +457,22 @@ describe("parseArgs — CLI argument parsing", () => { it("parses optional arguments", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", - "--model", "anthropic/claude-sonnet-4-20250514", - "--system-prompt-file", "/tmp/sys.md", - "--tools", "bash,read,write", - "--extensions", "ext1.ts,ext2.ts", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", + "--model", + "anthropic/claude-sonnet-4-20250514", + "--system-prompt-file", + "/tmp/sys.md", + "--tools", + "bash,read,write", + "--extensions", + "ext1.ts,ext2.ts", ]); expect(result.model).toBe("anthropic/claude-sonnet-4-20250514"); expect(result.systemPromptFile).toBe("/tmp/sys.md"); @@ -476,11 +483,17 @@ describe("parseArgs — CLI argument parsing", () => { it("handles -- passthrough args", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", - "--", "--verbose", "--debug", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", + "--", + "--verbose", + "--debug", ]); expect(result.passthrough).toEqual(["--verbose", "--debug"]); }); @@ -500,10 +513,14 @@ describe("parseArgs — CLI argument parsing", () => { it("collects unknown args as passthrough", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", "--unknown-flag", ]); expect(result.passthrough).toContain("--unknown-flag"); @@ -623,13 +640,7 @@ describe("redactValue — value redaction details", () => { it("handles arrays of mixed types", () => { const { redactValue } = wrapperModule; - const arr = [ - "normal", - { APP_SECRET: "s3cr3t" }, - 42, - null, - ["nested", { AUTH_KEY: "key123" }], - ]; + const arr = ["normal", { APP_SECRET: "s3cr3t" }, 42, null, ["nested", { AUTH_KEY: "key123" }]]; const result = redactValue(arr); expect(result[0]).toBe("normal"); expect(result[1].APP_SECRET).toBe("[REDACTED]"); @@ -718,7 +729,11 @@ describe("applyEvent — session state accumulation", () => { const { createSessionState, applyEvent } = wrapperModule; const state = createSessionState(); - applyEvent(state, { type: "tool_execution_start", toolName: "bash", args: { command: "echo hello" } }); + applyEvent(state, { + type: "tool_execution_start", + toolName: "bash", + args: { command: "echo hello" }, + }); expect(state.currentTool).toBe("bash: echo hello"); expect(state.lastToolCall).toBe("bash: echo hello"); @@ -759,7 +774,12 @@ describe("applyEvent — session state accumulation", () => { const { createSessionState, applyEvent } = wrapperModule; const state = createSessionState(); - applyEvent(state, { type: "auto_retry_start", attempt: 1, errorMessage: "rate_limit", delayMs: 1000 }); + applyEvent(state, { + type: "auto_retry_start", + attempt: 1, + errorMessage: "rate_limit", + delayMs: 1000, + }); expect(state.retries).toHaveLength(1); expect(state.retries[0]).toEqual({ attempt: 1, @@ -853,7 +873,11 @@ describe("buildExitSummary — exit summary construction", () => { type: "message_end", message: { usage: { input: 500, output: 200, cacheRead: 50, cacheWrite: 10, cost: 0.05 } }, }); - applyEvent(state, { type: "tool_execution_start", toolName: "bash", args: { command: "echo test" } }); + applyEvent(state, { + type: "tool_execution_start", + toolName: "bash", + args: { command: "echo test" }, + }); applyEvent(state, { type: "auto_compaction_start" }); const summary = buildExitSummary(state, 0, null, null, startTime); @@ -989,10 +1013,20 @@ describe("buildExitSummary — exit summary construction", () => { type: "message_end", message: { usage: { input: 100, output: 50, cost: 0.01 } }, }); - applyEvent(state, { type: "tool_execution_start", toolName: "bash", args: { command: "make build" } }); + applyEvent(state, { + type: "tool_execution_start", + toolName: "bash", + args: { command: "make build" }, + }); // No agent_end, no tool_execution_end — process crashed - const summary = buildExitSummary(state, 137, "SIGKILL", "pi process exited with code 137 (signal: SIGKILL)", startTime); + const summary = buildExitSummary( + state, + 137, + "SIGKILL", + "pi process exited with code 137 (signal: SIGKILL)", + startTime, + ); expect(summary.exitCode).toBe(137); expect(summary.exitSignal).toBe("SIGKILL"); @@ -1113,7 +1147,9 @@ describe("integration — mock pi process end-to-end", () => { // Create a mock pi script that reads the prompt command from stdin // and emits a scripted sequence of RPC events, then exits cleanly. const mockPiScript = join(tmpDir, "mock-pi.mjs"); - writeFileSync(mockPiScript, ` + writeFileSync( + mockPiScript, + ` import process from 'process'; // Read all stdin, then emit events once we see a prompt command. @@ -1156,7 +1192,8 @@ process.stdin.on('data', (chunk) => { process.stdin.on('end', () => { process.exit(0); }); -`); +`, + ); // Run rpc-wrapper.mjs, using node to execute the mock pi script // We override the pi command by passing -- to use our mock instead @@ -1169,7 +1206,10 @@ process.stdin.on('end', () => { const isWindows = process.platform === "win32"; if (isWindows) { // Create pi.cmd that ignores all pi args and runs our mock script - writeFileSync(join(shimDir, "pi.cmd"), `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`); + writeFileSync( + join(shimDir, "pi.cmd"), + `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`, + ); } else { writeFileSync(join(shimDir, "pi"), `#!/bin/sh\nexec node "${mockPiScript}" "$@"\n`); const { chmodSync } = await import("fs"); @@ -1185,15 +1225,22 @@ process.stdin.on('end', () => { }; try { - const { stdout, stderr } = await execFileAsync("node", [ - wrapperAbsPath, - "--sidecar-path", sidecarPath, - "--exit-summary-path", summaryPath, - "--prompt-file", promptFile, - ], { - env, - timeout: 30000, - }); + const { stdout, stderr } = await execFileAsync( + "node", + [ + wrapperAbsPath, + "--sidecar-path", + sidecarPath, + "--exit-summary-path", + summaryPath, + "--prompt-file", + promptFile, + ], + { + env, + timeout: 30000, + }, + ); // Verify sidecar file exists and contains expected events expect(existsSync(sidecarPath)).toBe(true); @@ -1244,7 +1291,9 @@ process.stdin.on('end', () => { expect(summary.error).toBe(null); } finally { // Cleanup - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} } }, 30000); @@ -1265,7 +1314,9 @@ process.stdin.on('end', () => { // Mock pi that emits one event, then crashes const mockPiScript = join(tmpDir, "mock-pi-crash.mjs"); - writeFileSync(mockPiScript, ` + writeFileSync( + mockPiScript, + ` import process from 'process'; let responded = false; @@ -1286,14 +1337,18 @@ process.stdin.on('data', (chunk) => { // Crash without agent_end setTimeout(() => process.exit(1), 100); }); -`); +`, + ); const shimDir = join(tmpDir, "bin"); mkdirSync(shimDir, { recursive: true }); const isWindows = process.platform === "win32"; if (isWindows) { - writeFileSync(join(shimDir, "pi.cmd"), `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`); + writeFileSync( + join(shimDir, "pi.cmd"), + `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`, + ); } else { writeFileSync(join(shimDir, "pi"), `#!/bin/sh\nexec node "${mockPiScript}" "$@"\n`); const { chmodSync } = await import("fs"); @@ -1309,12 +1364,19 @@ process.stdin.on('data', (chunk) => { try { // The wrapper should exit with the pi process exit code (1) - await execFileAsync("node", [ - wrapperAbsPath, - "--sidecar-path", sidecarPath, - "--exit-summary-path", summaryPath, - "--prompt-file", promptFile, - ], { env, timeout: 30000 }); + await execFileAsync( + "node", + [ + wrapperAbsPath, + "--sidecar-path", + sidecarPath, + "--exit-summary-path", + summaryPath, + "--prompt-file", + promptFile, + ], + { env, timeout: 30000 }, + ); // If it doesn't throw, that's also fine — check summary } catch (err: any) { // Expected: non-zero exit @@ -1338,7 +1400,9 @@ process.stdin.on('data', (chunk) => { expect(sidecarLines.length).toBeGreaterThanOrEqual(3); // Cleanup - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }, 30000); it("spawn failure produces valid summary via extracted buildExitSummary", () => { @@ -1370,11 +1434,16 @@ describe("parseArgs — mailbox-dir", () => { it("parses --mailbox-dir correctly", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", - "--mailbox-dir", "/tmp/.pi/mailbox/batch-1/session-1", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", + "--mailbox-dir", + "/tmp/.pi/mailbox/batch-1/session-1", ]); expect(result.mailboxDir).toBe("/tmp/.pi/mailbox/batch-1/session-1"); }); @@ -1382,10 +1451,14 @@ describe("parseArgs — mailbox-dir", () => { it("mailboxDir defaults to null when not provided", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", ]); expect(result.mailboxDir).toBe(null); }); @@ -1418,23 +1491,57 @@ describe("isValidMailboxMessageShape — rpc-wrapper validation", () => { it("rejects missing required fields", () => { const { isValidMailboxMessageShape } = wrapperModule; // Missing id - expect(isValidMailboxMessageShape({ batchId: "b", from: "f", to: "t", timestamp: 1, type: "steer", content: "c" })).toBe(false); + expect( + isValidMailboxMessageShape({ + batchId: "b", + from: "f", + to: "t", + timestamp: 1, + type: "steer", + content: "c", + }), + ).toBe(false); // Missing content - expect(isValidMailboxMessageShape({ id: "i", batchId: "b", from: "f", to: "t", timestamp: 1, type: "steer" })).toBe(false); + expect( + isValidMailboxMessageShape({ + id: "i", + batchId: "b", + from: "f", + to: "t", + timestamp: 1, + type: "steer", + }), + ).toBe(false); }); it("rejects invalid message type", () => { const { isValidMailboxMessageShape } = wrapperModule; - expect(isValidMailboxMessageShape({ - id: "1000-aaa00", batchId: "b", from: "f", to: "t", timestamp: 1, type: "bogus", content: "c", - })).toBe(false); + expect( + isValidMailboxMessageShape({ + id: "1000-aaa00", + batchId: "b", + from: "f", + to: "t", + timestamp: 1, + type: "bogus", + content: "c", + }), + ).toBe(false); }); it("rejects non-finite timestamp", () => { const { isValidMailboxMessageShape } = wrapperModule; - expect(isValidMailboxMessageShape({ - id: "1000-aaa00", batchId: "b", from: "f", to: "t", timestamp: Infinity, type: "steer", content: "c", - })).toBe(false); + expect( + isValidMailboxMessageShape({ + id: "1000-aaa00", + batchId: "b", + from: "f", + to: "t", + timestamp: Infinity, + type: "steer", + content: "c", + }), + ).toBe(false); }); }); @@ -1483,7 +1590,9 @@ describe("checkMailboxAndSteer — mailbox delivery", () => { const written: string[] = []; const mockProc = { stdin: { - write: (data: string) => { written.push(data); }, + write: (data: string) => { + written.push(data); + }, }, }; @@ -1501,7 +1610,9 @@ describe("checkMailboxAndSteer — mailbox delivery", () => { expect(fs.existsSync(join(inboxDir, "1000-aaa00.msg.json"))).toBe(false); // Cleanup - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); it("silent no-op when mailboxDir inbox doesn't exist", async () => { @@ -1514,7 +1625,9 @@ describe("checkMailboxAndSteer — mailbox delivery", () => { const written: string[] = []; const mockProc = { stdin: { - write: (data: string) => { written.push(data); }, + write: (data: string) => { + written.push(data); + }, }, }; @@ -1562,7 +1675,9 @@ describe("mailbox-dir runtime behavior", () => { ); const mockPiScript = join(tmpDir, "mock-pi-mailbox.mjs"); - writeFileSync(mockPiScript, ` + writeFileSync( + mockPiScript, + ` import process from 'process'; import { writeFileSync } from 'fs'; @@ -1596,13 +1711,17 @@ process.stdin.on('end', () => { } process.exit(0); }); -`); +`, + ); const shimDir = join(tmpDir, "bin"); mkdirSync(shimDir, { recursive: true }); const isWindows = process.platform === "win32"; if (isWindows) { - writeFileSync(join(shimDir, "pi.cmd"), `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`); + writeFileSync( + join(shimDir, "pi.cmd"), + `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`, + ); } else { writeFileSync(join(shimDir, "pi"), `#!/bin/sh\nexec node "${mockPiScript}" "$@"\n`); const { chmodSync } = await import("fs"); @@ -1617,19 +1736,31 @@ process.stdin.on('end', () => { }; try { - await execFileAsync("node", [ - wrapperPath, - "--sidecar-path", sidecarPath, - "--exit-summary-path", summaryPath, - "--prompt-file", promptFile, - "--mailbox-dir", mailboxDir, - ], { env, timeout: 30000 }); + await execFileAsync( + "node", + [ + wrapperPath, + "--sidecar-path", + sidecarPath, + "--exit-summary-path", + summaryPath, + "--prompt-file", + promptFile, + "--mailbox-dir", + mailboxDir, + ], + { env, timeout: 30000 }, + ); const cmds = JSON.parse(readFileSync(cmdLogPath, "utf-8")); expect(cmds.some((c: any) => c.type === "set_steering_mode" && c.mode === "all")).toBe(true); - expect(cmds.some((c: any) => c.type === "steer" && c.message === "Mailbox delivery test")).toBe(true); + expect(cmds.some((c: any) => c.type === "steer" && c.message === "Mailbox delivery test")).toBe( + true, + ); } finally { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} } }, 30000); @@ -1650,7 +1781,9 @@ process.stdin.on('end', () => { writeFileSync(promptFile, "runtime no mailbox test"); const mockPiScript = join(tmpDir, "mock-pi-no-mailbox.mjs"); - writeFileSync(mockPiScript, ` + writeFileSync( + mockPiScript, + ` import process from 'process'; import { writeFileSync } from 'fs'; @@ -1684,13 +1817,17 @@ process.stdin.on('end', () => { } process.exit(0); }); -`); +`, + ); const shimDir = join(tmpDir, "bin"); mkdirSync(shimDir, { recursive: true }); const isWindows = process.platform === "win32"; if (isWindows) { - writeFileSync(join(shimDir, "pi.cmd"), `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`); + writeFileSync( + join(shimDir, "pi.cmd"), + `@echo off\nnode "${mockPiScript.replace(/\\/g, "\\\\")}" %*\n`, + ); } else { writeFileSync(join(shimDir, "pi"), `#!/bin/sh\nexec node "${mockPiScript}" "$@"\n`); const { chmodSync } = await import("fs"); @@ -1705,18 +1842,27 @@ process.stdin.on('end', () => { }; try { - await execFileAsync("node", [ - wrapperPath, - "--sidecar-path", sidecarPath, - "--exit-summary-path", summaryPath, - "--prompt-file", promptFile, - ], { env, timeout: 30000 }); + await execFileAsync( + "node", + [ + wrapperPath, + "--sidecar-path", + sidecarPath, + "--exit-summary-path", + summaryPath, + "--prompt-file", + promptFile, + ], + { env, timeout: 30000 }, + ); const cmds = JSON.parse(readFileSync(cmdLogPath, "utf-8")); expect(cmds.some((c: any) => c.type === "set_steering_mode")).toBe(false); expect(cmds.some((c: any) => c.type === "steer")).toBe(false); } finally { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} } }, 30000); }); @@ -1727,11 +1873,16 @@ describe("parseArgs — steering-pending-path (TP-090)", () => { it("parses --steering-pending-path correctly", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", - "--steering-pending-path", "/tmp/task/.steering-pending", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", + "--steering-pending-path", + "/tmp/task/.steering-pending", ]); expect(result.steeringPendingPath).toBe("/tmp/task/.steering-pending"); }); @@ -1739,10 +1890,14 @@ describe("parseArgs — steering-pending-path (TP-090)", () => { it("steeringPendingPath defaults to null when not provided", () => { const { parseArgs } = wrapperModule; const result = parseArgs([ - "node", "rpc-wrapper.mjs", - "--sidecar-path", "/tmp/sidecar.jsonl", - "--exit-summary-path", "/tmp/summary.json", - "--prompt-file", "/tmp/prompt.md", + "node", + "rpc-wrapper.mjs", + "--sidecar-path", + "/tmp/sidecar.jsonl", + "--exit-summary-path", + "/tmp/summary.json", + "--prompt-file", + "/tmp/prompt.md", ]); expect(result.steeringPendingPath).toBe(null); }); @@ -1779,7 +1934,9 @@ describe("checkMailboxAndSteer — .steering-pending JSONL (TP-090)", () => { const written: string[] = []; const mockProc = { stdin: { - write: (data: string) => { written.push(data); }, + write: (data: string) => { + written.push(data); + }, destroyed: false, }, }; @@ -1796,7 +1953,9 @@ describe("checkMailboxAndSteer — .steering-pending JSONL (TP-090)", () => { expect(entry.content).toBe("Focus on the API."); expect(entry.id).toBe("1000-aaa00"); - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); it("does NOT write .steering-pending when steeringPendingPath is null", async () => { @@ -1825,7 +1984,9 @@ describe("checkMailboxAndSteer — .steering-pending JSONL (TP-090)", () => { const written: string[] = []; const mockProc = { stdin: { - write: (data: string) => { written.push(data); }, + write: (data: string) => { + written.push(data); + }, destroyed: false, }, }; @@ -1840,7 +2001,9 @@ describe("checkMailboxAndSteer — .steering-pending JSONL (TP-090)", () => { const hasPending = files.some((f: string) => f.includes(".steering-pending")); expect(hasPending).toBe(false); - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); it("appends multiple JSONL entries for multiple messages", async () => { @@ -1881,7 +2044,9 @@ describe("checkMailboxAndSteer — .steering-pending JSONL (TP-090)", () => { const written: string[] = []; const mockProc = { stdin: { - write: (data: string) => { written.push(data); }, + write: (data: string) => { + written.push(data); + }, destroyed: false, }, }; @@ -1897,6 +2062,8 @@ describe("checkMailboxAndSteer — .steering-pending JSONL (TP-090)", () => { expect(e1.content).toBe("First message."); expect(e2.content).toBe("Second message."); - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); }); diff --git a/extensions/tests/runtime-model-fallback.test.ts b/extensions/tests/runtime-model-fallback.test.ts index 3ed373ff..e790f48b 100644 --- a/extensions/tests/runtime-model-fallback.test.ts +++ b/extensions/tests/runtime-model-fallback.test.ts @@ -33,17 +33,11 @@ import { tier0ScopeKey, } from "../taskplane/types.ts"; -import type { - Tier0RecoveryPattern, -} from "../taskplane/types.ts"; +import type { Tier0RecoveryPattern } from "../taskplane/types.ts"; -import { - DEFAULT_TASK_RUNNER_SECTION, -} from "../taskplane/config-schema.ts"; +import { DEFAULT_TASK_RUNNER_SECTION } from "../taskplane/config-schema.ts"; -import type { - ModelFallbackMode, -} from "../taskplane/config-schema.ts"; +import type { ModelFallbackMode } from "../taskplane/config-schema.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -131,7 +125,7 @@ describe("model_access_error classification", () => { "connection timeout", "network error", "overloaded", - "service unavailable", // generic, not model-specific + "service unavailable", // generic, not model-specific "unknown error occurred", "context window exceeded", "max tokens exceeded", @@ -139,7 +133,7 @@ describe("model_access_error classification", () => { ]; for (const pattern of negativePatterns) { - it(`does NOT match: "${pattern || '(empty string)'}"`, () => { + it(`does NOT match: "${pattern || "(empty string)"}"`, () => { expect(isModelAccessError(pattern)).toBe(false); }); } @@ -149,9 +143,7 @@ describe("model_access_error classification", () => { it("classifies model_access_error when last retry error matches pattern", () => { const input = makeInput({ exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "rate_limit_exceeded", delayMs: 5000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "rate_limit_exceeded", delayMs: 5000, succeeded: false }], }), }); expect(classifyExit(input)).toBe("model_access_error"); @@ -160,9 +152,7 @@ describe("model_access_error classification", () => { it("classifies model_access_error for 401 error in retries", () => { const input = makeInput({ exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "HTTP 401 Unauthorized", delayMs: 1000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "HTTP 401 Unauthorized", delayMs: 1000, succeeded: false }], }), }); expect(classifyExit(input)).toBe("model_access_error"); @@ -182,9 +172,7 @@ describe("model_access_error classification", () => { it("classifies api_error for non-model retry errors", () => { const input = makeInput({ exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "internal_server_error", delayMs: 1000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "internal_server_error", delayMs: 1000, succeeded: false }], }), }); expect(classifyExit(input)).toBe("api_error"); @@ -217,9 +205,7 @@ describe("model_access_error classification", () => { const input = makeInput({ doneFileFound: true, exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "rate_limit_exceeded", delayMs: 1000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "rate_limit_exceeded", delayMs: 1000, succeeded: false }], }), }); expect(classifyExit(input)).toBe("completed"); @@ -229,9 +215,7 @@ describe("model_access_error classification", () => { const input = makeInput({ exitSummary: makeSummary({ compactions: 2, - retries: [ - { attempt: 1, error: "model not found", delayMs: 1000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "model not found", delayMs: 1000, succeeded: false }], }), contextPct: 95, }); @@ -241,9 +225,7 @@ describe("model_access_error classification", () => { it("model_access_error beats wall_clock_timeout", () => { const input = makeInput({ exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "authentication failed", delayMs: 1000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "authentication failed", delayMs: 1000, succeeded: false }], }), timerKilled: true, }); @@ -404,10 +386,11 @@ describe("model fallback retry logic", () => { }); it("executeLaneV2 batchId resolution preserves config-first fallback chain", () => { - expect(executionSource).toContain("config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now())"); + expect(executionSource).toContain( + "config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now())", + ); }); }); - }); // ── 4. Edge Cases ──────────────────────────────────────────────────── @@ -425,9 +408,7 @@ describe("model fallback edge cases", () => { it("api_error (generic) is not model_access_error", () => { const input = makeInput({ exitSummary: makeSummary({ - retries: [ - { attempt: 1, error: "overloaded", delayMs: 1000, succeeded: false }, - ], + retries: [{ attempt: 1, error: "overloaded", delayMs: 1000, succeeded: false }], }), }); expect(classifyExit(input)).toBe("api_error"); diff --git a/extensions/tests/runtime-v2-contracts.test.ts b/extensions/tests/runtime-v2-contracts.test.ts index 21e04831..ab2b1e91 100644 --- a/extensions/tests/runtime-v2-contracts.test.ts +++ b/extensions/tests/runtime-v2-contracts.test.ts @@ -171,7 +171,15 @@ describe("2.x: validateAgentManifest", () => { }); it("2.9: accepts all valid statuses", () => { - for (const status of ["spawning", "running", "wrapping_up", "exited", "crashed", "timed_out", "killed"] as RuntimeAgentStatus[]) { + for (const status of [ + "spawning", + "running", + "wrapping_up", + "exited", + "crashed", + "timed_out", + "killed", + ] as RuntimeAgentStatus[]) { const m = validManifest(); m.status = status; expect(validateAgentManifest(m)).toEqual([]); diff --git a/extensions/tests/schema-v4-migration.test.ts b/extensions/tests/schema-v4-migration.test.ts index 9c52d4f2..d79282a6 100644 --- a/extensions/tests/schema-v4-migration.test.ts +++ b/extensions/tests/schema-v4-migration.test.ts @@ -52,25 +52,29 @@ function makeValidV4(): Record { currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1-20260328T010000", - taskIds: ["TP-001"], - }], - tasks: [{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-001", - startedAt: 1741478400000, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-20260328T010000", + taskIds: ["TP-001"], + }, + ], + tasks: [ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-001", + startedAt: 1741478400000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -119,7 +123,6 @@ function makeSegmentRecord(overrides?: Partial): Persist // ═════════════════════════════════════════════════════════════════════ describe("Schema v4 Migration (TP-081)", () => { - describe("v3 → v4 migration", () => { it("migrates v3 state to v4 with empty segments", () => { const v3 = makeValidV3(); @@ -562,25 +565,29 @@ describe("Schema v4 Migration (TP-081)", () => { currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - taskIds: ["TP-001"], - }], - tasks: [{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-001", - startedAt: null, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + taskIds: ["TP-001"], + }, + ], + tasks: [ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-001", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -640,12 +647,15 @@ describe("Schema v4 Migration (TP-081)", () => { laneSessionId: lr.laneSessionId, worktreePath: lr.worktreePath, branch: lr.branch, - tasks: lr.taskIds.map((taskId, i) => ({ - taskId, - order: i, - task: { ...dummyParsedTask, taskId }, - estimatedMinutes: 10, - } as AllocatedTask)), + tasks: lr.taskIds.map( + (taskId, i) => + ({ + taskId, + order: i, + task: { ...dummyParsedTask, taskId }, + estimatedMinutes: 10, + }) as AllocatedTask, + ), strategy: "round-robin" as const, estimatedLoad: 1, estimatedMinutes: 10, @@ -666,7 +676,7 @@ describe("Schema v4 Migration (TP-081)", () => { batchId: persisted.batchId, baseBranch: persisted.baseBranch, orchBranch: persisted.orchBranch ?? "", - mode: persisted.mode as any ?? "repo", + mode: (persisted.mode as any) ?? "repo", pauseSignal: { paused: false }, waveResults: [], currentWaveIndex: persisted.currentWaveIndex, diff --git a/extensions/tests/segment-boundary-done-guard.test.ts b/extensions/tests/segment-boundary-done-guard.test.ts index 9766bcc3..99213b3c 100644 --- a/extensions/tests/segment-boundary-done-guard.test.ts +++ b/extensions/tests/segment-boundary-done-guard.test.ts @@ -14,7 +14,14 @@ * to derive the canonical worker agent ID. */ -import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, it, beforeEach, afterEach } from "node:test"; @@ -35,7 +42,9 @@ function rmrf(dir: string): void { try { const { rmSync } = require("fs"); rmSync(dir, { recursive: true, force: true }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } // ── Bug #1: Premature .DONE guard with pending expansion requests ─── @@ -56,13 +65,16 @@ describe("TP-165 regression: .DONE suppressed when expansion requests pending", it("detects pending expansion request files in outbox", () => { const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox"); mkdirSync(outboxDir, { recursive: true }); - writeFileSync(join(outboxDir, "segment-expansion-exp-001.json"), JSON.stringify({ - requestId: "exp-001", - taskId: "TP-100", - fromSegmentId: "TP-100::default", - requestedRepoIds: ["api"], - placement: "after-current", - })); + writeFileSync( + join(outboxDir, "segment-expansion-exp-001.json"), + JSON.stringify({ + requestId: "exp-001", + taskId: "TP-100", + fromSegmentId: "TP-100::default", + requestedRepoIds: ["api"], + placement: "after-current", + }), + ); const result = hasPendingExpansionRequestFiles(stateRoot, batchId, agentId); expect(result).toBe(true); @@ -95,9 +107,12 @@ describe("TP-165 regression: .DONE suppressed when expansion requests pending", const outboxDir = join(stateRoot, ".pi", "mailbox", batchId, agentId, "outbox"); mkdirSync(outboxDir, { recursive: true }); writeFileSync(join(outboxDir, "segment-expansion-exp-001.json.processed"), "{}"); - writeFileSync(join(outboxDir, "segment-expansion-exp-002.json"), JSON.stringify({ - requestId: "exp-002", - })); + writeFileSync( + join(outboxDir, "segment-expansion-exp-002.json"), + JSON.stringify({ + requestId: "exp-002", + }), + ); const result = hasPendingExpansionRequestFiles(stateRoot, batchId, agentId); expect(result).toBe(true); @@ -130,7 +145,9 @@ describe("TP-165 regression: resolveTaskWorkerAgentId workspace-mode fix", () => }); it("prefers outcome.sessionName when available (no fallback needed)", () => { - const outcomes: any[] = [{ taskId: "TP-100", sessionName: "orch-henry-lane-1-worker", status: "succeeded" }]; + const outcomes: any[] = [ + { taskId: "TP-100", sessionName: "orch-henry-lane-1-worker", status: "succeeded" }, + ]; const result = resolveTaskWorkerAgentId("TP-100", outcomes, new Map()); expect(result).toBe("orch-henry-lane-1-worker"); }); diff --git a/extensions/tests/segment-expansion-engine.test.ts b/extensions/tests/segment-expansion-engine.test.ts index 4c2732fd..86dd7b72 100644 --- a/extensions/tests/segment-expansion-engine.test.ts +++ b/extensions/tests/segment-expansion-engine.test.ts @@ -8,14 +8,17 @@ import { processSegmentExpansionRequestAtBoundary, resolveTaskWorkerAgentId, } from "../taskplane/engine.ts"; -import { - buildResumeRuntimeWavePlan, - reconstructSegmentFrontier, -} from "../taskplane/resume.ts"; +import { buildResumeRuntimeWavePlan, reconstructSegmentFrontier } from "../taskplane/resume.ts"; import { defaultBatchDiagnostics, defaultResilienceState } from "../taskplane/types.ts"; -import type { PersistedBatchState, PersistedSegmentRecord, SegmentExpansionRequest } from "../taskplane/types.ts"; - -function makeExpansionRequest(overrides: Partial = {}): SegmentExpansionRequest { +import type { + PersistedBatchState, + PersistedSegmentRecord, + SegmentExpansionRequest, +} from "../taskplane/types.ts"; + +function makeExpansionRequest( + overrides: Partial = {}, +): SegmentExpansionRequest { return { requestId: "exp-001", taskId: "TP-900", @@ -44,19 +47,21 @@ function makeState(overrides: Partial = {}): PersistedBatch totalWaves: 1, wavePlan: [["TP-900"]], lanes: [], - tasks: [{ - taskId: "TP-900", - laneNumber: 1, - sessionName: "", - status: "pending", - taskFolder: "/tmp/tasks/TP-900", - startedAt: null, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-900::api"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-900", + laneNumber: 1, + sessionName: "", + status: "pending", + taskFolder: "/tmp/tasks/TP-900", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-900::api"], + activeSegmentId: null, + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -102,18 +107,30 @@ describe("TP-143 segment expansion engine coverage", () => { { segmentId: "TP-901::c", taskId: "TP-901", repoId: "c", order: 2 }, ], nextSegmentIndex: 2, - statusBySegmentId: new Map([["TP-901::a", "succeeded"], ["TP-901::b", "succeeded"], ["TP-901::c", "pending"]]), - dependsOnBySegmentId: new Map([["TP-901::a", []], ["TP-901::b", ["TP-901::a"]], ["TP-901::c", ["TP-901::b"]]]), + statusBySegmentId: new Map([ + ["TP-901::a", "succeeded"], + ["TP-901::b", "succeeded"], + ["TP-901::c", "pending"], + ]), + dependsOnBySegmentId: new Map([ + ["TP-901::a", []], + ["TP-901::b", ["TP-901::a"]], + ["TP-901::c", ["TP-901::b"]], + ]), terminalStatus: "pending", }; - const linear = applySegmentExpansionMutation(linearState, makeExpansionRequest({ - requestId: "exp-linear", - taskId: "TP-901", - fromSegmentId: "TP-901::b", - requestedRepoIds: ["x"], - placement: "after-current", - edges: [], - }), "TP-901::b"); + const linear = applySegmentExpansionMutation( + linearState, + makeExpansionRequest({ + requestId: "exp-linear", + taskId: "TP-901", + fromSegmentId: "TP-901::b", + requestedRepoIds: ["x"], + placement: "after-current", + edges: [], + }), + "TP-901::b", + ); expect(linear.insertedSegmentIds).toEqual(["TP-901::x"]); expect(linearState.dependsOnBySegmentId.get("TP-901::c")).toEqual(["TP-901::x"]); @@ -125,18 +142,30 @@ describe("TP-143 segment expansion engine coverage", () => { { segmentId: "TP-902::c", taskId: "TP-902", repoId: "c", order: 2 }, ], nextSegmentIndex: 1, - statusBySegmentId: new Map([["TP-902::a", "succeeded"], ["TP-902::b", "pending"], ["TP-902::c", "pending"]]), - dependsOnBySegmentId: new Map([["TP-902::a", []], ["TP-902::b", ["TP-902::a"]], ["TP-902::c", ["TP-902::a"]]]), + statusBySegmentId: new Map([ + ["TP-902::a", "succeeded"], + ["TP-902::b", "pending"], + ["TP-902::c", "pending"], + ]), + dependsOnBySegmentId: new Map([ + ["TP-902::a", []], + ["TP-902::b", ["TP-902::a"]], + ["TP-902::c", ["TP-902::a"]], + ]), terminalStatus: "pending", }; - const fanout = applySegmentExpansionMutation(fanoutState, makeExpansionRequest({ - requestId: "exp-fanout", - taskId: "TP-902", - fromSegmentId: "TP-902::a", - requestedRepoIds: ["x"], - placement: "after-current", - edges: [], - }), "TP-902::a"); + const fanout = applySegmentExpansionMutation( + fanoutState, + makeExpansionRequest({ + requestId: "exp-fanout", + taskId: "TP-902", + fromSegmentId: "TP-902::a", + requestedRepoIds: ["x"], + placement: "after-current", + edges: [], + }), + "TP-902::a", + ); expect(fanout.insertedSegmentIds).toEqual(["TP-902::x"]); expect(fanoutState.dependsOnBySegmentId.get("TP-902::b")).toEqual(["TP-902::x"]); expect(fanoutState.dependsOnBySegmentId.get("TP-902::c")).toEqual(["TP-902::x"]); @@ -149,20 +178,35 @@ describe("TP-143 segment expansion engine coverage", () => { { segmentId: "TP-903::c", taskId: "TP-903", repoId: "c", order: 2 }, ], nextSegmentIndex: 1, - statusBySegmentId: new Map([["TP-903::a", "succeeded"], ["TP-903::b", "pending"], ["TP-903::c", "pending"]]), - dependsOnBySegmentId: new Map([["TP-903::a", []], ["TP-903::b", ["TP-903::a"]], ["TP-903::c", ["TP-903::a"]]]), + statusBySegmentId: new Map([ + ["TP-903::a", "succeeded"], + ["TP-903::b", "pending"], + ["TP-903::c", "pending"], + ]), + dependsOnBySegmentId: new Map([ + ["TP-903::a", []], + ["TP-903::b", ["TP-903::a"]], + ["TP-903::c", ["TP-903::a"]], + ]), terminalStatus: "pending", }; - const end = applySegmentExpansionMutation(endState, makeExpansionRequest({ - requestId: "exp-end", - taskId: "TP-903", - fromSegmentId: "TP-903::c", - requestedRepoIds: ["x", "y"], - placement: "end", - edges: [{ from: "x", to: "y" }], - }), "TP-903::c"); + const end = applySegmentExpansionMutation( + endState, + makeExpansionRequest({ + requestId: "exp-end", + taskId: "TP-903", + fromSegmentId: "TP-903::c", + requestedRepoIds: ["x", "y"], + placement: "end", + edges: [{ from: "x", to: "y" }], + }), + "TP-903::c", + ); expect(end.insertedSegmentIds).toEqual(["TP-903::x", "TP-903::y"]); - expect(endState.dependsOnBySegmentId.get("TP-903::x")?.sort()).toEqual(["TP-903::b", "TP-903::c"]); + expect(endState.dependsOnBySegmentId.get("TP-903::x")?.sort()).toEqual([ + "TP-903::b", + "TP-903::c", + ]); const repeatState: any = { taskId: "TP-904", @@ -171,17 +215,27 @@ describe("TP-143 segment expansion engine coverage", () => { { segmentId: "TP-904::api::3", taskId: "TP-904", repoId: "api", order: 1 }, ], nextSegmentIndex: 1, - statusBySegmentId: new Map([["TP-904::api", "succeeded"], ["TP-904::api::3", "pending"]]), - dependsOnBySegmentId: new Map([["TP-904::api", []], ["TP-904::api::3", ["TP-904::api"]]]), + statusBySegmentId: new Map([ + ["TP-904::api", "succeeded"], + ["TP-904::api::3", "pending"], + ]), + dependsOnBySegmentId: new Map([ + ["TP-904::api", []], + ["TP-904::api::3", ["TP-904::api"]], + ]), terminalStatus: "pending", }; - const repeat = applySegmentExpansionMutation(repeatState, makeExpansionRequest({ - requestId: "exp-repeat", - taskId: "TP-904", - fromSegmentId: "TP-904::api::3", - requestedRepoIds: ["api"], - placement: "end", - }), "TP-904::api::3"); + const repeat = applySegmentExpansionMutation( + repeatState, + makeExpansionRequest({ + requestId: "exp-repeat", + taskId: "TP-904", + fromSegmentId: "TP-904::api::3", + requestedRepoIds: ["api"], + placement: "end", + }), + "TP-904::api::3", + ); expect(repeat.insertedSegmentIds).toEqual(["TP-904::api::4"]); }); @@ -192,7 +246,14 @@ describe("TP-143 segment expansion engine coverage", () => { "TP-905", "TP-905::api", "agent-1", - { filePath: "/tmp/segment-expansion-exp-005.json", request: makeExpansionRequest({ taskId: "TP-905", fromSegmentId: "TP-905::api", requestedRepoIds: ["web"] }) }, + { + filePath: "/tmp/segment-expansion-exp-005.json", + request: makeExpansionRequest({ + taskId: "TP-905", + fromSegmentId: "TP-905::api", + requestedRepoIds: ["web"], + }), + }, baseState, { repos: new Map([["api", {}]]) } as any, new Set(), @@ -204,9 +265,25 @@ describe("TP-143 segment expansion engine coverage", () => { "TP-905", "TP-905::api", "agent-1", - { filePath: "/tmp/segment-expansion-exp-006.json", request: makeExpansionRequest({ taskId: "TP-905", fromSegmentId: "TP-905::api", requestedRepoIds: ["api", "web"], edges: [{ from: "api", to: "web" }, { from: "web", to: "api" }] }) }, + { + filePath: "/tmp/segment-expansion-exp-006.json", + request: makeExpansionRequest({ + taskId: "TP-905", + fromSegmentId: "TP-905::api", + requestedRepoIds: ["api", "web"], + edges: [ + { from: "api", to: "web" }, + { from: "web", to: "api" }, + ], + }), + }, baseState, - { repos: new Map([["api", {}], ["web", {}]]) } as any, + { + repos: new Map([ + ["api", {}], + ["web", {}], + ]), + } as any, new Set(), ); expect(cycle.ok).toBe(false); @@ -217,7 +294,14 @@ describe("TP-143 segment expansion engine coverage", () => { "TP-905", "TP-905::api", "agent-1", - { filePath: "/tmp/segment-expansion-exp-007.json", request: makeExpansionRequest({ requestId: "exp-dupe", taskId: "TP-905", fromSegmentId: "TP-905::api" }) }, + { + filePath: "/tmp/segment-expansion-exp-007.json", + request: makeExpansionRequest({ + requestId: "exp-dupe", + taskId: "TP-905", + fromSegmentId: "TP-905::api", + }), + }, baseState, { repos: new Map([["api", {}]]) } as any, knownRequestIds, @@ -228,7 +312,14 @@ describe("TP-143 segment expansion engine coverage", () => { "TP-905", "TP-905::api", "agent-1", - { filePath: "/tmp/segment-expansion-exp-007-dupe.json", request: makeExpansionRequest({ requestId: "exp-dupe", taskId: "TP-905", fromSegmentId: "TP-905::api" }) }, + { + filePath: "/tmp/segment-expansion-exp-007-dupe.json", + request: makeExpansionRequest({ + requestId: "exp-dupe", + taskId: "TP-905", + fromSegmentId: "TP-905::api", + }), + }, baseState, { repos: new Map([["api", {}]]) } as any, knownRequestIds, @@ -240,22 +331,35 @@ describe("TP-143 segment expansion engine coverage", () => { const state = makeState({ wavePlan: [["TP-906"]], totalWaves: 1, - tasks: [{ - taskId: "TP-906", - laneNumber: 1, - sessionName: "", - status: "pending", - taskFolder: "/tmp/tasks/TP-906", - startedAt: null, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-906::api", "TP-906::web"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-906", + laneNumber: 1, + sessionName: "", + status: "pending", + taskFolder: "/tmp/tasks/TP-906", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-906::api", "TP-906::web"], + activeSegmentId: null, + }, + ], segments: [ - makeSegment({ taskId: "TP-906", segmentId: "TP-906::api", status: "succeeded", endedAt: Date.now() - 200 }), - makeSegment({ taskId: "TP-906", segmentId: "TP-906::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-906::api"] }), + makeSegment({ + taskId: "TP-906", + segmentId: "TP-906::api", + status: "succeeded", + endedAt: Date.now() - 200, + }), + makeSegment({ + taskId: "TP-906", + segmentId: "TP-906::web", + repoId: "web", + status: "pending", + dependsOnSegmentIds: ["TP-906::api"], + }), ], }); @@ -268,19 +372,21 @@ describe("TP-143 segment expansion engine coverage", () => { const state = makeState({ wavePlan: [["TP-920"]], totalWaves: 1, - tasks: [{ - taskId: "TP-920", - laneNumber: 1, - sessionName: "", - status: "pending", - taskFolder: "/tmp/tasks/TP-920", - startedAt: Date.now() - 400, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-920::api-service", "TP-920::web-client"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-920", + laneNumber: 1, + sessionName: "", + status: "pending", + taskFolder: "/tmp/tasks/TP-920", + startedAt: Date.now() - 400, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-920::api-service", "TP-920::web-client"], + activeSegmentId: null, + }, + ], segments: [ makeSegment({ taskId: "TP-920", @@ -305,7 +411,9 @@ describe("TP-143 segment expansion engine coverage", () => { reconstructSegmentFrontier(state); expect(state.tasks[0].activeSegmentId).toBe("TP-920::web-client"); expect(buildResumeRuntimeWavePlan(state)).toEqual([["TP-920"], ["TP-920"]]); - const persistedExpanded = state.segments.find((segment) => segment.segmentId === "TP-920::web-client"); + const persistedExpanded = state.segments.find( + (segment) => segment.segmentId === "TP-920::web-client", + ); expect(persistedExpanded?.expandedFrom).toBe("TP-920::api-service"); expect(persistedExpanded?.expansionRequestId).toBe("exp-tp920"); }); @@ -314,19 +422,21 @@ describe("TP-143 segment expansion engine coverage", () => { const state = makeState({ wavePlan: [["TP-921"]], totalWaves: 1, - tasks: [{ - taskId: "TP-921", - laneNumber: 1, - sessionName: "", - status: "pending", - taskFolder: "/tmp/tasks/TP-921", - startedAt: Date.now() - 500, - endedAt: null, - doneFileFound: false, - exitReason: "", - segmentIds: ["TP-921::shared-libs", "TP-921::api-service", "TP-921::shared-libs::2"], - activeSegmentId: null, - }], + tasks: [ + { + taskId: "TP-921", + laneNumber: 1, + sessionName: "", + status: "pending", + taskFolder: "/tmp/tasks/TP-921", + startedAt: Date.now() - 500, + endedAt: null, + doneFileFound: false, + exitReason: "", + segmentIds: ["TP-921::shared-libs", "TP-921::api-service", "TP-921::shared-libs::2"], + activeSegmentId: null, + }, + ], segments: [ makeSegment({ taskId: "TP-921", @@ -363,9 +473,7 @@ describe("TP-143 segment expansion engine coverage", () => { it("resume-seeded processed request IDs block duplicate expansion processing", () => { const knownRequestIds = collectProcessedSegmentExpansionRequestIds({ resilience: { - repairHistory: [ - { id: "exp-resume-dup", strategy: "segment-expansion-request" }, - ] as any, + repairHistory: [{ id: "exp-resume-dup", strategy: "segment-expansion-request" }] as any, }, } as any); expect([...knownRequestIds]).toEqual(["exp-resume-dup"]); @@ -396,8 +504,17 @@ describe("TP-143 segment expansion engine coverage", () => { it("boundary handling keeps deterministic request ordering and failed-origin/malformed file lifecycle guards", () => { const src = readFileSync(new URL("../taskplane/engine.ts", import.meta.url), "utf-8"); - expect(src).toMatch(/orderedRequests = \[\.\.\.parsedRequests\.valid\]\.sort\(\(a, b\) => a\.request\.requestId\.localeCompare\(b\.request\.requestId\)\)/); - expect(src).toContain("markSegmentExpansionRequestFile(requestFile.filePath, \"discarded\")"); + // TP-193: Whitespace-normalize so the formatter's vertical re-wrapping + // of long chained-call expressions doesn't break the regex match. + const normSrc = src + .replace(/\s+/g, " ") + .replace(/([(\[{])\s+/g, "$1") + .replace(/\s+([)\]},])/g, "$1") + .replace(/,([)\]}])/g, "$1"); + expect(normSrc).toMatch( + /orderedRequests = \[\.\.\.parsedRequests\.valid\]\.sort\(\(a, b\) => a\.request\.requestId\.localeCompare\(b\.request\.requestId\)\)/, + ); + expect(src).toContain('markSegmentExpansionRequestFile(requestFile.filePath, "discarded")'); expect(src).toContain("segment expansion request malformed"); }); }); @@ -439,7 +556,13 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { }), }, segmentState, - { repos: new Map([["shared-libs", {}], ["api-service", {}], ["web-client", {}]]) } as any, + { + repos: new Map([ + ["shared-libs", {}], + ["api-service", {}], + ["web-client", {}], + ]), + } as any, new Set(), ); expect(result.ok).toBe(true); @@ -448,9 +571,7 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { it("accepts edge between two new repos (existing behavior preserved)", () => { const segmentState: any = { terminalStatus: "pending", - orderedSegments: [ - { segmentId: "TP-951::api", taskId: "TP-951", repoId: "api", order: 0 }, - ], + orderedSegments: [{ segmentId: "TP-951::api", taskId: "TP-951", repoId: "api", order: 0 }], statusBySegmentId: new Map([["TP-951::api", "running"]]), dependsOnBySegmentId: new Map([["TP-951::api", []]]), }; @@ -470,7 +591,13 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { }), }, segmentState, - { repos: new Map([["api", {}], ["web", {}], ["mobile", {}]]) } as any, + { + repos: new Map([ + ["api", {}], + ["web", {}], + ["mobile", {}], + ]), + } as any, new Set(), ); expect(result.ok).toBe(true); @@ -479,9 +606,7 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { it("still rejects edge to truly unknown repo", () => { const segmentState: any = { terminalStatus: "pending", - orderedSegments: [ - { segmentId: "TP-952::api", taskId: "TP-952", repoId: "api", order: 0 }, - ], + orderedSegments: [{ segmentId: "TP-952::api", taskId: "TP-952", repoId: "api", order: 0 }], statusBySegmentId: new Map([["TP-952::api", "running"]]), dependsOnBySegmentId: new Map([["TP-952::api", []]]), }; @@ -501,7 +626,12 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { }), }, segmentState, - { repos: new Map([["api", {}], ["web", {}]]) } as any, + { + repos: new Map([ + ["api", {}], + ["web", {}], + ]), + } as any, new Set(), ); expect(result.ok).toBe(false); @@ -542,7 +672,13 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { }), }, segmentState, - { repos: new Map([["shared-libs", {}], ["api-service", {}], ["web-client", {}]]) } as any, + { + repos: new Map([ + ["shared-libs", {}], + ["api-service", {}], + ["web-client", {}], + ]), + } as any, new Set(), ); expect(result.ok).toBe(true); @@ -553,22 +689,26 @@ describe("TP-145 expansion edge validation anchor-repo fix", () => { describe("TP-165 resolveTaskWorkerAgentId worker ID resolution", () => { it("returns outcome.sessionName when present", () => { - const outcomes: any[] = [{ - taskId: "TP-100", - sessionName: "orch-henry-lane-1-worker", - status: "succeeded", - }]; + const outcomes: any[] = [ + { + taskId: "TP-100", + sessionName: "orch-henry-lane-1-worker", + status: "succeeded", + }, + ]; const laneByTaskId = new Map(); const result = resolveTaskWorkerAgentId("TP-100", outcomes, laneByTaskId); expect(result).toBe("orch-henry-lane-1-worker"); }); it("falls back to canonical worker agent ID via agentIdPrefix when outcome sessionName is empty", () => { - const outcomes: any[] = [{ - taskId: "TP-100", - sessionName: "", - status: "succeeded", - }]; + const outcomes: any[] = [ + { + taskId: "TP-100", + sessionName: "", + status: "succeeded", + }, + ]; const laneByTaskId = new Map([ ["TP-100", { laneSessionId: "orch-henry-lane-1", laneNumber: 1 } as any], ]); @@ -589,11 +729,13 @@ describe("TP-165 resolveTaskWorkerAgentId worker ID resolution", () => { // In workspace mode, laneSessionId includes repoId and local lane number // (e.g., "orch-op-api-lane-1"), but the worker agent ID uses the global // laneNumber (e.g., lane 3 globally → "orch-op-lane-3-worker"). - const outcomes: any[] = [{ - taskId: "TP-200", - sessionName: "", - status: "succeeded", - }]; + const outcomes: any[] = [ + { + taskId: "TP-200", + sessionName: "", + status: "succeeded", + }, + ]; const laneByTaskId = new Map([ ["TP-200", { laneSessionId: "orch-op-api-lane-1", laneNumber: 3 } as any], ]); diff --git a/extensions/tests/segment-expansion-tool.test.ts b/extensions/tests/segment-expansion-tool.test.ts index 7acdf668..fc90e999 100644 --- a/extensions/tests/segment-expansion-tool.test.ts +++ b/extensions/tests/segment-expansion-tool.test.ts @@ -10,10 +10,16 @@ import { buildSegmentId } from "../taskplane/types.ts"; interface RegisteredTool { name: string; - execute: (toolCallId: string, params: any) => Promise<{ content: Array<{ type: string; text: string }> }>; + execute: ( + toolCallId: string, + params: any, + ) => Promise<{ content: Array<{ type: string; text: string }> }>; } -function withEnv(overrides: Record, fn: () => Promise | void): Promise | void { +function withEnv( + overrides: Record, + fn: () => Promise | void, +): Promise | void { const keys = Object.keys(overrides); const previous = new Map(); for (const key of keys) { @@ -70,180 +76,200 @@ afterEach(() => { describe("request_segment_expansion registration + autonomy guard", () => { it("is not registered when active segment context is missing", () => { - withEnv({ - TASKPLANE_ACTIVE_SEGMENT_ID: "", - TASKPLANE_OUTBOX_DIR: "", - }, () => { - const tools = registerTools(); - expect(tools.has("request_segment_expansion")).toBe(false); - }); + withEnv( + { + TASKPLANE_ACTIVE_SEGMENT_ID: "", + TASKPLANE_OUTBOX_DIR: "", + }, + () => { + const tools = registerTools(); + expect(tools.has("request_segment_expansion")).toBe(false); + }, + ); }); it("rejects non-autonomous calls with accepted=false and no file write", async () => { const outboxDir = mkdtempSync(join(tmpdir(), "tp-seg-expansion-")); tempDirs.push(outboxDir); - await withEnv({ - TASKPLANE_OUTBOX_DIR: outboxDir, - TASKPLANE_ACTIVE_SEGMENT_ID: "TP-777::api", - TASKPLANE_TASK_ID: "TP-777", - TASKPLANE_SUPERVISOR_AUTONOMY: "supervised", - }, async () => { - const tools = registerTools(); - expect(tools.has("request_segment_expansion")).toBe(true); - - const tool = tools.get("request_segment_expansion")!; - const result = await tool.execute("call-1", { - requestedRepoIds: ["web"], - rationale: "Need cross-repo update", - }); - const payload = parsePayload(result); - expect(payload.accepted).toBe(false); - expect(payload.requestId).toBe(null); - expect(payload.message).toBe("Segment expansion requires autonomous supervisor mode"); - expect(readdirSync(outboxDir)).toEqual([]); - }); + await withEnv( + { + TASKPLANE_OUTBOX_DIR: outboxDir, + TASKPLANE_ACTIVE_SEGMENT_ID: "TP-777::api", + TASKPLANE_TASK_ID: "TP-777", + TASKPLANE_SUPERVISOR_AUTONOMY: "supervised", + }, + async () => { + const tools = registerTools(); + expect(tools.has("request_segment_expansion")).toBe(true); + + const tool = tools.get("request_segment_expansion")!; + const result = await tool.execute("call-1", { + requestedRepoIds: ["web"], + rationale: "Need cross-repo update", + }); + const payload = parsePayload(result); + expect(payload.accepted).toBe(false); + expect(payload.requestId).toBe(null); + expect(payload.message).toBe("Segment expansion requires autonomous supervisor mode"); + expect(readdirSync(outboxDir)).toEqual([]); + }, + ); }); it("rejects invalid repo IDs and writes no request file", async () => { const outboxDir = mkdtempSync(join(tmpdir(), "tp-seg-expansion-")); tempDirs.push(outboxDir); - await withEnv({ - TASKPLANE_OUTBOX_DIR: outboxDir, - TASKPLANE_ACTIVE_SEGMENT_ID: "TP-780::api", - TASKPLANE_TASK_ID: "TP-780", - TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", - }, async () => { - const tool = registerTools().get("request_segment_expansion")!; - const result = await tool.execute("call-invalid", { - requestedRepoIds: ["Bad Repo"], - rationale: "bad", - }); - const payload = parsePayload(result); - expect(payload.accepted).toBe(false); - expect(payload.requestId).toBe(null); - expect(payload.rejections[0].reason).toBe("invalid repo ID format"); - expect(readdirSync(outboxDir)).toEqual([]); - }); + await withEnv( + { + TASKPLANE_OUTBOX_DIR: outboxDir, + TASKPLANE_ACTIVE_SEGMENT_ID: "TP-780::api", + TASKPLANE_TASK_ID: "TP-780", + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }, + async () => { + const tool = registerTools().get("request_segment_expansion")!; + const result = await tool.execute("call-invalid", { + requestedRepoIds: ["Bad Repo"], + rationale: "bad", + }); + const payload = parsePayload(result); + expect(payload.accepted).toBe(false); + expect(payload.requestId).toBe(null); + expect(payload.rejections[0].reason).toBe("invalid repo ID format"); + expect(readdirSync(outboxDir)).toEqual([]); + }, + ); }); it("rejects duplicate repo IDs within a single request", async () => { const outboxDir = mkdtempSync(join(tmpdir(), "tp-seg-expansion-")); tempDirs.push(outboxDir); - await withEnv({ - TASKPLANE_OUTBOX_DIR: outboxDir, - TASKPLANE_ACTIVE_SEGMENT_ID: "TP-781::api", - TASKPLANE_TASK_ID: "TP-781", - TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", - }, async () => { - const tool = registerTools().get("request_segment_expansion")!; - const result = await tool.execute("call-dup", { - requestedRepoIds: ["web", "web"], - rationale: "dup", - }); - const payload = parsePayload(result); - expect(payload.accepted).toBe(false); - expect(payload.rejections[0].reason).toBe("duplicate repo ID in request"); - expect(readdirSync(outboxDir)).toEqual([]); - }); + await withEnv( + { + TASKPLANE_OUTBOX_DIR: outboxDir, + TASKPLANE_ACTIVE_SEGMENT_ID: "TP-781::api", + TASKPLANE_TASK_ID: "TP-781", + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }, + async () => { + const tool = registerTools().get("request_segment_expansion")!; + const result = await tool.execute("call-dup", { + requestedRepoIds: ["web", "web"], + rationale: "dup", + }); + const payload = parsePayload(result); + expect(payload.accepted).toBe(false); + expect(payload.rejections[0].reason).toBe("duplicate repo ID in request"); + expect(readdirSync(outboxDir)).toEqual([]); + }, + ); }); it("rejects empty requestedRepoIds", async () => { const outboxDir = mkdtempSync(join(tmpdir(), "tp-seg-expansion-")); tempDirs.push(outboxDir); - await withEnv({ - TASKPLANE_OUTBOX_DIR: outboxDir, - TASKPLANE_ACTIVE_SEGMENT_ID: "TP-782::api", - TASKPLANE_TASK_ID: "TP-782", - TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", - }, async () => { - const tool = registerTools().get("request_segment_expansion")!; - const result = await tool.execute("call-empty", { - requestedRepoIds: [], - rationale: "empty", - }); - const payload = parsePayload(result); - expect(payload.accepted).toBe(false); - expect(payload.rejections[0].reason).toBe("requestedRepoIds must be a non-empty array"); - expect(readdirSync(outboxDir)).toEqual([]); - }); + await withEnv( + { + TASKPLANE_OUTBOX_DIR: outboxDir, + TASKPLANE_ACTIVE_SEGMENT_ID: "TP-782::api", + TASKPLANE_TASK_ID: "TP-782", + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }, + async () => { + const tool = registerTools().get("request_segment_expansion")!; + const result = await tool.execute("call-empty", { + requestedRepoIds: [], + rationale: "empty", + }); + const payload = parsePayload(result); + expect(payload.accepted).toBe(false); + expect(payload.rejections[0].reason).toBe("requestedRepoIds must be a non-empty array"); + expect(readdirSync(outboxDir)).toEqual([]); + }, + ); }); it("writes segment expansion request file with schema payload on valid input", async () => { const outboxDir = mkdtempSync(join(tmpdir(), "tp-seg-expansion-")); tempDirs.push(outboxDir); - await withEnv({ - TASKPLANE_OUTBOX_DIR: outboxDir, - TASKPLANE_ACTIVE_SEGMENT_ID: "TP-888::api", - TASKPLANE_TASK_ID: "TP-888", - TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", - }, async () => { - const tools = registerTools(); - const tool = tools.get("request_segment_expansion")!; - const result = await tool.execute("call-2", { - requestedRepoIds: ["web", "docs"], - rationale: "Need docs + UI updates", - placement: "end", - edges: [{ from: "web", to: "docs" }], - }); - const payload = parsePayload(result); - expect(payload.accepted).toBe(true); - expect(payload.requestId).toMatch(/^exp-\d{13}-[a-z0-9]{5}$/); - - const requestFile = join(outboxDir, `segment-expansion-${payload.requestId}.json`); - const raw = readFileSync(requestFile, "utf-8"); - const parsed = JSON.parse(raw); - expect(parsed.requestId).toBe(payload.requestId); - expect(parsed.taskId).toBe("TP-888"); - expect(parsed.fromSegmentId).toBe("TP-888::api"); - expect(parsed.requestedRepoIds).toEqual(["web", "docs"]); - expect(parsed.rationale).toBe("Need docs + UI updates"); - expect(parsed.placement).toBe("end"); - expect(parsed.edges).toEqual([{ from: "web", to: "docs" }]); - expect(typeof parsed.timestamp).toBe("number"); - const files = readdirSync(outboxDir); - expect(files.some((f) => f.endsWith(".tmp"))).toBe(false); - }); + await withEnv( + { + TASKPLANE_OUTBOX_DIR: outboxDir, + TASKPLANE_ACTIVE_SEGMENT_ID: "TP-888::api", + TASKPLANE_TASK_ID: "TP-888", + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }, + async () => { + const tools = registerTools(); + const tool = tools.get("request_segment_expansion")!; + const result = await tool.execute("call-2", { + requestedRepoIds: ["web", "docs"], + rationale: "Need docs + UI updates", + placement: "end", + edges: [{ from: "web", to: "docs" }], + }); + const payload = parsePayload(result); + expect(payload.accepted).toBe(true); + expect(payload.requestId).toMatch(/^exp-\d{13}-[a-z0-9]{5}$/); + + const requestFile = join(outboxDir, `segment-expansion-${payload.requestId}.json`); + const raw = readFileSync(requestFile, "utf-8"); + const parsed = JSON.parse(raw); + expect(parsed.requestId).toBe(payload.requestId); + expect(parsed.taskId).toBe("TP-888"); + expect(parsed.fromSegmentId).toBe("TP-888::api"); + expect(parsed.requestedRepoIds).toEqual(["web", "docs"]); + expect(parsed.rationale).toBe("Need docs + UI updates"); + expect(parsed.placement).toBe("end"); + expect(parsed.edges).toEqual([{ from: "web", to: "docs" }]); + expect(typeof parsed.timestamp).toBe("number"); + const files = readdirSync(outboxDir); + expect(files.some((f) => f.endsWith(".tmp"))).toBe(false); + }, + ); }); it("writes TP-007-style api-service → web-client after-current request payload", async () => { const outboxDir = mkdtempSync(join(tmpdir(), "tp-seg-expansion-")); tempDirs.push(outboxDir); - await withEnv({ - TASKPLANE_OUTBOX_DIR: outboxDir, - TASKPLANE_ACTIVE_SEGMENT_ID: "TP-007::api-service", - TASKPLANE_TASK_ID: "TP-007", - TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", - }, async () => { - const tool = registerTools().get("request_segment_expansion")!; - const result = await tool.execute("call-tp-007", { - requestedRepoIds: ["web-client"], - rationale: "api-service health payload now includes statusLevel", - placement: "after-current", - edges: [], - }); - const payload = parsePayload(result); - expect(payload.accepted).toBe(true); - expect(payload.requestId).toMatch(/^exp-\d{13}-[a-z0-9]{5}$/); - - const requestFile = join(outboxDir, `segment-expansion-${payload.requestId}.json`); - const parsed = JSON.parse(readFileSync(requestFile, "utf-8")); - expect(parsed.taskId).toBe("TP-007"); - expect(parsed.fromSegmentId).toBe("TP-007::api-service"); - expect(parsed.requestedRepoIds).toEqual(["web-client"]); - expect(parsed.placement).toBe("after-current"); - expect(parsed.edges).toEqual([]); - expect(parsed.rationale).toContain("statusLevel"); - }); + await withEnv( + { + TASKPLANE_OUTBOX_DIR: outboxDir, + TASKPLANE_ACTIVE_SEGMENT_ID: "TP-007::api-service", + TASKPLANE_TASK_ID: "TP-007", + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }, + async () => { + const tool = registerTools().get("request_segment_expansion")!; + const result = await tool.execute("call-tp-007", { + requestedRepoIds: ["web-client"], + rationale: "api-service health payload now includes statusLevel", + placement: "after-current", + edges: [], + }); + const payload = parsePayload(result); + expect(payload.accepted).toBe(true); + expect(payload.requestId).toMatch(/^exp-\d{13}-[a-z0-9]{5}$/); + + const requestFile = join(outboxDir, `segment-expansion-${payload.requestId}.json`); + const parsed = JSON.parse(readFileSync(requestFile, "utf-8")); + expect(parsed.taskId).toBe("TP-007"); + expect(parsed.fromSegmentId).toBe("TP-007::api-service"); + expect(parsed.requestedRepoIds).toEqual(["web-client"]); + expect(parsed.placement).toBe("after-current"); + expect(parsed.edges).toEqual([]); + expect(parsed.rationale).toContain("statusLevel"); + }, + ); }); }); - describe("segment ID helpers", () => { it("buildSegmentId appends sequence suffix when sequence >= 2", () => { expect(buildSegmentId("TP-900", "api", 2)).toBe("TP-900::api::2"); @@ -260,7 +286,7 @@ describe("autonomy wiring contracts", () => { const workerSrc = readFileSync(join(__dirname, "..", "taskplane", "engine-worker.ts"), "utf-8"); expect(extensionSrc).toContain("supervisorAutonomy: supervisorConfig.autonomy"); - expect(workerSrc).toContain("data.supervisorAutonomy ?? \"autonomous\""); + expect(workerSrc).toContain('data.supervisorAutonomy ?? "autonomous"'); }); it("propagates autonomy through executeWave into lane-runner env", () => { @@ -269,6 +295,8 @@ describe("autonomy wiring contracts", () => { expect(executionSrc).toContain("TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy"); expect(executionSrc).toContain("extraEnvVars?.TASKPLANE_SUPERVISOR_AUTONOMY"); - expect(laneRunnerSrc).toContain("TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || \"autonomous\""); + expect(laneRunnerSrc).toContain( + 'TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous"', + ); }); }); diff --git a/extensions/tests/segment-marker-validation.test.ts b/extensions/tests/segment-marker-validation.test.ts index b59e4c07..cb483163 100644 --- a/extensions/tests/segment-marker-validation.test.ts +++ b/extensions/tests/segment-marker-validation.test.ts @@ -9,7 +9,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { readFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; -import { parseStepSegmentMapping, SEGMENT_FALLBACK_REPO_PLACEHOLDER } from "../taskplane/discovery.ts"; +import { + parseStepSegmentMapping, + SEGMENT_FALLBACK_REPO_PLACEHOLDER, +} from "../taskplane/discovery.ts"; const WORKSPACE_ROOT = "C:/dev/tp-test-workspace"; const TASKS_ROOT = resolve(WORKSPACE_ROOT, "shared-libs/task-management/platform/general"); @@ -24,7 +27,9 @@ function readPrompt(taskFolder: string): string { const WORKSPACE_EXISTS = existsSync(TASKS_ROOT); -describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS && "polyrepo test workspace not available" }, () => { +describe("TP-177: Polyrepo segment marker validation", { + skip: !WORKSPACE_EXISTS && "polyrepo test workspace not available", +}, () => { // ── Single-segment tasks should have NO segment markers ── describe("Single-segment tasks (no segment markers expected)", () => { for (const task of [ @@ -36,8 +41,16 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS const content = readPrompt(task.folder); const result = parseStepSegmentMapping(content, task.id, task.repo); - assert.equal(result.errors.length, 0, `Expected no errors, got: ${JSON.stringify(result.errors)}`); - assert.equal(result.warnings.length, 0, `Expected no warnings, got: ${JSON.stringify(result.warnings)}`); + assert.equal( + result.errors.length, + 0, + `Expected no errors, got: ${JSON.stringify(result.errors)}`, + ); + assert.equal( + result.warnings.length, + 0, + `Expected no warnings, got: ${JSON.stringify(result.warnings)}`, + ); assert.ok(result.mapping.length > 0, "Expected at least one step"); // All segments should use the fallback repo @@ -65,7 +78,7 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS const result = parseStepSegmentMapping(content, "TP-004", "shared-libs"); // Step 0: Preflight → shared-libs + web-client - const step0 = result.mapping.find(s => s.stepNumber === 0); + const step0 = result.mapping.find((s) => s.stepNumber === 0); assert.ok(step0, "Step 0 must exist"); assert.equal(step0.segments.length, 2, "Step 0 should have 2 segments"); assert.equal(step0.segments[0].repoId, "shared-libs"); @@ -74,21 +87,21 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS assert.ok(step0.segments[1].checkboxes.length > 0, "web-client segment has checkboxes"); // Step 1: shared-libs only - const step1 = result.mapping.find(s => s.stepNumber === 1); + const step1 = result.mapping.find((s) => s.stepNumber === 1); assert.ok(step1, "Step 1 must exist"); assert.equal(step1.segments.length, 1, "Step 1 should have 1 segment"); assert.equal(step1.segments[0].repoId, "shared-libs"); assert.equal(step1.segments[0].checkboxes.length, 3); // Step 2: web-client only - const step2 = result.mapping.find(s => s.stepNumber === 2); + const step2 = result.mapping.find((s) => s.stepNumber === 2); assert.ok(step2, "Step 2 must exist"); assert.equal(step2.segments.length, 1, "Step 2 should have 1 segment"); assert.equal(step2.segments[0].repoId, "web-client"); assert.equal(step2.segments[0].checkboxes.length, 4); // Step 3: Documentation → shared-libs (packet repo) - const step3 = result.mapping.find(s => s.stepNumber === 3); + const step3 = result.mapping.find((s) => s.stepNumber === 3); assert.ok(step3, "Step 3 must exist"); assert.equal(step3.segments.length, 1, "Step 3 should have 1 segment"); assert.equal(step3.segments[0].repoId, "shared-libs"); @@ -110,28 +123,28 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS const result = parseStepSegmentMapping(content, "TP-005", "shared-libs"); // Step 0: Preflight → shared-libs + api-service - const step0 = result.mapping.find(s => s.stepNumber === 0); + const step0 = result.mapping.find((s) => s.stepNumber === 0); assert.ok(step0, "Step 0 must exist"); assert.equal(step0.segments.length, 2); assert.equal(step0.segments[0].repoId, "shared-libs"); assert.equal(step0.segments[1].repoId, "api-service"); // Step 1: shared-libs only - const step1 = result.mapping.find(s => s.stepNumber === 1); + const step1 = result.mapping.find((s) => s.stepNumber === 1); assert.ok(step1, "Step 1 must exist"); assert.equal(step1.segments.length, 1); assert.equal(step1.segments[0].repoId, "shared-libs"); assert.equal(step1.segments[0].checkboxes.length, 4); // Step 2: api-service only - const step2 = result.mapping.find(s => s.stepNumber === 2); + const step2 = result.mapping.find((s) => s.stepNumber === 2); assert.ok(step2, "Step 2 must exist"); assert.equal(step2.segments.length, 1); assert.equal(step2.segments[0].repoId, "api-service"); assert.equal(step2.segments[0].checkboxes.length, 4); // Step 3: Documentation → shared-libs - const step3 = result.mapping.find(s => s.stepNumber === 3); + const step3 = result.mapping.find((s) => s.stepNumber === 3); assert.ok(step3, "Step 3 must exist"); assert.equal(step3.segments.length, 1); assert.equal(step3.segments[0].repoId, "shared-libs"); @@ -153,35 +166,35 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS const result = parseStepSegmentMapping(content, "TP-006", "shared-libs"); // Step 0: Preflight → shared-libs + api-service + web-client - const step0 = result.mapping.find(s => s.stepNumber === 0); + const step0 = result.mapping.find((s) => s.stepNumber === 0); assert.ok(step0, "Step 0 must exist"); assert.equal(step0.segments.length, 3, "Step 0 should have 3 segments"); - const step0Repos = step0.segments.map(s => s.repoId).sort(); + const step0Repos = step0.segments.map((s) => s.repoId).sort(); assert.deepEqual(step0Repos, ["api-service", "shared-libs", "web-client"]); // Step 1: shared-libs only - const step1 = result.mapping.find(s => s.stepNumber === 1); + const step1 = result.mapping.find((s) => s.stepNumber === 1); assert.ok(step1, "Step 1 must exist"); assert.equal(step1.segments.length, 1); assert.equal(step1.segments[0].repoId, "shared-libs"); assert.equal(step1.segments[0].checkboxes.length, 3); // Step 2: api-service only - const step2 = result.mapping.find(s => s.stepNumber === 2); + const step2 = result.mapping.find((s) => s.stepNumber === 2); assert.ok(step2, "Step 2 must exist"); assert.equal(step2.segments.length, 1); assert.equal(step2.segments[0].repoId, "api-service"); assert.equal(step2.segments[0].checkboxes.length, 2); // Step 3: web-client only - const step3 = result.mapping.find(s => s.stepNumber === 3); + const step3 = result.mapping.find((s) => s.stepNumber === 3); assert.ok(step3, "Step 3 must exist"); assert.equal(step3.segments.length, 1); assert.equal(step3.segments[0].repoId, "web-client"); assert.equal(step3.segments[0].checkboxes.length, 2); // Step 4: Documentation → shared-libs - const step4 = result.mapping.find(s => s.stepNumber === 4); + const step4 = result.mapping.find((s) => s.stepNumber === 4); assert.ok(step4, "Step 4 must exist"); assert.equal(step4.segments.length, 1); assert.equal(step4.segments[0].repoId, "shared-libs"); @@ -226,7 +239,7 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS for (const expected of task.expectedSegments) { assert.ok( foundSegments.has(expected), - `STATUS.md should contain #### Segment: ${expected}. Found: ${[...foundSegments].join(", ")}` + `STATUS.md should contain #### Segment: ${expected}. Found: ${[...foundSegments].join(", ")}`, ); } }); @@ -239,7 +252,7 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS // For each segment marker, verify there are checkboxes below it for (const seg of task.expectedSegments) { const segHeaderPattern = new RegExp(`^####\\s+Segment:\\s*${seg}\\s*$`); - const segHeaderIdx = lines.findIndex(l => segHeaderPattern.test(l)); + const segHeaderIdx = lines.findIndex((l) => segHeaderPattern.test(l)); assert.ok(segHeaderIdx >= 0, `Should find #### Segment: ${seg} header line`); // Count checkboxes from the header line until next header or end @@ -250,7 +263,7 @@ describe("TP-177: Polyrepo segment marker validation", { skip: !WORKSPACE_EXISTS } assert.ok( checkboxCount > 0, - `Segment ${seg} in STATUS.md should have at least one checkbox, found ${checkboxCount}` + `Segment ${seg} in STATUS.md should have at least one checkbox, found ${checkboxCount}`, ); } }); diff --git a/extensions/tests/segment-model.test.ts b/extensions/tests/segment-model.test.ts index fc5b34b5..8b618b1f 100644 --- a/extensions/tests/segment-model.test.ts +++ b/extensions/tests/segment-model.test.ts @@ -38,15 +38,19 @@ describe("segment ID contract", () => { describe("task segment plan determinism", () => { it("orders task map keys, segments, and edges deterministically", () => { const pending = new Map([ - ["TP-200", makeTask("TP-200", { resolvedRepoId: "api", fileScope: ["docs/README.md", "api/src/main.ts"] })], - ["TP-100", makeTask("TP-100", { - explicitSegmentDag: { - repoIds: ["web", "api"], - edges: [ - { fromRepoId: "web", toRepoId: "api" }, - ], - }, - })], + [ + "TP-200", + makeTask("TP-200", { resolvedRepoId: "api", fileScope: ["docs/README.md", "api/src/main.ts"] }), + ], + [ + "TP-100", + makeTask("TP-100", { + explicitSegmentDag: { + repoIds: ["web", "api"], + edges: [{ fromRepoId: "web", toRepoId: "api" }], + }, + }), + ], ]); const plans = buildTaskSegmentPlans(pending); @@ -54,10 +58,7 @@ describe("task segment plan determinism", () => { const explicit = plans.get("TP-100")!; expect(explicit.mode).toBe("explicit-dag"); - expect(explicit.segments.map((s) => s.segmentId)).toEqual([ - "TP-100::web", - "TP-100::api", - ]); + expect(explicit.segments.map((s) => s.segmentId)).toEqual(["TP-100::web", "TP-100::api"]); expect(explicit.edges.map((e) => `${e.fromSegmentId}->${e.toSegmentId}`)).toEqual([ "TP-100::web->TP-100::api", ]); @@ -102,18 +103,18 @@ describe("computeWaveAssignments segment plan wiring", () => { it("accepts workspaceRepoIds to infer cross-repo file scope hints", () => { const pending = new Map([ - ["TP-450", makeTask("TP-450", { - resolvedRepoId: "api", - fileScope: ["api/src/service.ts", "web/src/client.ts"], - })], + [ + "TP-450", + makeTask("TP-450", { + resolvedRepoId: "api", + fileScope: ["api/src/service.ts", "web/src/client.ts"], + }), + ], ]); - const result = computeWaveAssignments( - pending, - new Set(), - DEFAULT_ORCHESTRATOR_CONFIG, - { workspaceRepoIds: ["api", "web"] }, - ); + const result = computeWaveAssignments(pending, new Set(), DEFAULT_ORCHESTRATOR_CONFIG, { + workspaceRepoIds: ["api", "web"], + }); expect(result.errors).toEqual([]); expect(result.segmentPlans).toBeDefined(); expect(result.segmentPlans!.get("TP-450")!.segments.map((s) => s.repoId)).toEqual(["api", "web"]); diff --git a/extensions/tests/segment-scoped-lane-runner.test.ts b/extensions/tests/segment-scoped-lane-runner.test.ts index 94ea459c..cd4f3847 100644 --- a/extensions/tests/segment-scoped-lane-runner.test.ts +++ b/extensions/tests/segment-scoped-lane-runner.test.ts @@ -44,9 +44,7 @@ const MULTI_SEGMENT_MAP: StepSegmentMapping[] = [ { stepNumber: 2, stepName: "Documentation & Delivery", - segments: [ - { repoId: "shared-libs", checkboxes: ["- [ ] Update STATUS.md"] }, - ], + segments: [{ repoId: "shared-libs", checkboxes: ["- [ ] Update STATUS.md"] }], }, ]; @@ -54,16 +52,12 @@ const SINGLE_SEGMENT_MAP: StepSegmentMapping[] = [ { stepNumber: 0, stepName: "Preflight", - segments: [ - { repoId: "default", checkboxes: ["- [ ] Verify project structure"] }, - ], + segments: [{ repoId: "default", checkboxes: ["- [ ] Verify project structure"] }], }, { stepNumber: 1, stepName: "Implement feature", - segments: [ - { repoId: "default", checkboxes: ["- [ ] Create src/utils.js", "- [ ] Add tests"] }, - ], + segments: [{ repoId: "default", checkboxes: ["- [ ] Create src/utils.js", "- [ ] Add tests"] }], }, ]; @@ -339,7 +333,9 @@ describe("5.x: Segment-scoped progress and stall detection contracts (source ana }); it("5.4: corrective re-spawn references segment-specific unchecked items", () => { - expect(laneRunnerSrc).toContain("TP-174: When segment-scoped, report only this segment's unchecked items"); + expect(laneRunnerSrc).toContain( + "TP-174: When segment-scoped, report only this segment's unchecked items", + ); }); }); @@ -361,7 +357,9 @@ describe("6.x: Segment exit condition contracts (source analysis)", () => { }); it("6.2: remainingSteps uses isSegmentComplete for segment-scoped step advancement", () => { - expect(laneRunnerSrc).toContain("!isSegmentComplete(iterStatusContent, step.number, currentRepoId)"); + expect(laneRunnerSrc).toContain( + "!isSegmentComplete(iterStatusContent, step.number, currentRepoId)", + ); }); it("6.3: post-loop completion uses segment-scoped check", () => { @@ -397,23 +395,32 @@ describe("7.x: Legacy fallback — no behavior change for tasks without markers" }); it("7.3: segment prompt block skipped when repoStepNumbers is null", () => { - expect(laneRunnerSrc).toContain("if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0)"); + expect(laneRunnerSrc).toContain( + "if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0)", + ); }); it("7.4: progress counting falls back to full-task when no segment context", () => { // The else branches should reduce across all steps - expect(laneRunnerSrc).toContain("currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0)"); + expect(laneRunnerSrc).toContain( + "currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0)", + ); expect(laneRunnerSrc).toContain("afterStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0)"); }); it("7.5: step completion check falls back to isStepComplete when no segment context", () => { - // allComplete else branch uses isStepComplete - const fallbackPattern = /allComplete = parsed\.steps\.every\(step =>/; - expect(fallbackPattern.test(laneRunnerSrc)).toBe(true); + // allComplete else branch uses isStepComplete. + // TP-193: pattern accepts both `step =>` and `(step) =>` (formatter + // inserts arrow parens) and uses normalized whitespace. + const normSrc = laneRunnerSrc.replace(/\s+/g, " "); + const fallbackPattern = /allComplete = parsed\.steps\.every\(\(?step\)? =>/; + expect(fallbackPattern.test(normSrc)).toBe(true); }); it("7.6: emitSnapshot receives null segmentContext for non-segment tasks", () => { - expect(laneRunnerSrc).toContain("snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null"); + expect(laneRunnerSrc).toContain( + "snapshotSegmentCtx: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null", + ); }); }); @@ -431,7 +438,9 @@ describe("8.x: Snapshot segment-scoped progress (emitSnapshot)", () => { }); it("8.1: emitSnapshot accepts segmentContext parameter", () => { - expect(laneRunnerSrc).toContain("segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null"); + expect(laneRunnerSrc).toContain( + "segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null", + ); }); it("8.2: emitSnapshot uses segment-scoped checked/total when segmentContext provided", () => { @@ -440,12 +449,21 @@ describe("8.x: Snapshot segment-scoped progress (emitSnapshot)", () => { }); it("8.3: all emitSnapshot calls pass snapshotSegmentCtx", () => { - const calls = laneRunnerSrc.match(/emitSnapshot\(config,.*snapshotSegmentCtx\)/g); + // TP-193: Whitespace-normalize so cosmetic formatter wrapping (multi-arg + // emitSnapshot calls split across lines) doesn't break the regex match. + const normSrc = laneRunnerSrc + .replace(/\s+/g, " ") + .replace(/([(\[{])\s+/g, "$1") + .replace(/\s+([)\]},])/g, "$1") + .replace(/,([)\]}])/g, "$1"); + const calls = normSrc.match(/emitSnapshot\(config,.*?snapshotSegmentCtx\)/g); expect(calls).not.toBe(null); expect(calls!.length).toBeGreaterThanOrEqual(2); }); it("8.4: makeResult passes segmentCtx to emitSnapshot", () => { - expect(laneRunnerSrc).toContain("emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx)"); + expect(laneRunnerSrc).toContainNormalized( + "emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx)", + ); }); }); diff --git a/extensions/tests/segment-state-persistence.test.ts b/extensions/tests/segment-state-persistence.test.ts index 4fb7221b..cc170c56 100644 --- a/extensions/tests/segment-state-persistence.test.ts +++ b/extensions/tests/segment-state-persistence.test.ts @@ -35,12 +35,14 @@ describe("TP-135 segment state persistence", () => { laneSessionId: "orch-lane-1", worktreePath: "/tmp/worktree-1", branch: "task/lane-1", - tasks: [{ - taskId: "TP-100", - order: 0, - task, - estimatedMinutes: 5, - }], + tasks: [ + { + taskId: "TP-100", + order: 0, + task, + estimatedMinutes: 5, + }, + ], strategy: "round-robin", estimatedLoad: 1, estimatedMinutes: 5, @@ -68,21 +70,23 @@ describe("TP-135 segment state persistence", () => { batchState.totalWaves = 1; batchState.totalTasks = 1; batchState.currentLanes = [lane]; - batchState.segments = [{ - segmentId: "TP-100::api", - taskId: "TP-100", - repoId: "api", - status: "running", - laneId: "lane-1", - sessionName: "orch-lane-1", - worktreePath: "/tmp/worktree-1", - branch: "task/lane-1", - startedAt: Date.now() - 1000, - endedAt: null, - retries: 0, - exitReason: "Segment running", - dependsOnSegmentIds: [], - }]; + batchState.segments = [ + { + segmentId: "TP-100::api", + taskId: "TP-100", + repoId: "api", + status: "running", + laneId: "lane-1", + sessionName: "orch-lane-1", + worktreePath: "/tmp/worktree-1", + branch: "task/lane-1", + startedAt: Date.now() - 1000, + endedAt: null, + retries: 0, + exitReason: "Segment running", + dependsOnSegmentIds: [], + }, + ]; persistRuntimeState( "segment-start", diff --git a/extensions/tests/settings-loader.test.ts b/extensions/tests/settings-loader.test.ts index 45e23ee2..4914c1c3 100644 --- a/extensions/tests/settings-loader.test.ts +++ b/extensions/tests/settings-loader.test.ts @@ -21,7 +21,10 @@ import { loadPiSettingsPackages, filterExcludedExtensions } from "../taskplane/s // ── Test Helpers ───────────────────────────────────────────────────── function createTempDir(): string { - const dir = join(tmpdir(), `tp180-settings-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const dir = join( + tmpdir(), + `tp180-settings-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); mkdirSync(dir, { recursive: true }); return dir; } @@ -102,7 +105,7 @@ describe("loadPiSettingsPackages", () => { packages: ["npm:pi-sage", "npm:pi-sage"], }); const result = loadPiSettingsPackages(tempDir); - const sageCount = result.filter(p => p === "npm:pi-sage").length; + const sageCount = result.filter((p) => p === "npm:pi-sage").length; assert.equal(sageCount, 1); }); @@ -114,7 +117,7 @@ describe("loadPiSettingsPackages", () => { assert.ok(result.includes("npm:pi-sage")); assert.ok(result.includes("npm:pi-memory")); // Numeric/null/boolean values should be excluded - assert.ok(!result.some(p => typeof p !== "string")); + assert.ok(!result.some((p) => typeof p !== "string")); }); it("handles packages that is not an array", () => { diff --git a/extensions/tests/settings-tui.test.ts b/extensions/tests/settings-tui.test.ts index 94f55665..710b52cb 100644 --- a/extensions/tests/settings-tui.test.ts +++ b/extensions/tests/settings-tui.test.ts @@ -26,13 +26,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { expect } from "./expect.ts"; -import { - mkdirSync, - writeFileSync, - readFileSync, - existsSync, - rmSync, -} from "fs"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -61,11 +55,7 @@ import { GLOBAL_PREFERENCES_FILENAME, GLOBAL_PREFERENCES_SUBDIR, } from "../taskplane/config-schema.ts"; -import type { - TaskplaneConfig, - GlobalPreferences, -} from "../taskplane/config-schema.ts"; - +import type { TaskplaneConfig, GlobalPreferences } from "../taskplane/config-schema.ts"; // ── Helpers ────────────────────────────────────────────────────────── @@ -127,7 +117,6 @@ function makeL2NumberField(overrides: Partial = {}): FieldDef { }; } - // ── 9.x detectFieldSource ──────────────────────────────────────────── describe("9. detectFieldSource", () => { @@ -304,7 +293,6 @@ describe("9. detectFieldSource", () => { }); }); - // ── 10.x getFieldDisplayValue ──────────────────────────────────────── describe("10. getFieldDisplayValue", () => { @@ -367,11 +355,9 @@ describe("10. getFieldDisplayValue", () => { }); }); - // ── 11.x validateFieldInput ────────────────────────────────────────── describe("11. validateFieldInput", () => { - // 11.1 — Number validation describe("11.1 Number validation", () => { @@ -504,7 +490,6 @@ describe("11. validateFieldInput", () => { }); }); - // ── 12.x SECTIONS coverage ────────────────────────────────────────── describe("12. SECTIONS schema coverage", () => { @@ -570,9 +555,9 @@ describe("12. SECTIONS schema coverage", () => { }); it("12.8 merge thinking remains L1+L2 with prefs destination", () => { - const mergeThinking = SECTIONS - .flatMap((section) => section.fields) - .find((f) => f.configPath === "orchestrator.merge.thinking"); + const mergeThinking = SECTIONS.flatMap((section) => section.fields).find( + (f) => f.configPath === "orchestrator.merge.thinking", + ); expect(mergeThinking).toBeDefined(); expect(mergeThinking!.layer).toBe("L1+L2"); expect(mergeThinking!.prefsKey).toBe("mergeThinking"); @@ -580,7 +565,6 @@ describe("12. SECTIONS schema coverage", () => { }); }); - // ── Write-Back Test Fixtures ───────────────────────────────────────── let writeTestRoot: string; @@ -608,7 +592,6 @@ function readJsonFile(path: string): any { return JSON.parse(readFileSync(path, "utf-8")); } - // ── 13.x coerceValueForWrite ───────────────────────────────────────── describe("13. coerceValueForWrite", () => { @@ -695,7 +678,6 @@ describe("13. coerceValueForWrite", () => { }); }); - // ── 14.x writeProjectConfigField ───────────────────────────────────── describe("14. writeProjectConfigField", () => { @@ -717,7 +699,9 @@ describe("14. writeProjectConfigField", () => { delete process.env.TASKPLANE_WORKSPACE_ROOT; try { rmSync(writeTestRoot, { recursive: true, force: true }); - } catch { /* best effort on Windows */ } + } catch { + /* best effort on Windows */ + } }); it("14.1 writes new value to existing JSON config", () => { @@ -767,19 +751,23 @@ describe("14. writeProjectConfigField", () => { const dir = makeWriteTestDir("malformed"); writePiFile(dir, PROJECT_CONFIG_FILENAME, "{ bad json !!!"); - expect(() => - writeProjectConfigField(dir, "orchestrator.orchestrator.maxLanes", 5), - ).toThrow(/malformed JSON/i); + expect(() => writeProjectConfigField(dir, "orchestrator.orchestrator.maxLanes", 5)).toThrow( + /malformed JSON/i, + ); }); it("14.5 seeds first JSON override from YAML-only project (preserves YAML overrides)", () => { const dir = makeWriteTestDir("yaml-only"); // Write a YAML config with a custom value - writePiFile(dir, "task-orchestrator.yaml", ` + writePiFile( + dir, + "task-orchestrator.yaml", + ` orchestrator: max_lanes: 7 spawn_mode: subprocess -`); +`, + ); writeProjectConfigField(dir, "orchestrator.orchestrator.worktreePrefix", "test-wt"); @@ -797,11 +785,15 @@ orchestrator: it("14.5b removing a seeded project override keeps unrelated YAML overrides", () => { const dir = makeWriteTestDir("yaml-remove-override"); - writePiFile(dir, "task-orchestrator.yaml", ` + writePiFile( + dir, + "task-orchestrator.yaml", + ` orchestrator: max_lanes: 7 spawn_mode: subprocess -`); +`, + ); writeProjectConfigField(dir, "orchestrator.orchestrator.worktreePrefix", "temp-prefix"); writeProjectConfigField(dir, "orchestrator.orchestrator.worktreePrefix", undefined); @@ -814,18 +806,26 @@ orchestrator: it("14.5c first write preserves YAML keys outside source-detection mapper", () => { const dir = makeWriteTestDir("yaml-preserve-extra-keys"); - writePiFile(dir, "task-runner.yaml", ` + writePiFile( + dir, + "task-runner.yaml", + ` quality_gate: enabled: true model_fallback: fail -`); - writePiFile(dir, "task-orchestrator.yaml", ` +`, + ); + writePiFile( + dir, + "task-orchestrator.yaml", + ` supervisor: model: custom-super verification: enabled: true mode: strict -`); +`, + ); writeProjectConfigField(dir, "orchestrator.orchestrator.worktreePrefix", "seeded-prefix"); @@ -839,7 +839,10 @@ verification: it("14.5d first write preserves taskplane-workspace.yaml overrides", () => { const dir = makeWriteTestDir("yaml-preserve-workspace"); - writePiFile(dir, "taskplane-workspace.yaml", ` + writePiFile( + dir, + "taskplane-workspace.yaml", + ` repos: docs: path: ../docs @@ -847,7 +850,8 @@ routing: tasks_root: taskplane-tasks default_repo: docs task_packet_repo: docs -`); +`, + ); writeProjectConfigField(dir, "orchestrator.orchestrator.worktreePrefix", "with-workspace"); @@ -932,7 +936,11 @@ routing: const workspaceRoot = makeWriteTestDir("pointer-flat-workspace"); const pointerRoot = join(workspaceRoot, "config-repo", ".taskplane"); mkdirSync(pointerRoot, { recursive: true }); - writeFileSync(join(pointerRoot, "task-orchestrator.yaml"), "orchestrator:\n max_lanes: 6\n", "utf-8"); + writeFileSync( + join(pointerRoot, "task-orchestrator.yaml"), + "orchestrator:\n max_lanes: 6\n", + "utf-8", + ); writeProjectConfigField( workspaceRoot, @@ -950,12 +958,14 @@ routing: }); }); - // ── 15.x writeGlobalPreference ───────────────────────────────────────── describe("15. writeGlobalPreference", () => { beforeEach(() => { - writeTestRoot = join(tmpdir(), `tp-prefs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + writeTestRoot = join( + tmpdir(), + `tp-prefs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(writeTestRoot, { recursive: true }); writeCounter = 0; savedAgentDir = process.env.PI_CODING_AGENT_DIR; @@ -971,7 +981,9 @@ describe("15. writeGlobalPreference", () => { } try { rmSync(writeTestRoot, { recursive: true, force: true }); - } catch { /* best effort on Windows */ } + } catch { + /* best effort on Windows */ + } }); function getPrefsPath(): string { @@ -1073,7 +1085,10 @@ describe("16. YAML source detection", () => { function makeYamlTestDir(suffix?: string): string { yamlCounter++; - const dir = join(tmpdir(), `tp-yaml-test-${Date.now()}-${yamlCounter}${suffix ? `-${suffix}` : ""}`); + const dir = join( + tmpdir(), + `tp-yaml-test-${Date.now()}-${yamlCounter}${suffix ? `-${suffix}` : ""}`, + ); mkdirSync(dir, { recursive: true }); return dir; } @@ -1087,10 +1102,18 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("json-only"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, PROJECT_CONFIG_FILENAME), JSON.stringify({ - configVersion: CONFIG_VERSION, - orchestrator: { orchestrator: { maxLanes: 5, spawnMode: "tmux" } }, - }, null, 2), "utf-8"); + writeFileSync( + join(piDir, PROJECT_CONFIG_FILENAME), + JSON.stringify( + { + configVersion: CONFIG_VERSION, + orchestrator: { orchestrator: { maxLanes: 5, spawnMode: "tmux" } }, + }, + null, + 2, + ), + "utf-8", + ); const raw = readRawProjectJson(dir); expect(raw).not.toBeNull(); @@ -1115,10 +1138,18 @@ describe("16. YAML source detection", () => { it("16.1.4 readRawProjectJson supports flat pointer layout", () => { const dir = makeYamlTestDir("json-flat"); - writeFileSync(join(dir, PROJECT_CONFIG_FILENAME), JSON.stringify({ - configVersion: CONFIG_VERSION, - orchestrator: { orchestrator: { maxLanes: 9 } }, - }, null, 2), "utf-8"); + writeFileSync( + join(dir, PROJECT_CONFIG_FILENAME), + JSON.stringify( + { + configVersion: CONFIG_VERSION, + orchestrator: { orchestrator: { maxLanes: 9 } }, + }, + null, + 2, + ), + "utf-8", + ); const raw = readRawProjectJson(dir); expect(raw).not.toBeNull(); @@ -1131,15 +1162,19 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("yaml-orch"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, "task-orchestrator.yaml"), [ - "orchestrator:", - " max_lanes: 7", - " spawn_mode: tmux", - " worktree_prefix: test-wt", - "failure:", - " stall_timeout: 60", - " on_task_failure: stop-all", - ].join("\n"), "utf-8"); + writeFileSync( + join(piDir, "task-orchestrator.yaml"), + [ + "orchestrator:", + " max_lanes: 7", + " spawn_mode: tmux", + " worktree_prefix: test-wt", + "failure:", + " stall_timeout: 60", + " on_task_failure: stop-all", + ].join("\n"), + "utf-8", + ); const raw = readRawYamlConfigs(dir); expect(raw).not.toBeNull(); @@ -1154,13 +1189,17 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("yaml-tr"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, "task-runner.yaml"), [ - "worker:", - " model: gpt-4", - "context:", - " worker_context_window: 200000", - " max_worker_iterations: 10", - ].join("\n"), "utf-8"); + writeFileSync( + join(piDir, "task-runner.yaml"), + [ + "worker:", + " model: gpt-4", + "context:", + " worker_context_window: 200000", + " max_worker_iterations: 10", + ].join("\n"), + "utf-8", + ); const raw = readRawYamlConfigs(dir); expect(raw).not.toBeNull(); @@ -1190,12 +1229,11 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("yaml-prewarm"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, "task-orchestrator.yaml"), [ - "pre_warm:", - " auto_detect: true", - " commands:", - " npm: npm install", - ].join("\n"), "utf-8"); + writeFileSync( + join(piDir, "task-orchestrator.yaml"), + ["pre_warm:", " auto_detect: true", " commands:", " npm: npm install"].join("\n"), + "utf-8", + ); const raw = readRawYamlConfigs(dir); expect(raw).not.toBeNull(); @@ -1207,13 +1245,13 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("yaml-assign"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, "task-orchestrator.yaml"), [ - "assignment:", - " strategy: round-robin", - " size_weights:", - " S: 1", - " M: 2", - ].join("\n"), "utf-8"); + writeFileSync( + join(piDir, "task-orchestrator.yaml"), + ["assignment:", " strategy: round-robin", " size_weights:", " S: 1", " M: 2"].join( + "\n", + ), + "utf-8", + ); const raw = readRawYamlConfigs(dir); expect(raw).not.toBeNull(); @@ -1223,10 +1261,11 @@ describe("16. YAML source detection", () => { it("16.2.7 readRawYamlConfigs supports flat pointer layout", () => { const dir = makeYamlTestDir("yaml-flat"); - writeFileSync(join(dir, "task-orchestrator.yaml"), [ - "orchestrator:", - " max_lanes: 11", - ].join("\n"), "utf-8"); + writeFileSync( + join(dir, "task-orchestrator.yaml"), + ["orchestrator:", " max_lanes: 11"].join("\n"), + "utf-8", + ); const raw = readRawYamlConfigs(dir); expect(raw).not.toBeNull(); @@ -1239,10 +1278,18 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("both"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, PROJECT_CONFIG_FILENAME), JSON.stringify({ - configVersion: CONFIG_VERSION, - orchestrator: { orchestrator: { maxLanes: 10 } }, - }, null, 2), "utf-8"); + writeFileSync( + join(piDir, PROJECT_CONFIG_FILENAME), + JSON.stringify( + { + configVersion: CONFIG_VERSION, + orchestrator: { orchestrator: { maxLanes: 10 } }, + }, + null, + 2, + ), + "utf-8", + ); writeFileSync(join(piDir, "task-orchestrator.yaml"), "orchestrator:\n max_lanes: 5\n", "utf-8"); const rawJson = readRawProjectJson(dir); @@ -1256,10 +1303,22 @@ describe("16. YAML source detection", () => { const dir = makeYamlTestDir("precedence"); const piDir = join(dir, ".pi"); mkdirSync(piDir, { recursive: true }); - writeFileSync(join(piDir, PROJECT_CONFIG_FILENAME), JSON.stringify({ - orchestrator: { orchestrator: { maxLanes: 10 } }, - }, null, 2), "utf-8"); - writeFileSync(join(piDir, "task-orchestrator.yaml"), "orchestrator:\n max_lanes: 5\n spawn_mode: tmux\n", "utf-8"); + writeFileSync( + join(piDir, PROJECT_CONFIG_FILENAME), + JSON.stringify( + { + orchestrator: { orchestrator: { maxLanes: 10 } }, + }, + null, + 2, + ), + "utf-8", + ); + writeFileSync( + join(piDir, "task-orchestrator.yaml"), + "orchestrator:\n max_lanes: 5\n spawn_mode: tmux\n", + "utf-8", + ); // Simulate the || fallback from loadConfigState const rawProject = readRawProjectJson(dir) || readRawYamlConfigs(dir); @@ -1299,7 +1358,6 @@ describe("16. YAML source detection", () => { }); }); - // ── 17.x Write-Decision Logic ──────────────────────────────────────── // Tests the extracted resolveWriteAction + getDefaultWriteDestination // functions that encapsulate the destination/confirmation decision tree @@ -1307,7 +1365,6 @@ describe("16. YAML source detection", () => { // tautological "file unchanged when we didn't write" assertions (R010 fix). describe("17. Write-decision logic (resolveWriteAction)", () => { - // 17.1 — getDefaultWriteDestination routing describe("17.1 getDefaultWriteDestination", () => { @@ -1413,7 +1470,9 @@ describe("17. Write-decision logic (resolveWriteAction)", () => { it("17.6.4 remove-project destination returns remove-project route", () => { const field = makeL1L2StringField(); - expect(resolveWriteAction(field, "Remove project override (revert to global)", true)).toBe("remove-project"); + expect(resolveWriteAction(field, "Remove project override (revert to global)", true)).toBe( + "remove-project", + ); }); }); @@ -1437,17 +1496,27 @@ describe("17. Write-decision logic (resolveWriteAction)", () => { } try { rmSync(zeroMutRoot, { recursive: true, force: true }); - } catch { /* best effort */ } + } catch { + /* best effort */ + } }); it("17.7.1 writeProjectConfigField with same value produces valid JSON (idempotent)", () => { const piDir = join(zeroMutRoot, ".pi"); mkdirSync(piDir, { recursive: true }); const configPath = join(piDir, PROJECT_CONFIG_FILENAME); - writeFileSync(configPath, JSON.stringify({ - configVersion: CONFIG_VERSION, - orchestrator: { orchestrator: { maxLanes: 3 } }, - }, null, 2), "utf-8"); + writeFileSync( + configPath, + JSON.stringify( + { + configVersion: CONFIG_VERSION, + orchestrator: { orchestrator: { maxLanes: 3 } }, + }, + null, + 2, + ), + "utf-8", + ); writeProjectConfigField(zeroMutRoot, "orchestrator.orchestrator.maxLanes", 3); @@ -1482,7 +1551,6 @@ describe("17. Write-decision logic (resolveWriteAction)", () => { }); }); - // ── 18.x Advanced Section Discoverability ──────────────────────────── // Verifies that uncovered/new fields appear in the Advanced section, // ensuring the "immediately discoverable" completion criterion (R009 item 3). @@ -1517,7 +1585,7 @@ describe("18. Advanced section discoverability", () => { it("18.3 getAdvancedItems surfaces collection/Record fields", () => { const config = cloneConfig(); // Add some data to collection fields so they appear - config.taskRunner.testing = { commands: { "test": "npm test" } }; + config.taskRunner.testing = { commands: { test: "npm test" } }; config.taskRunner.standards = { docs: ["README.md"], rules: ["rule1"] }; config.taskRunner.neverLoad = ["node_modules"]; config.orchestrator.merge.verify = ["lint"]; @@ -1547,7 +1615,7 @@ describe("18. Advanced section discoverability", () => { it("18.5 Advanced item values are summarized correctly", () => { const config = cloneConfig(); config.taskRunner.neverLoad = ["node_modules", ".git", "dist"]; - config.taskRunner.testing = { commands: { "test": "npm test", "lint": "npm run lint" } }; + config.taskRunner.testing = { commands: { test: "npm test", lint: "npm run lint" } }; const items = getAdvancedItems(config); @@ -1614,7 +1682,9 @@ describe("19. model-change thinking suggestion helpers", () => { it("19.1 modelSupportsThinking detects boolean, nested, and string thinking flags", () => { expect(modelSupportsThinking({ supportsThinking: true })).toBe(true); - expect(modelSupportsThinking({ capabilities: { reasoningEffort: ["low", "medium"] } })).toBe(true); + expect(modelSupportsThinking({ capabilities: { reasoningEffort: ["low", "medium"] } })).toBe( + true, + ); expect(modelSupportsThinking({ thinking: "yes" })).toBe(true); expect(modelSupportsThinking({ thinking: "no" })).toBe(false); expect(modelSupportsThinking({ id: "plain-model" })).toBe(false); diff --git a/extensions/tests/sidecar-tailing.test.ts b/extensions/tests/sidecar-tailing.test.ts index 78962dbe..ac030d52 100644 --- a/extensions/tests/sidecar-tailing.test.ts +++ b/extensions/tests/sidecar-tailing.test.ts @@ -32,18 +32,20 @@ beforeEach(() => { }); afterEach(() => { - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); /** Append JSONL events to the sidecar file */ function appendEvents(...events: object[]): void { - const content = events.map(e => JSON.stringify(e) + "\n").join(""); + const content = events.map((e) => JSON.stringify(e) + "\n").join(""); appendFileSync(sidecarPath, content); } /** Create the sidecar file with initial events */ function writeEvents(...events: object[]): void { - const content = events.map(e => JSON.stringify(e) + "\n").join(""); + const content = events.map((e) => JSON.stringify(e) + "\n").join(""); writeFileSync(sidecarPath, content); } @@ -212,9 +214,7 @@ describe("tailSidecarJsonl — incremental reading", () => { expect(delta2.hadEvents).toBe(false); // Tick 3: append 1 new event - appendEvents( - { type: "message_end", message: { usage: { input: 200, output: 80, cost: 0.02 } } }, - ); + appendEvents({ type: "message_end", message: { usage: { input: 200, output: 80, cost: 0.02 } } }); const delta3 = tailSidecarJsonl(sidecarPath, state); expect(delta3.inputTokens).toBe(200); expect(delta3.outputTokens).toBe(80); @@ -302,9 +302,7 @@ describe("tailSidecarJsonl — retry state persistence", () => { expect(d1.retriesStarted).toBe(1); // Tick 2: unrelated events during retry - appendEvents( - { type: "message_end", message: { usage: { input: 100, output: 50, cost: 0.01 } } }, - ); + appendEvents({ type: "message_end", message: { usage: { input: 100, output: 50, cost: 0.01 } } }); const d2 = tailSidecarJsonl(sidecarPath, state); expect(d2.retryActive).toBe(true); // still active expect(d2.retriesStarted).toBe(0); // no new retries @@ -408,13 +406,16 @@ describe("tailSidecarJsonl — partial-line buffering", () => { describe("tailSidecarJsonl — malformed lines", () => { it("skips malformed JSON lines without breaking", () => { const state = createSidecarTailState(); - writeFileSync(sidecarPath, [ - JSON.stringify({ type: "agent_start" }), - "this is not JSON", - JSON.stringify({ type: "message_end", message: { usage: { input: 100, output: 50 } } }), - "{malformed json", - JSON.stringify({ type: "tool_execution_start", toolName: "read", args: { path: "f.ts" } }), - ].join("\n") + "\n"); + writeFileSync( + sidecarPath, + [ + JSON.stringify({ type: "agent_start" }), + "this is not JSON", + JSON.stringify({ type: "message_end", message: { usage: { input: 100, output: 50 } } }), + "{malformed json", + JSON.stringify({ type: "tool_execution_start", toolName: "read", args: { path: "f.ts" } }), + ].join("\n") + "\n", + ); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.hadEvents).toBe(true); @@ -436,12 +437,10 @@ describe("tailSidecarJsonl — malformed lines", () => { it("skips empty and whitespace-only lines", () => { const state = createSidecarTailState(); - writeFileSync(sidecarPath, [ - "", - " ", - JSON.stringify({ type: "agent_start" }), - "", - ].join("\n") + "\n"); + writeFileSync( + sidecarPath, + ["", " ", JSON.stringify({ type: "agent_start" }), ""].join("\n") + "\n", + ); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.hadEvents).toBe(true); @@ -455,9 +454,7 @@ describe("tailSidecarJsonl — final tail scenarios", () => { const state = createSidecarTailState(); // Tick 1: initial events - writeEvents( - { type: "message_end", message: { usage: { input: 100, output: 50, cost: 0.01 } } }, - ); + writeEvents({ type: "message_end", message: { usage: { input: 100, output: 50, cost: 0.01 } } }); tailSidecarJsonl(sidecarPath, state); // Events written between last tick and session exit @@ -581,9 +578,7 @@ describe("tailSidecarJsonl — poll loop integration simulation", () => { expect(totalToolCalls).toBe(1); // Tick 2: retry starts - appendEvents( - { type: "auto_retry_start", attempt: 1, errorMessage: "rate_limit", delayMs: 5000 }, - ); + appendEvents({ type: "auto_retry_start", attempt: 1, errorMessage: "rate_limit", delayMs: 5000 }); delta = tailSidecarJsonl(sidecarPath, state); if (delta.hadEvents) onTelemetry(delta); expect(retryActive).toBe(true); @@ -622,11 +617,13 @@ describe("tailSidecarJsonl — contextUsage from get_session_stats (pi ≥ 0.63. it("extracts contextUsage.percent from response event (TP-094 fix)", () => { const state = createSidecarTailState(); // Pi sends `percent` (not `percentUsed`) in contextUsage - writeEvents( - { type: "response", success: true, data: { + writeEvents({ + type: "response", + success: true, + data: { contextUsage: { percent: 42.5, tokens: 425000, contextWindow: 1000000 }, - }}, - ); + }, + }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage).not.toBe(null); expect(delta.contextUsage!.percent).toBe(42.5); @@ -637,11 +634,13 @@ describe("tailSidecarJsonl — contextUsage from get_session_stats (pi ≥ 0.63. it("accepts legacy percentUsed as backward-compatible fallback", () => { const state = createSidecarTailState(); // Hypothetical older format with percentUsed - writeEvents( - { type: "response", success: true, data: { + writeEvents({ + type: "response", + success: true, + data: { contextUsage: { percentUsed: 55.0, totalTokens: 550000, maxTokens: 1000000 }, - }}, - ); + }, + }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage).not.toBe(null); expect(delta.contextUsage!.percent).toBe(55.0); @@ -651,29 +650,27 @@ describe("tailSidecarJsonl — contextUsage from get_session_stats (pi ≥ 0.63. it("prefers percent over percentUsed when both present", () => { const state = createSidecarTailState(); - writeEvents( - { type: "response", success: true, data: { + writeEvents({ + type: "response", + success: true, + data: { contextUsage: { percent: 60.0, percentUsed: 59.0, totalTokens: 600000, maxTokens: 1000000 }, - }}, - ); + }, + }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage!.percent).toBe(60.0); }); it("contextUsage is null when response has no contextUsage (older pi)", () => { const state = createSidecarTailState(); - writeEvents( - { type: "response", success: true, data: {} }, - ); + writeEvents({ type: "response", success: true, data: {} }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage).toBe(null); }); it("sets sawStatsResponseWithoutContextUsage when response lacks it", () => { const state = createSidecarTailState(); - writeEvents( - { type: "response", success: true, data: { sessionId: "abc" } }, - ); + writeEvents({ type: "response", success: true, data: { sessionId: "abc" } }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage).toBe(null); expect(delta.sawStatsResponseWithoutContextUsage).toBe(true); @@ -681,9 +678,7 @@ describe("tailSidecarJsonl — contextUsage from get_session_stats (pi ≥ 0.63. it("does not set sawStatsResponseWithoutContextUsage on error response", () => { const state = createSidecarTailState(); - writeEvents( - { type: "response", success: false, error: "something broke" }, - ); + writeEvents({ type: "response", success: false, error: "something broke" }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage).toBe(null); expect(delta.sawStatsResponseWithoutContextUsage).toBe(false); @@ -691,9 +686,7 @@ describe("tailSidecarJsonl — contextUsage from get_session_stats (pi ≥ 0.63. it("contextUsage is null when response is an error", () => { const state = createSidecarTailState(); - writeEvents( - { type: "response", success: false, error: "something broke" }, - ); + writeEvents({ type: "response", success: false, error: "something broke" }); const delta = tailSidecarJsonl(sidecarPath, state); expect(delta.contextUsage).toBe(null); }); @@ -703,9 +696,13 @@ describe("tailSidecarJsonl — contextUsage from get_session_stats (pi ≥ 0.63. // message_end gives manual tokens AND response gives authoritative contextUsage writeEvents( { type: "message_end", message: { usage: { input: 100, output: 50, totalTokens: 150 } } }, - { type: "response", success: true, data: { - contextUsage: { percent: 87.3, tokens: 873000, contextWindow: 1000000 }, - }}, + { + type: "response", + success: true, + data: { + contextUsage: { percent: 87.3, tokens: 873000, contextWindow: 1000000 }, + }, + }, ); const delta = tailSidecarJsonl(sidecarPath, state); // Both should be present — consumer uses authoritative percent diff --git a/extensions/tests/skip-progress-preservation.test.ts b/extensions/tests/skip-progress-preservation.test.ts index 4925d771..fd1a597b 100644 --- a/extensions/tests/skip-progress-preservation.test.ts +++ b/extensions/tests/skip-progress-preservation.test.ts @@ -30,7 +30,9 @@ describe("TP-171: skipped artifact lane detection in mergeWave", () => { // Verify skipped lanes use restricted allowlist (no .DONE) expect(mergeSource).toContain("SKIPPED_ARTIFACT_NAMES"); - expect(mergeSource).toContain('const SKIPPED_ARTIFACT_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"]'); + expect(mergeSource).toContain( + 'const SKIPPED_ARTIFACT_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"]', + ); }); it("skipped artifact allowlist excludes .DONE to prevent false completion", () => { @@ -54,7 +56,9 @@ describe("TP-171: skipped artifact lane detection in mergeWave", () => { ); // artifactStagingLanes should combine orderedLanes + skippedArtifactLanes - expect(mergeSource).toContain("const artifactStagingLanes = [...orderedLanes, ...skippedArtifactLanes]"); + expect(mergeSource).toContain( + "const artifactStagingLanes = [...orderedLanes, ...skippedArtifactLanes]", + ); }); it("artifact staging uses per-lane allowlist based on lane type", () => { @@ -209,45 +213,69 @@ describe("TP-171: batch history with mixed task statuses", () => { tokens: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, costUsd: 0.05 }, tasks: [ { - taskId: "TP-001", taskName: "TP-001", status: "succeeded", - wave: 1, lane: 1, durationMs: 500, + taskId: "TP-001", + taskName: "TP-001", + status: "succeeded", + wave: 1, + lane: 1, + durationMs: 500, tokens: { input: 5, output: 10, cacheRead: 0, cacheWrite: 0, costUsd: 0.02 }, exitReason: null, }, { - taskId: "TP-002", taskName: "TP-002", status: "failed", - wave: 1, lane: 2, durationMs: 300, + taskId: "TP-002", + taskName: "TP-002", + status: "failed", + wave: 1, + lane: 2, + durationMs: 300, tokens: { input: 3, output: 5, cacheRead: 0, cacheWrite: 0, costUsd: 0.01 }, exitReason: "Task crashed", }, { - taskId: "TP-003", taskName: "TP-003", status: "skipped", - wave: 1, lane: 2, durationMs: 0, + taskId: "TP-003", + taskName: "TP-003", + status: "skipped", + wave: 1, + lane: 2, + durationMs: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, exitReason: "Skipped by stop-wave policy", }, { - taskId: "TP-004", taskName: "TP-004", status: "blocked", - wave: 2, lane: 0, durationMs: 0, + taskId: "TP-004", + taskName: "TP-004", + status: "blocked", + wave: 2, + lane: 0, + durationMs: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 }, exitReason: "Blocked by upstream failure", }, { - taskId: "TP-005", taskName: "TP-005", status: "pending", - wave: 2, lane: 0, durationMs: 0, + taskId: "TP-005", + taskName: "TP-005", + status: "pending", + wave: 2, + lane: 0, + durationMs: 0, tokens: { input: 2, output: 5, cacheRead: 0, cacheWrite: 0, costUsd: 0.02 }, exitReason: null, }, ], waves: [ { - wave: 1, tasks: ["TP-001", "TP-002", "TP-003"], - mergeStatus: "succeeded", durationMs: 500, + wave: 1, + tasks: ["TP-001", "TP-002", "TP-003"], + mergeStatus: "succeeded", + durationMs: 500, tokens: { input: 8, output: 15, cacheRead: 0, cacheWrite: 0, costUsd: 0.03 }, }, { - wave: 2, tasks: ["TP-004", "TP-005"], - mergeStatus: "skipped", durationMs: 0, + wave: 2, + tasks: ["TP-004", "TP-005"], + mergeStatus: "skipped", + durationMs: 0, tokens: { input: 2, output: 5, cacheRead: 0, cacheWrite: 0, costUsd: 0.02 }, }, ], @@ -260,7 +288,7 @@ describe("TP-171: batch history with mixed task statuses", () => { expect(loaded[0].tasks).toHaveLength(5); // Verify all statuses preserved - const statuses = loaded[0].tasks.map(t => t.status); + const statuses = loaded[0].tasks.map((t) => t.status); expect(statuses).toContain("succeeded"); expect(statuses).toContain("failed"); expect(statuses).toContain("skipped"); @@ -268,12 +296,12 @@ describe("TP-171: batch history with mixed task statuses", () => { expect(statuses).toContain("pending"); // Verify skipped task has correct metadata - const skipped = loaded[0].tasks.find(t => t.taskId === "TP-003")!; + const skipped = loaded[0].tasks.find((t) => t.taskId === "TP-003")!; expect(skipped.status).toBe("skipped"); expect(skipped.exitReason).toBe("Skipped by stop-wave policy"); // Verify blocked task has correct metadata - const blocked = loaded[0].tasks.find(t => t.taskId === "TP-004")!; + const blocked = loaded[0].tasks.find((t) => t.taskId === "TP-004")!; expect(blocked.status).toBe("blocked"); expect(blocked.lane).toBe(0); // never allocated } finally { diff --git a/extensions/tests/spawn-failure-visibility.test.ts b/extensions/tests/spawn-failure-visibility.test.ts index 22cf6ea7..ab54eddd 100644 --- a/extensions/tests/spawn-failure-visibility.test.ts +++ b/extensions/tests/spawn-failure-visibility.test.ts @@ -79,7 +79,9 @@ mock.module("../taskplane/lane-runner.ts", { const { executeLaneV2 } = await import("../taskplane/execution.ts"); const { EXIT_CLASSIFICATIONS } = await import("../taskplane/diagnostics.ts"); const { TIER0_RETRYABLE_CLASSIFICATIONS } = await import("../taskplane/types.ts"); -const { isAllLanesSpawnFailedWave, buildSpawnFailureAlertExtras } = await import("../taskplane/engine.ts"); +const { isAllLanesSpawnFailedWave, buildSpawnFailureAlertExtras } = await import( + "../taskplane/engine.ts" +); type MockLaneTaskOutcome = { taskId: string; @@ -116,11 +118,7 @@ function makeTempRoot(prefix: string): string { } /** Build a minimal AllocatedLane backed by real temp directories. */ -function buildFakeAllocatedLane(opts: { - repoRoot: string; - laneNumber: number; - taskId: string; -}) { +function buildFakeAllocatedLane(opts: { repoRoot: string; laneNumber: number; taskId: string }) { const taskFolder = join(opts.repoRoot, "tasks", opts.taskId); mkdirSync(taskFolder, { recursive: true }); writeFileSync( @@ -224,7 +222,11 @@ describe("TP-190 #561: executeLaneV2 catch behavior on spawn failure", () => { }); afterEach(() => { - try { rmSync(repoRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(repoRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } }); it("1.1: produces a failed LaneTaskOutcome tagged with classification='spawn_failure'", async () => { @@ -259,15 +261,10 @@ describe("TP-190 #561: executeLaneV2 catch behavior on spawn failure", () => { const config = buildFakeOrchestratorConfig(); const pauseSignal = { paused: false }; - await executeLaneV2( - lane as any, - config as any, - repoRoot, - pauseSignal, - undefined, - false, - { ORCH_BATCH_ID: batchId, TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous" }, - ); + await executeLaneV2(lane as any, config as any, repoRoot, pauseSignal, undefined, false, { + ORCH_BATCH_ID: batchId, + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }); const snapshotPath = join(repoRoot, ".pi", "runtime", batchId, "lanes", "lane-1.json"); expect(existsSync(snapshotPath)).toBe(true); @@ -286,15 +283,10 @@ describe("TP-190 #561: executeLaneV2 catch behavior on spawn failure", () => { const config = buildFakeOrchestratorConfig(); const pauseSignal = { paused: false }; - await executeLaneV2( - lane as any, - config as any, - repoRoot, - pauseSignal, - undefined, - false, - { ORCH_BATCH_ID: batchId, TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous" }, - ); + await executeLaneV2(lane as any, config as any, repoRoot, pauseSignal, undefined, false, { + ORCH_BATCH_ID: batchId, + TASKPLANE_SUPERVISOR_AUTONOMY: "autonomous", + }); // executeLaneV2 must call executeTaskV2 exactly once for this task — // no internal retry loop on spawn errors. Engine-level retry is also @@ -385,7 +377,7 @@ describe("TP-190 #561: spawn_failure registered as a non-retryable ExitClassific }); it("2.4: diagnostics.ts ExitClassification doc table mentions spawn_failure with TP-190 rationale", () => { - expect(diagnosticsSrc).toContain('| `spawn_failure`'); + expect(diagnosticsSrc).toContain("| `spawn_failure`"); expect(diagnosticsSrc).toContain("TP-190"); }); }); @@ -507,10 +499,7 @@ describe("TP-190 #561: engine.ts isAllLanesSpawnFailedWave (behavioral)", () => const waveResult = { failedTaskIds: ["TP-1", "TP-2"], succeededTaskIds: [] as string[], - laneResults: [ - { tasks: [{ status: "failed" }] }, - { tasks: [{ status: "failed" }] }, - ], + laneResults: [{ tasks: [{ status: "failed" }] }, { tasks: [{ status: "failed" }] }], }; const outcomes = [ makeOutcome("TP-1", "failed", "spawn_failure"), @@ -597,11 +586,12 @@ describe("TP-190 #561: engine.ts wire-up for spawn_failure", () => { // expected side effects (phase transition + persist + terminal + break). const phaseIdx = engineSrc.indexOf("allFailedAreSpawnFailures"); expect(phaseIdx).toBeGreaterThan(-1); - const phaseBlock = engineSrc.slice(phaseIdx, phaseIdx + 2000); - expect(phaseBlock).toContain("isAllLanesSpawnFailedWave(waveResult, allTaskOutcomes)"); + // TP-193: Window increased from 2000 to 3500 to absorb formatter re-wrapping. + const phaseBlock = engineSrc.slice(phaseIdx, phaseIdx + 3500); + expect(phaseBlock).toContainNormalized("isAllLanesSpawnFailedWave(waveResult, allTaskOutcomes)"); expect(phaseBlock).toContain('batchState.phase = "failed"'); // Persist + terminal event + break out of wave loop. - expect(phaseBlock).toContain("persistRuntimeState(\"wave-spawn-failure\""); + expect(phaseBlock).toContainNormalized('persistRuntimeState("wave-spawn-failure"'); expect(phaseBlock).toContain("emitTerminalEvent("); expect(phaseBlock).toContain("break;"); }); @@ -611,7 +601,8 @@ describe("TP-190 #561: engine.ts wire-up for spawn_failure", () => { // fix the underlying cause first. The PROMPT explicitly chose // 'failed' for this reason. const phaseIdx = engineSrc.indexOf("allFailedAreSpawnFailures"); - const phaseBlock = engineSrc.slice(phaseIdx, phaseIdx + 2000); + // TP-193: Window increased from 2000 to 3500 to absorb formatter re-wrapping. + const phaseBlock = engineSrc.slice(phaseIdx, phaseIdx + 3500); // 'paused' must not be the destination phase here. expect(phaseBlock).not.toContain('batchState.phase = "paused"'); }); @@ -643,7 +634,7 @@ describe("TP-190 #561: execution.ts catch hardening", () => { it("5.2: catch writes a synthetic terminal RuntimeLaneSnapshot via writeLaneSnapshot", () => { expect(executionSrc).toContain("spawnFailureSnapshot"); - expect(executionSrc).toContain("writeLaneSnapshot(stateRoot, batchId, lane.laneNumber"); + expect(executionSrc).toContainNormalized("writeLaneSnapshot(stateRoot, batchId, lane.laneNumber"); }); it("5.3: synthetic snapshot uses status='failed' so monitorLanes Priority 3 fires", () => { @@ -706,7 +697,11 @@ describe("TP-190 #561: integrated post-wave behavior on all-spawn-failed wave", }); afterEach(() => { - try { rmSync(repoRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(repoRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } }); it("6.1: three-lane wave — all spawn-fail → batchState.phase=failed, failedTasks counter, IPC alerts with exitCategory='spawn_failure'", async () => { @@ -739,7 +734,9 @@ describe("TP-190 #561: integrated post-wave behavior on all-spawn-failed wave", const succeededTaskIds: string[] = []; const waveResult = { failedTaskIds, succeededTaskIds, blockedTaskIds: [] as string[] }; - expect(allTaskOutcomes.every((t) => t.exitDiagnostic?.classification === "spawn_failure")).toBe(true); + expect(allTaskOutcomes.every((t) => t.exitDiagnostic?.classification === "spawn_failure")).toBe( + true, + ); // (c) Reproduce the engine's post-wave bookkeeping against a real // OrchBatchRuntimeState shape. This mirrors engine.ts:3105-3175. @@ -788,10 +785,7 @@ describe("TP-190 #561: integrated post-wave behavior on all-spawn-failed wave", } // engine.ts post-TP-190 — phase-transition decision via the helper. - const allFailedAreSpawnFailures = isAllLanesSpawnFailedWave( - waveResult, - allTaskOutcomes as any, - ); + const allFailedAreSpawnFailures = isAllLanesSpawnFailedWave(waveResult, allTaskOutcomes as any); if (allFailedAreSpawnFailures) { batchState.phase = "failed"; } diff --git a/extensions/tests/stale-branch-cleanup.integration.test.ts b/extensions/tests/stale-branch-cleanup.integration.test.ts index d28432c4..d629655b 100644 --- a/extensions/tests/stale-branch-cleanup.integration.test.ts +++ b/extensions/tests/stale-branch-cleanup.integration.test.ts @@ -19,7 +19,12 @@ import { deleteStaleBranches } from "../taskplane/worktree.ts"; import type { StaleBranchCleanupResult } from "../taskplane/worktree.ts"; import { runGit } from "../taskplane/git.ts"; import { syncTaskOutcomesFromMonitor } from "../taskplane/persistence.ts"; -import type { LaneTaskOutcome, MonitorState, TaskMonitorSnapshot, LaneMonitorSnapshot } from "../taskplane/types.ts"; +import type { + LaneTaskOutcome, + MonitorState, + TaskMonitorSnapshot, + LaneMonitorSnapshot, +} from "../taskplane/types.ts"; // ── Helpers ─────────────────────────────────────────────────────────── @@ -42,7 +47,10 @@ function branchExists(repoRoot: string, branchName: string): boolean { function listBranches(repoRoot: string, pattern: string): string[] { const result = runGit(["branch", "--list", pattern], repoRoot); if (!result.ok || !result.stdout.trim()) return []; - return result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean); + return result.stdout + .split("\n") + .map((b) => b.replace(/^\*?\s+/, "").trim()) + .filter(Boolean); } // ── deleteStaleBranches Tests ──────────────────────────────────────── @@ -55,7 +63,11 @@ describe("deleteStaleBranches — TP-051", () => { }); afterEach(() => { - try { rmSync(repoRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(repoRoot, { recursive: true, force: true }); + } catch { + /* best effort */ + } }); it("deletes task/{opId}-lane-* branches for the operator", () => { @@ -213,7 +225,7 @@ describe("syncTaskOutcomesFromMonitor — TP-051 task startedAt fix", () => { taskId: "TP-001", status: "running", lastHeartbeat: staleStatusMtime, // STATUS.md mtime — stale - observedAt: now, // actual poll time + observedAt: now, // actual poll time }); syncTaskOutcomesFromMonitor(monitor, outcomes); @@ -257,15 +269,17 @@ describe("syncTaskOutcomesFromMonitor — TP-051 task startedAt fix", () => { const monitorObserved = 510000; // Pre-populated outcome from executeLane (has a real startTime) - const outcomes: LaneTaskOutcome[] = [{ - taskId: "TP-001", - status: "running", - startTime: executionStartTime, - endTime: null, - exitReason: "Task in progress", - sessionName: "orch-lane-1", - doneFileFound: false, - }]; + const outcomes: LaneTaskOutcome[] = [ + { + taskId: "TP-001", + status: "running", + startTime: executionStartTime, + endTime: null, + exitReason: "Task in progress", + sessionName: "orch-lane-1", + doneFileFound: false, + }, + ]; const monitor = makeMonitorWithCurrentTask({ taskId: "TP-001", diff --git a/extensions/tests/state-migration.test.ts b/extensions/tests/state-migration.test.ts index 177e6c0b..b9697731 100644 --- a/extensions/tests/state-migration.test.ts +++ b/extensions/tests/state-migration.test.ts @@ -61,25 +61,29 @@ function makeValidV4(): Record { currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - laneSessionId: "orch-lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1-20260319T010000", - taskIds: ["TP-001"], - }], - tasks: [{ - taskId: "TP-001", - laneNumber: 1, - sessionName: "orch-lane-1", - status: "running", - taskFolder: "/tmp/tasks/TP-001", - startedAt: 1741478400000, - endedAt: null, - doneFileFound: false, - exitReason: "", - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1-20260319T010000", + taskIds: ["TP-001"], + }, + ], + tasks: [ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-lane-1", + status: "running", + taskFolder: "/tmp/tasks/TP-001", + startedAt: 1741478400000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, @@ -132,7 +136,6 @@ function makeValidV1(): Record { // ═════════════════════════════════════════════════════════════════════ describe("State Schema v3 Migration", () => { - describe("v1 → v3 migration", () => { it("migrates v1 fixture to v3 with correct defaults", () => { const v1Data = loadFixtureJSON("batch-state-v1-valid.json"); @@ -257,24 +260,26 @@ describe("State Schema v3 Migration", () => { resumeForced: true, retryCountByScope: { "TP-001:w0:l1": 2 }, lastFailureClass: "context-overflow", - repairHistory: [{ - id: "r-20260319-001", - strategy: "stale-worktree-cleanup", - status: "succeeded", - startedAt: 1000, - endedAt: 2000, - }], + repairHistory: [ + { + id: "r-20260319-001", + strategy: "stale-worktree-cleanup", + status: "succeeded", + startedAt: 1000, + endedAt: 2000, + }, + ], }; v3.diagnostics = { taskExits: { "TP-001": { classification: "context-overflow", - cost: 1.50, + cost: 1.5, durationSec: 120, retries: 1, }, }, - batchCost: 1.50, + batchCost: 1.5, }; const result = validatePersistedState(v3); @@ -285,8 +290,8 @@ describe("State Schema v3 Migration", () => { expect(result.resilience.repairHistory).toHaveLength(1); expect(result.resilience.repairHistory[0].strategy).toBe("stale-worktree-cleanup"); expect(result.diagnostics.taskExits["TP-001"].classification).toBe("context-overflow"); - expect(result.diagnostics.taskExits["TP-001"].cost).toBe(1.50); - expect(result.diagnostics.batchCost).toBe(1.50); + expect(result.diagnostics.taskExits["TP-001"].cost).toBe(1.5); + expect(result.diagnostics.batchCost).toBe(1.5); }); it("reads v3 state with exitDiagnostic on task records", () => { @@ -388,40 +393,46 @@ describe("State Schema v3 Migration", () => { it("rejects repairHistory entry with invalid status", () => { const v3 = makeValidV3(); - (v3.resilience as any).repairHistory = [{ - id: "r-001", - strategy: "test", - status: "exploded", // invalid - startedAt: 1000, - endedAt: 2000, - }]; + (v3.resilience as any).repairHistory = [ + { + id: "r-001", + strategy: "test", + status: "exploded", // invalid + startedAt: 1000, + endedAt: 2000, + }, + ]; expect(() => validatePersistedState(v3)).toThrow(/repairHistory/); }); it("rejects repairHistory entry with non-number startedAt", () => { const v3 = makeValidV3(); - (v3.resilience as any).repairHistory = [{ - id: "r-001", - strategy: "test", - status: "succeeded", - startedAt: "now", - endedAt: 2000, - }]; + (v3.resilience as any).repairHistory = [ + { + id: "r-001", + strategy: "test", + status: "succeeded", + startedAt: "now", + endedAt: 2000, + }, + ]; expect(() => validatePersistedState(v3)).toThrow(/repairHistory/); }); it("rejects repairHistory entry with non-string repoId", () => { const v3 = makeValidV3(); - (v3.resilience as any).repairHistory = [{ - id: "r-001", - strategy: "test", - status: "succeeded", - startedAt: 1000, - endedAt: 2000, - repoId: 42, - }]; + (v3.resilience as any).repairHistory = [ + { + id: "r-001", + strategy: "test", + status: "succeeded", + startedAt: 1000, + endedAt: 2000, + repoId: 42, + }, + ]; expect(() => validatePersistedState(v3)).toThrow(/repairHistory/); }); @@ -783,14 +794,16 @@ describe("State Schema v3 Migration", () => { describe("edge cases", () => { it("accepts repairHistory entry with optional repoId", () => { const v3 = makeValidV3(); - (v3.resilience as any).repairHistory = [{ - id: "r-001", - strategy: "stale-worktree-cleanup", - status: "succeeded", - startedAt: 1000, - endedAt: 2000, - repoId: "api", - }]; + (v3.resilience as any).repairHistory = [ + { + id: "r-001", + strategy: "stale-worktree-cleanup", + status: "succeeded", + startedAt: 1000, + endedAt: 2000, + repoId: "api", + }, + ]; const result = validatePersistedState(v3); expect(result.resilience.repairHistory[0].repoId).toBe("api"); @@ -819,13 +832,15 @@ describe("State Schema v3 Migration", () => { it("accepts valid repairHistory statuses: succeeded, failed, skipped", () => { for (const status of ["succeeded", "failed", "skipped"]) { const v3 = makeValidV3(); - (v3.resilience as any).repairHistory = [{ - id: `r-${status}`, - strategy: "test", - status, - startedAt: 1000, - endedAt: 2000, - }]; + (v3.resilience as any).repairHistory = [ + { + id: `r-${status}`, + strategy: "test", + status, + startedAt: 1000, + endedAt: 2000, + }, + ]; const result = validatePersistedState(v3); expect(result.resilience.repairHistory[0].status).toBe(status); @@ -906,12 +921,15 @@ describe("State Schema v3 Migration", () => { laneSessionId: lr.laneSessionId, worktreePath: lr.worktreePath, branch: lr.branch, - tasks: lr.taskIds.map((taskId, i) => ({ - taskId, - order: i, - task: { ...dummyParsedTask, taskId }, - estimatedMinutes: 10, - } as AllocatedTask)), + tasks: lr.taskIds.map( + (taskId, i) => + ({ + taskId, + order: i, + task: { ...dummyParsedTask, taskId }, + estimatedMinutes: 10, + }) as AllocatedTask, + ), strategy: "round-robin" as const, estimatedLoad: 1, estimatedMinutes: 10, @@ -926,8 +944,12 @@ describe("State Schema v3 Migration", () => { sessionName: tr.sessionName, doneFileFound: tr.doneFileFound, ...(tr.exitDiagnostic ? { exitDiagnostic: tr.exitDiagnostic } : {}), - ...(tr.partialProgressCommits !== undefined ? { partialProgressCommits: tr.partialProgressCommits } : {}), - ...(tr.partialProgressBranch !== undefined ? { partialProgressBranch: tr.partialProgressBranch } : {}), + ...(tr.partialProgressCommits !== undefined + ? { partialProgressCommits: tr.partialProgressCommits } + : {}), + ...(tr.partialProgressBranch !== undefined + ? { partialProgressBranch: tr.partialProgressBranch } + : {}), })); const runtimeState: OrchBatchRuntimeState = { @@ -935,7 +957,7 @@ describe("State Schema v3 Migration", () => { batchId: persisted.batchId, baseBranch: persisted.baseBranch, orchBranch: persisted.orchBranch ?? "", - mode: persisted.mode as any ?? "repo", + mode: (persisted.mode as any) ?? "repo", pauseSignal: { paused: false }, waveResults: [], currentWaveIndex: persisted.currentWaveIndex, @@ -1047,19 +1069,21 @@ describe("State Schema v3 Migration", () => { resumeForced: true, retryCountByScope: { "TP-001:w0:l1": 3 }, lastFailureClass: "tool-error", - repairHistory: [{ - id: "r-001", - strategy: "stale-worktree-cleanup", - status: "succeeded", - startedAt: 1000, - endedAt: 2000, - }], + repairHistory: [ + { + id: "r-001", + strategy: "stale-worktree-cleanup", + status: "succeeded", + startedAt: 1000, + endedAt: 2000, + }, + ], }; v3.diagnostics = { taskExits: { - "TP-001": { classification: "tool-error", cost: 2.50, durationSec: 180, retries: 3 }, + "TP-001": { classification: "tool-error", cost: 2.5, durationSec: 180, retries: 3 }, }, - batchCost: 2.50, + batchCost: 2.5, }; const validated = validatePersistedState(v3); @@ -1075,12 +1099,12 @@ describe("State Schema v3 Migration", () => { // Diagnostics survives expect(reParsed.diagnostics.taskExits["TP-001"].classification).toBe("tool-error"); - expect(reParsed.diagnostics.batchCost).toBe(2.50); + expect(reParsed.diagnostics.batchCost).toBe(2.5); // Re-validate const reValidated = validatePersistedState(reParsed); expect(reValidated.resilience.resumeForced).toBe(true); - expect(reValidated.diagnostics.batchCost).toBe(2.50); + expect(reValidated.diagnostics.batchCost).toBe(2.5); }); }); diff --git a/extensions/tests/status-reconciliation.test.ts b/extensions/tests/status-reconciliation.test.ts index 2c3b8d60..0c164f53 100644 --- a/extensions/tests/status-reconciliation.test.ts +++ b/extensions/tests/status-reconciliation.test.ts @@ -26,7 +26,6 @@ import { // ── Fixture Helpers ────────────────────────────────────────────────── - const __dirname = dirname(fileURLToPath(import.meta.url)); let testRoot: string; let counter = 0; @@ -65,7 +64,11 @@ beforeEach(() => { }); afterEach(() => { - try { rmSync(testRoot, { recursive: true, force: true }); } catch { /* ignore */ } + try { + rmSync(testRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); // ══════════════════════════════════════════════════════════════════════ @@ -75,11 +78,10 @@ afterEach(() => { describe("1.x: Reconciliation happy path", () => { it("1.1: checked→unchecked for not_done", () => { const dir = makeTestDir("uncheck"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Implement feature A", - "- [x] Write tests", - ].join("\n")); + const statusPath = writeStatus( + dir, + ["# Status", "- [x] Implement feature A", "- [x] Write tests"].join("\n"), + ); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature A", "not_done", "No code changes found"), @@ -97,11 +99,10 @@ describe("1.x: Reconciliation happy path", () => { it("1.2: unchecked→checked for done", () => { const dir = makeTestDir("check"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [ ] Implement feature B", - "- [ ] Run tests", - ].join("\n")); + const statusPath = writeStatus( + dir, + ["# Status", "- [ ] Implement feature B", "- [ ] Run tests"].join("\n"), + ); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature B", "done", "Implementation verified in source"), @@ -118,10 +119,7 @@ describe("1.x: Reconciliation happy path", () => { it("1.3: partial adds annotation to checked checkbox", () => { const dir = makeTestDir("partial-checked"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Implement feature C", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] Implement feature C"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature C", "partial", "Only half the requirements met"), @@ -136,10 +134,7 @@ describe("1.x: Reconciliation happy path", () => { it("1.4: partial adds annotation to already-unchecked checkbox", () => { const dir = makeTestDir("partial-unchecked"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [ ] Implement feature D", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [ ] Implement feature D"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature D", "partial", "Partially done"), @@ -153,10 +148,7 @@ describe("1.x: Reconciliation happy path", () => { it("1.5: already correct checked→done is idempotent", () => { const dir = makeTestDir("idempotent-done"); - const original = [ - "# Status", - "- [x] Implement feature E", - ].join("\n"); + const original = ["# Status", "- [x] Implement feature E"].join("\n"); const statusPath = writeStatus(dir, original); const result = applyStatusReconciliation(statusPath, [ @@ -173,10 +165,7 @@ describe("1.x: Reconciliation happy path", () => { it("1.6: already correct unchecked→not_done is idempotent", () => { const dir = makeTestDir("idempotent-notdone"); - const original = [ - "# Status", - "- [ ] Implement feature F", - ].join("\n"); + const original = ["# Status", "- [ ] Implement feature F"].join("\n"); const statusPath = writeStatus(dir, original); const result = applyStatusReconciliation(statusPath, [ @@ -192,12 +181,12 @@ describe("1.x: Reconciliation happy path", () => { it("1.7: multiple reconciliations in one pass", () => { const dir = makeTestDir("multi"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Step 1 complete", - "- [ ] Step 2 pending", - "- [x] Step 3 complete", - ].join("\n")); + const statusPath = writeStatus( + dir, + ["# Status", "- [x] Step 1 complete", "- [ ] Step 2 pending", "- [x] Step 3 complete"].join( + "\n", + ), + ); const result = applyStatusReconciliation(statusPath, [ makeRecon("Step 1 complete", "not_done", "Reverted"), @@ -221,10 +210,7 @@ describe("1.x: Reconciliation happy path", () => { describe("2.x: Reconciliation edge cases", () => { it("2.1: duplicate match — first match wins, second is unmatched", () => { const dir = makeTestDir("duplicate"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Implement feature", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] Implement feature"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature", "not_done", "First entry"), @@ -242,10 +228,7 @@ describe("2.x: Reconciliation edge cases", () => { it("2.2: unmatched entry when no checkbox text matches", () => { const dir = makeTestDir("unmatched"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Build the parser", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] Build the parser"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("Deploy to production", "not_done", "Not deployed"), @@ -302,10 +285,7 @@ describe("2.x: Reconciliation edge cases", () => { it("2.6: partial annotation on already-unchecked item — adds annotation", () => { const dir = makeTestDir("partial-already-unchecked"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [ ] Implement feature G", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [ ] Implement feature G"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature G", "partial", "Work in progress"), @@ -335,9 +315,7 @@ describe("2.x: Reconciliation edge cases", () => { const dir = makeTestDir("empty-checkbox"); const statusPath = writeStatus(dir, "# Status\n- [x] Real item"); - const result = applyStatusReconciliation(statusPath, [ - makeRecon("", "not_done", "Empty text"), - ]); + const result = applyStatusReconciliation(statusPath, [makeRecon("", "not_done", "Empty text")]); expect(result.unmatched).toBe(1); expect(result.actions[0].reason).toContain("Empty checkbox text"); @@ -345,10 +323,7 @@ describe("2.x: Reconciliation edge cases", () => { it("2.9: fuzzy matching handles markdown formatting differences", () => { const dir = makeTestDir("fuzzy"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] **Implement** `feature` I", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] **Implement** `feature` I"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("Implement feature I", "not_done", "Not actually done"), @@ -364,10 +339,7 @@ describe("2.x: Reconciliation edge cases", () => { it("2.10: case-insensitive matching", () => { const dir = makeTestDir("case"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Implement Feature J", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] Implement Feature J"].join("\n")); const result = applyStatusReconciliation(statusPath, [ makeRecon("implement feature j", "not_done", "Case mismatch"), @@ -378,11 +350,7 @@ describe("2.x: Reconciliation edge cases", () => { it("2.11: idempotent no-rewrite when all entries already correct", () => { const dir = makeTestDir("all-correct"); - const original = [ - "# Status", - "- [x] Step 1 done", - "- [ ] Step 2 pending", - ].join("\n"); + const original = ["# Status", "- [x] Step 1 done", "- [ ] Step 2 pending"].join("\n"); const statusPath = writeStatus(dir, original); const result = applyStatusReconciliation(statusPath, [ @@ -439,10 +407,7 @@ describe("3.x: Reconciliation guard — gate enabled check", () => { it("3.3: reconciliation only applies with non-empty entries (positive guard)", () => { const dir = makeTestDir("guard-positive"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Feature implemented", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] Feature implemented"].join("\n")); // Simulates the case where quality gate IS enabled and verdict has entries const result = applyStatusReconciliation(statusPath, [ @@ -455,10 +420,7 @@ describe("3.x: Reconciliation guard — gate enabled check", () => { it("3.4: reconciliation is idempotent across multiple calls (same input)", () => { const dir = makeTestDir("guard-idempotent"); - const statusPath = writeStatus(dir, [ - "# Status", - "- [x] Build feature", - ].join("\n")); + const statusPath = writeStatus(dir, ["# Status", "- [x] Build feature"].join("\n")); const entries = [makeRecon("Build feature", "not_done", "Not built")]; @@ -489,9 +451,7 @@ describe("4.x: Artifact staging allowlist", () => { const EXPECTED_FILE_ARTIFACTS = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"]; // Verify by reading the merge.ts source to confirm constants - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Extract the ALLOWED_ARTIFACT_NAMES array from source const filesMatch = mergeSource.match(/ALLOWED_ARTIFACT_NAMES\s*=\s*\[([^\]]+)\]/); @@ -525,7 +485,7 @@ describe("4.x: Artifact staging allowlist", () => { "taskplane-tasks/TP-035-test/REVIEW_VERDICT.json", ]; const ALLOWED_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"]; - const actual = ALLOWED_NAMES.map(name => `${relFolder}/${name}`); + const actual = ALLOWED_NAMES.map((name) => `${relFolder}/${name}`); expect(actual).toEqual(expected); }); @@ -553,8 +513,8 @@ describe("4.x: Artifact staging allowlist", () => { } } - expect(staged).toBe(2); // .DONE and STATUS.md exist - expect(skipped).toBe(1); // REVIEW_VERDICT.json doesn't exist + expect(staged).toBe(2); // .DONE and STATUS.md exist + expect(skipped).toBe(1); // REVIEW_VERDICT.json doesn't exist }); }); diff --git a/extensions/tests/supervisor-alerts.test.ts b/extensions/tests/supervisor-alerts.test.ts index e687782f..d6634b6a 100644 --- a/extensions/tests/supervisor-alerts.test.ts +++ b/extensions/tests/supervisor-alerts.test.ts @@ -17,7 +17,11 @@ import { expect } from "./expect.ts"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, freshOrchBatchState } from "../taskplane/types.ts"; +import { + buildBatchProgressSnapshot, + buildSupervisorSegmentFrontierSnapshot, + freshOrchBatchState, +} from "../taskplane/types.ts"; import type { SupervisorAlert, SupervisorAlertCategory, @@ -57,8 +61,18 @@ describe("1.x — SupervisorAlert type structure", () => { activeSegmentId: "TP-001::api", segments: [ { segmentId: "TP-001::api", repoId: "api", status: "running", dependsOnSegmentIds: [] }, - { segmentId: "TP-001::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-001::api"] }, - { segmentId: "TP-001::docs", repoId: "docs", status: "pending", dependsOnSegmentIds: ["TP-001::web"] }, + { + segmentId: "TP-001::web", + repoId: "web", + status: "pending", + dependsOnSegmentIds: ["TP-001::api"], + }, + { + segmentId: "TP-001::docs", + repoId: "docs", + status: "pending", + dependsOnSegmentIds: ["TP-001::web"], + }, ], }, partialProgress: false, @@ -138,7 +152,12 @@ describe("1.x — SupervisorAlert type structure", () => { }); it("1.4 — all alert categories are valid", () => { - const categories: SupervisorAlertCategory[] = ["task-failure", "merge-failure", "batch-complete", "agent-message"]; + const categories: SupervisorAlertCategory[] = [ + "task-failure", + "merge-failure", + "batch-complete", + "agent-message", + ]; for (const cat of categories) { const alert: SupervisorAlert = { category: cat, diff --git a/extensions/tests/supervisor-force-merge.test.ts b/extensions/tests/supervisor-force-merge.test.ts index 2eeafca8..8f6ed4ca 100644 --- a/extensions/tests/supervisor-force-merge.test.ts +++ b/extensions/tests/supervisor-force-merge.test.ts @@ -26,7 +26,12 @@ import { fileURLToPath } from "url"; import { tmpdir } from "os"; import { randomBytes } from "crypto"; import { BATCH_STATE_SCHEMA_VERSION, freshOrchBatchState } from "../taskplane/types.ts"; -import type { PersistedBatchState, PersistedTaskRecord, PersistedMergeResult, LaneTaskStatus } from "../taskplane/types.ts"; +import type { + PersistedBatchState, + PersistedTaskRecord, + PersistedMergeResult, + LaneTaskStatus, +} from "../taskplane/types.ts"; import { saveBatchState, loadBatchState } from "../taskplane/persistence.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -57,7 +62,10 @@ function buildTaskRecord( status, taskFolder: `/tmp/tasks/${taskId}`, startedAt: status !== "pending" ? Date.now() - 30000 : null, - endedAt: status === "succeeded" || status === "failed" || status === "stalled" ? Date.now() - 10000 : null, + endedAt: + status === "succeeded" || status === "failed" || status === "stalled" + ? Date.now() - 10000 + : null, doneFileFound: status === "succeeded", exitReason, }; @@ -78,25 +86,30 @@ function buildTestPersistedState(overrides?: Partial): Pers currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001", "TP-002", "TP-003"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - laneSessionId: "orch-lane-1", - taskIds: ["TP-001", "TP-002", "TP-003"], - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + laneSessionId: "orch-lane-1", + taskIds: ["TP-001", "TP-002", "TP-003"], + }, + ], tasks: [ buildTaskRecord("TP-001", "succeeded"), buildTaskRecord("TP-002", "failed", "Session died without .DONE"), buildTaskRecord("TP-003", "succeeded"), ], - mergeResults: [{ - waveIndex: 0, - status: "partial", - failedLane: 1, - failureReason: "Lane(s) lane-1 contain both succeeded and failed tasks. Automatic partial-branch merge is disabled to avoid dropping succeeded commits.", - }], + mergeResults: [ + { + waveIndex: 0, + status: "partial", + failedLane: 1, + failureReason: + "Lane(s) lane-1 contain both succeeded and failed tasks. Automatic partial-branch merge is disabled to avoid dropping succeeded commits.", + }, + ], totalTasks: 3, succeededTasks: 2, failedTasks: 1, @@ -138,14 +151,14 @@ describe("1.x — orch_force_merge tool registration", () => { const idx = extensionSource.indexOf('name: "orch_force_merge"'); const block = extensionSource.slice(idx, idx + 2000); expect(block).toContain("waveIndex:"); - expect(block).toContain("Type.Optional(Type.Number("); + expect(block).toContainNormalized("Type.Optional(Type.Number("); }); it("1.3 — orch_force_merge has optional skipFailed boolean parameter", () => { const idx = extensionSource.indexOf('name: "orch_force_merge"'); const block = extensionSource.slice(idx, idx + 2000); expect(block).toContain("skipFailed:"); - expect(block).toContain("Type.Optional(Type.Boolean("); + expect(block).toContainNormalized("Type.Optional(Type.Boolean("); }); it("1.4 — orch_force_merge has description, promptSnippet, and promptGuidelines", () => { @@ -182,21 +195,23 @@ describe("2.x — orch_force_merge validation logic (persisted state)", () => { it("2.1 — force merge rejects when no merge result exists for wave", () => { const state = buildTestPersistedState({ mergeResults: [] }); const targetWave = state.currentWaveIndex; - const mergeEntry = state.mergeResults.find(mr => mr.waveIndex === targetWave); + const mergeEntry = state.mergeResults.find((mr) => mr.waveIndex === targetWave); // No merge result → should reject expect(mergeEntry).toBeUndefined(); }); it("2.2 — force merge is no-op when merge already succeeded", () => { const state = buildTestPersistedState({ - mergeResults: [{ - waveIndex: 0, - status: "succeeded", - failedLane: null, - failureReason: null, - }], + mergeResults: [ + { + waveIndex: 0, + status: "succeeded", + failedLane: null, + failureReason: null, + }, + ], }); - const mergeEntry = state.mergeResults.find(mr => mr.waveIndex === 0); + const mergeEntry = state.mergeResults.find((mr) => mr.waveIndex === 0); expect(mergeEntry!.status).toBe("succeeded"); // Should return "already succeeded" message }); @@ -224,8 +239,8 @@ describe("2.x — orch_force_merge validation logic (persisted state)", () => { failedTasks: 3, }); const waveTasks = state.wavePlan[0]; - const succeededInWave = waveTasks.filter(tid => { - const t = state.tasks.find(t => t.taskId === tid); + const succeededInWave = waveTasks.filter((tid) => { + const t = state.tasks.find((t) => t.taskId === tid); return t?.status === "succeeded"; }); expect(succeededInWave.length).toBe(0); @@ -234,8 +249,8 @@ describe("2.x — orch_force_merge validation logic (persisted state)", () => { it("2.6 — force merge requires skipFailed when failed tasks exist and skipFailed is false", () => { const state = buildTestPersistedState(); const waveTasks = state.wavePlan[0]; - const failedInWave = waveTasks.filter(tid => { - const t = state.tasks.find(t => t.taskId === tid); + const failedInWave = waveTasks.filter((tid) => { + const t = state.tasks.find((t) => t.taskId === tid); return t?.status === "failed" || t?.status === "stalled"; }); // There are failed tasks → without skipFailed, should reject @@ -254,7 +269,7 @@ describe("3.x — orch_force_merge recovery prep logic (persisted state)", () => // Simulate doOrchForceMerge with skipFailed=true for (const taskId of waveTasks) { - const task = state.tasks.find(t => t.taskId === taskId); + const task = state.tasks.find((t) => t.taskId === taskId); if (!task) continue; if (task.status === "failed" || task.status === "stalled") { task.status = "skipped"; @@ -266,7 +281,7 @@ describe("3.x — orch_force_merge recovery prep logic (persisted state)", () => } // Verify - const tp002 = state.tasks.find(t => t.taskId === "TP-002")!; + const tp002 = state.tasks.find((t) => t.taskId === "TP-002")!; expect(tp002.status).toBe("skipped"); expect(tp002.exitReason).toBe("Skipped by orch_force_merge"); expect(state.failedTasks).toBe(0); @@ -317,17 +332,19 @@ describe("3.x — orch_force_merge recovery prep logic (persisted state)", () => succeededTasks: 2, failedTasks: 2, skippedTasks: 0, - mergeResults: [{ - waveIndex: 0, - status: "partial", - failedLane: 1, - failureReason: "Lane(s) lane-1 contain both succeeded and failed tasks.", - }], + mergeResults: [ + { + waveIndex: 0, + status: "partial", + failedLane: 1, + failureReason: "Lane(s) lane-1 contain both succeeded and failed tasks.", + }, + ], }); // Simulate skipFailed for all failed tasks in the wave for (const taskId of state.wavePlan[0]) { - const task = state.tasks.find(t => t.taskId === taskId); + const task = state.tasks.find((t) => t.taskId === taskId); if (!task) continue; if (task.status === "failed" || task.status === "stalled") { task.status = "skipped"; @@ -353,7 +370,9 @@ describe("3.x — orch_force_merge recovery prep logic (persisted state)", () => }); // Simulate doOrchForceMerge error clearing - state.errors = state.errors.filter(e => !e.includes("mixed") && !e.includes("merge") && !e.includes("Merge")); + state.errors = state.errors.filter( + (e) => !e.includes("mixed") && !e.includes("merge") && !e.includes("Merge"), + ); state.lastError = null; expect(state.errors).toEqual(["some other error"]); @@ -395,12 +414,12 @@ describe("3.x — orch_force_merge recovery prep logic (persisted state)", () => // Force merge wave 1 const targetWave = 1; - const mergeEntry = state.mergeResults.find(mr => mr.waveIndex === targetWave); + const mergeEntry = state.mergeResults.find((mr) => mr.waveIndex === targetWave); expect(mergeEntry).not.toBeUndefined(); expect(mergeEntry!.status).toBe("partial"); // Verify wave 0 is untouched - const wave0 = state.mergeResults.find(mr => mr.waveIndex === 0); + const wave0 = state.mergeResults.find((mr) => mr.waveIndex === 0); expect(wave0!.status).toBe("succeeded"); }); }); @@ -423,7 +442,7 @@ describe("4.x — orch_force_merge persisted state round-trip", () => { expect(loaded.mergeResults[0].status).toBe("partial"); // Skip failed task - const task = loaded.tasks.find(t => t.taskId === "TP-002")!; + const task = loaded.tasks.find((t) => t.taskId === "TP-002")!; task.status = "skipped"; task.exitReason = "Skipped by orch_force_merge"; task.endedAt = Date.now(); @@ -439,7 +458,7 @@ describe("4.x — orch_force_merge persisted state round-trip", () => { // Verify round-trip const reloaded = loadBatchState(tempDir)!; - const skippedTask = reloaded.tasks.find(t => t.taskId === "TP-002")!; + const skippedTask = reloaded.tasks.find((t) => t.taskId === "TP-002")!; expect(skippedTask.status).toBe("skipped"); expect(skippedTask.exitReason).toBe("Skipped by orch_force_merge"); expect(reloaded.failedTasks).toBe(0); @@ -697,11 +716,11 @@ describe("7.x — Follow-up regression guards", () => { }); it("7.3 — resume excludes persisted skipped tasks from wave execution", () => { - expect(resumeSource).toContain("persistedStatusByTaskId.get(taskId) !== \"skipped\""); + expect(resumeSource).toContain('persistedStatusByTaskId.get(taskId) !== "skipped"'); }); it("7.4 — resume synthetic merge retry preserves skipped task status", () => { expect(resumeSource).toContain("Task skipped (merge retry)"); - expect(resumeSource).toContain("status === \"skipped\""); + expect(resumeSource).toContain('status === "skipped"'); }); }); diff --git a/extensions/tests/supervisor-merge-monitoring.test.ts b/extensions/tests/supervisor-merge-monitoring.test.ts index f2d79eb3..c68c3780 100644 --- a/extensions/tests/supervisor-merge-monitoring.test.ts +++ b/extensions/tests/supervisor-merge-monitoring.test.ts @@ -25,15 +25,10 @@ import { MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_CAPTURE_LINES, } from "../taskplane/types.ts"; -import type { - MergeSessionHealthState, - MergeHealthStatus, -} from "../taskplane/types.ts"; - +import type { MergeSessionHealthState, MergeHealthStatus } from "../taskplane/types.ts"; // ── Helper: create a default MergeSessionHealthState ───────────────── - const __dirname = dirname(fileURLToPath(import.meta.url)); function makeHealthState(overrides?: Partial): MergeSessionHealthState { const now = Date.now(); @@ -50,7 +45,6 @@ function makeHealthState(overrides?: Partial): MergeSes }; } - // ── 1. Health Classification Tests ─────────────────────────────────── describe("classifyMergeHealth", () => { @@ -100,7 +94,6 @@ describe("classifyMergeHealth", () => { }); }); - // ── 2. Elapsed-Time Classification Tests ───────────────────────────── describe("elapsed-time classification", () => { @@ -124,7 +117,6 @@ describe("elapsed-time classification", () => { }); }); - // ── 3. Constants Verification ──────────────────────────────────────── describe("monitoring constants", () => { @@ -149,7 +141,6 @@ describe("monitoring constants", () => { }); }); - // ── 4. Supervisor Event Formatting Tests ───────────────────────────── describe("supervisor merge health event formatting", () => { @@ -212,7 +203,6 @@ describe("supervisor merge health event formatting", () => { }); }); - // ── 5. Supervisor shouldNotify Tests ───────────────────────────────── describe("shouldNotify for merge health events", () => { @@ -240,15 +230,11 @@ describe("shouldNotify for merge health events", () => { }); }); - // ── 6. Source-Level Integration Verification ───────────────────────── describe("source-level integration verification", () => { it("6.1: engine.ts imports and uses MergeHealthMonitor", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Verify import expect(engineSource).toContain("MergeHealthMonitor"); // Verify it creates a monitor during merge @@ -259,10 +245,7 @@ describe("source-level integration verification", () => { }); it("6.2: merge.ts mergeWave accepts healthMonitor parameter", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // mergeWave signature includes healthMonitor expect(mergeSource).toContain("healthMonitor?: MergeHealthMonitor"); // Runtime V2 merge flow still performs deregistration on completion/error. @@ -280,10 +263,7 @@ describe("source-level integration verification", () => { }); it("6.4: types.ts exports merge health constants", () => { - const typesSource = readFileSync( - join(__dirname, "..", "taskplane", "types.ts"), - "utf-8", - ); + const typesSource = readFileSync(join(__dirname, "..", "taskplane", "types.ts"), "utf-8"); expect(typesSource).toContain("MERGE_HEALTH_POLL_INTERVAL_MS"); expect(typesSource).toContain("MERGE_HEALTH_WARNING_THRESHOLD_MS"); expect(typesSource).toContain("MERGE_HEALTH_STUCK_THRESHOLD_MS"); @@ -292,10 +272,7 @@ describe("source-level integration verification", () => { }); it("6.5: EngineEventType includes merge health event types", () => { - const typesSource = readFileSync( - join(__dirname, "..", "taskplane", "types.ts"), - "utf-8", - ); + const typesSource = readFileSync(join(__dirname, "..", "taskplane", "types.ts"), "utf-8"); // Find the EngineEventType union const engineEventMatch = typesSource.match(/export type EngineEventType\s*=[\s\S]*?;/); expect(engineEventMatch).not.toBeNull(); @@ -306,17 +283,13 @@ describe("source-level integration verification", () => { }); it("6.6: EngineEvent interface includes merge health fields", () => { - const typesSource = readFileSync( - join(__dirname, "..", "taskplane", "types.ts"), - "utf-8", - ); + const typesSource = readFileSync(join(__dirname, "..", "taskplane", "types.ts"), "utf-8"); expect(typesSource).toContain("sessionName?: string"); expect(typesSource).toContain("healthStatus?: MergeHealthStatus"); expect(typesSource).toContain("stalledMinutes?: number"); }); }); - // ── 7. MergeHealthMonitor Unit Tests ───────────────────────────────── describe("MergeHealthMonitor", () => { @@ -404,23 +377,21 @@ describe("MergeHealthMonitor", () => { }); }); - // ── 8. MergeHealthMonitor.poll() Behavior Tests ────────────────────── describe("MergeHealthMonitor.poll() behavior", () => { it("8.1: poll() source verifies V2 liveness cache wiring + classifyMergeHealth", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Find the poll() method body const pollIdx = mergeSource.indexOf("poll(): Promise {"); expect(pollIdx).toBeGreaterThan(-1); const pollBody = mergeSource.substring(pollIdx, pollIdx + 1500); // Verify poll seeds/clears V2 liveness cache and checks V2 liveness - expect(pollBody).toContain("setV2LivenessRegistryCache(readRegistrySnapshot(this.stateRoot, this.batchId))"); - expect(pollBody).toContain("isV2AgentAlive(sessionName, \"v2\")"); + expect(pollBody).toContain( + "setV2LivenessRegistryCache(readRegistrySnapshot(this.stateRoot, this.batchId))", + ); + expect(pollBody).toContain('isV2AgentAlive(sessionName, "v2")'); expect(pollBody).toContain("setV2LivenessRegistryCache(null)"); // Verify poll checks result file expect(pollBody).toContain("existsSync(resultPath)"); @@ -431,10 +402,7 @@ describe("MergeHealthMonitor.poll() behavior", () => { }); it("8.2: poll() no longer updates snapshots from pane output", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const pollIdx = mergeSource.indexOf("poll(): Promise {"); const pollBody = mergeSource.substring(pollIdx, pollIdx + 1500); @@ -443,10 +411,7 @@ describe("MergeHealthMonitor.poll() behavior", () => { }); it("8.3: poll() calls _emitHealthEvents for each session", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const pollIdx = mergeSource.indexOf("poll(): Promise {"); const pollBody = mergeSource.substring(pollIdx, pollIdx + 1500); @@ -454,10 +419,7 @@ describe("MergeHealthMonitor.poll() behavior", () => { }); it("8.4: poll() fires onDeadSession callback when dead session detected", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const pollIdx = mergeSource.indexOf("poll(): Promise {"); const pollBody = mergeSource.substring(pollIdx, pollIdx + 1500); @@ -468,15 +430,11 @@ describe("MergeHealthMonitor.poll() behavior", () => { }); }); - // ── 9. Event Emission and De-duplication Tests ─────────────────────── describe("event emission and de-duplication", () => { it("9.1: _emitHealthEvents source emits warning event only when warningEmitted is false", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const emitIdx = mergeSource.indexOf("_emitHealthEvents"); expect(emitIdx).toBeGreaterThan(-1); const emitBody = mergeSource.substring(emitIdx, emitIdx + 2000); @@ -488,10 +446,7 @@ describe("event emission and de-duplication", () => { }); it("9.2: _emitHealthEvents source emits dead event only when deadEmitted is false", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const emitIdx = mergeSource.indexOf("_emitHealthEvents"); const emitBody = mergeSource.substring(emitIdx, emitIdx + 2000); @@ -500,10 +455,7 @@ describe("event emission and de-duplication", () => { }); it("9.3: _emitHealthEvents source emits stuck event only when stuckEmitted is false", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const emitIdx = mergeSource.indexOf("_emitHealthEvents"); const emitBody = mergeSource.substring(emitIdx, emitIdx + 2500); @@ -513,10 +465,7 @@ describe("event emission and de-duplication", () => { }); it("9.4: events include laneNumber, sessionName, healthStatus, and stalledMinutes fields", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const emitIdx = mergeSource.indexOf("_emitHealthEvents"); const emitBody = mergeSource.substring(emitIdx, emitIdx + 2000); @@ -527,10 +476,7 @@ describe("event emission and de-duplication", () => { }); it("9.5: events are written via emitEngineEvent (to unified events.jsonl)", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const emitIdx = mergeSource.indexOf("_emitHealthEvents"); const emitBody = mergeSource.substring(emitIdx, emitIdx + 2000); @@ -538,10 +484,7 @@ describe("event emission and de-duplication", () => { }); it("9.6: event uses buildEngineEventBase for consistent event structure", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const emitIdx = mergeSource.indexOf("_emitHealthEvents"); const emitBody = mergeSource.substring(emitIdx, emitIdx + 2000); @@ -549,15 +492,11 @@ describe("event emission and de-duplication", () => { }); }); - // ── 10. Dead-Session Early Exit Signaling Tests ────────────────────── describe("dead-session early exit signaling", () => { it("10.1: MergeHealthMonitor accepts onDeadSession callback in constructor", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // Constructor accepts onDeadSession parameter expect(mergeSource).toContain("onDeadSession?:"); // Stored as private field @@ -565,10 +504,7 @@ describe("dead-session early exit signaling", () => { }); it("10.2: onDeadSession callback is invoked with sessionName and laneNumber", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const pollIdx = mergeSource.indexOf("poll(): Promise {"); const pollBody = mergeSource.substring(pollIdx, pollIdx + 1500); @@ -577,20 +513,14 @@ describe("dead-session early exit signaling", () => { }); it("10.3: engine.ts wires onDeadSession callback when creating monitor", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); expect(engineSource).toContain("onDeadSession:"); // The callback logs the event for now — demonstrates the contract expect(engineSource).toContain("merge health monitor detected dead session"); }); it("10.4: dead session detection in poll() only fires once per session (deadEmitted guard)", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const pollIdx = mergeSource.indexOf("poll(): Promise {"); const pollBody = mergeSource.substring(pollIdx, pollIdx + 1500); @@ -608,10 +538,7 @@ describe("dead-session early exit signaling", () => { // and the _dead session callback_ (for engine-level awareness), not a parallel // abort signal — the existing session-liveness check in waitForMergeResult handles // the actual early exit within its 2-second poll loop. - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // waitForMergeResult already checks session liveness each poll const waitFn = mergeSource.substring( @@ -629,26 +556,19 @@ describe("dead-session early exit signaling", () => { }); }); - // ── 11. Merge TMUX capture removal tests ───────────────────────────── describe("merge TMUX capture removal", () => { it("11.1: merge source no longer includes TMUX capture helper functions", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); expect(mergeSource).not.toContain("captureMergePaneOutput"); expect(mergeSource).not.toContain("runMergeTmuxCommandAsync"); }); it("11.2: merge source no longer invokes tmux capture-pane commands", () => { - const mergeSource = readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); - expect(mergeSource).not.toContain("spawnSync(\"tmux\""); - expect(mergeSource).not.toContain("spawn(\"tmux\""); + const mergeSource = readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); + expect(mergeSource).not.toContain('spawnSync("tmux"'); + expect(mergeSource).not.toContain('spawn("tmux"'); expect(mergeSource).not.toContain("capture-pane"); }); }); diff --git a/extensions/tests/supervisor-onboarding.test.ts b/extensions/tests/supervisor-onboarding.test.ts index 5764919c..3cae6eab 100644 --- a/extensions/tests/supervisor-onboarding.test.ts +++ b/extensions/tests/supervisor-onboarding.test.ts @@ -89,22 +89,26 @@ describe("10.x — detectOrchState: state detection with strict precedence", () // ── Basic state detection ──────────────────────────────────────── it("10.1: no config, no batch, no branches, no tasks → no-config", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => false, - loadBatchState: () => null, - listOrchBranches: () => [], - countPendingTasks: () => 0, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => false, + loadBatchState: () => null, + listOrchBranches: () => [], + countPendingTasks: () => 0, + }), + ); expect(result.state).toBe("no-config"); expect(result.contextMessage).toContain("Welcome to Taskplane"); expect(result.contextMessage).toContain("configuration"); }); it("10.2: active batch (executing) → active-batch", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ phase: "executing" }), - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => makeBatchState({ phase: "executing" }), + }), + ); expect(result.state).toBe("active-batch"); expect(result.batchId).toBe("20260322T120000"); expect(result.batchPhase).toBe("executing"); @@ -113,34 +117,41 @@ describe("10.x — detectOrchState: state detection with strict precedence", () }); it("10.3: active batch (merging) → active-batch", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ phase: "merging" }), - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => makeBatchState({ phase: "merging" }), + }), + ); expect(result.state).toBe("active-batch"); expect(result.batchPhase).toBe("merging"); }); it("10.4: active batch (launching) → active-batch", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ phase: "launching" }), - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => makeBatchState({ phase: "launching" }), + }), + ); expect(result.state).toBe("active-batch"); expect(result.batchPhase).toBe("launching"); }); it("10.5: completed batch + orch branch exists → completed-batch", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ - phase: "completed", - orchBranch: "orch/test-20260322T120000", - succeededTasks: 8, - totalTasks: 10, + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => + makeBatchState({ + phase: "completed", + orchBranch: "orch/test-20260322T120000", + succeededTasks: 8, + totalTasks: 10, + }), + listOrchBranches: () => ["orch/test-20260322T120000"], }), - listOrchBranches: () => ["orch/test-20260322T120000"], - })); + ); expect(result.state).toBe("completed-batch"); expect(result.batchId).toBe("20260322T120000"); expect(result.orchBranch).toBe("orch/test-20260322T120000"); @@ -148,24 +159,28 @@ describe("10.x — detectOrchState: state detection with strict precedence", () }); it("10.6: config exists + pending tasks → pending-tasks", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => null, - listOrchBranches: () => [], - countPendingTasks: () => 5, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => null, + listOrchBranches: () => [], + countPendingTasks: () => 5, + }), + ); expect(result.state).toBe("pending-tasks"); expect(result.pendingTaskCount).toBe(5); expect(result.contextMessage).toContain("5 pending tasks"); }); it("10.7: config exists + no pending tasks → no-tasks", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => null, - listOrchBranches: () => [], - countPendingTasks: () => 0, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => null, + listOrchBranches: () => [], + countPendingTasks: () => 0, + }), + ); expect(result.state).toBe("no-tasks"); expect(result.contextMessage).toContain("No pending tasks"); expect(result.contextMessage).toContain("GitHub Issues"); @@ -175,56 +190,68 @@ describe("10.x — detectOrchState: state detection with strict precedence", () it("10.8: active batch takes precedence over no-config", () => { // Even if config is missing, an active batch is surfaced first - const result = detectOrchState(makeDeps({ - hasConfig: () => false, - loadBatchState: () => makeBatchState({ phase: "executing" }), - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => false, + loadBatchState: () => makeBatchState({ phase: "executing" }), + }), + ); expect(result.state).toBe("active-batch"); }); it("10.9: active batch takes precedence over pending tasks", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ phase: "executing" }), - countPendingTasks: () => 10, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => makeBatchState({ phase: "executing" }), + countPendingTasks: () => 10, + }), + ); expect(result.state).toBe("active-batch"); }); it("10.10: completed batch + branch takes precedence over no-config", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => false, - loadBatchState: () => makeBatchState({ - phase: "completed", - orchBranch: "orch/test", + const result = detectOrchState( + makeDeps({ + hasConfig: () => false, + loadBatchState: () => + makeBatchState({ + phase: "completed", + orchBranch: "orch/test", + }), + listOrchBranches: () => ["orch/test"], }), - listOrchBranches: () => ["orch/test"], - })); + ); expect(result.state).toBe("completed-batch"); }); it("10.11: completed batch + branch takes precedence over pending tasks", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ - phase: "completed", - orchBranch: "orch/test", + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => + makeBatchState({ + phase: "completed", + orchBranch: "orch/test", + }), + listOrchBranches: () => ["orch/test"], + countPendingTasks: () => 5, }), - listOrchBranches: () => ["orch/test"], - countPendingTasks: () => 5, - })); + ); expect(result.state).toBe("completed-batch"); }); it("10.12: no-config takes precedence over pending tasks", () => { // If there's no config, we can't even know about tasks properly // But the precedence order puts no-config after batch states - const result = detectOrchState(makeDeps({ - hasConfig: () => false, - loadBatchState: () => null, - listOrchBranches: () => [], - countPendingTasks: () => 3, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => false, + loadBatchState: () => null, + listOrchBranches: () => [], + countPendingTasks: () => 3, + }), + ); expect(result.state).toBe("no-config"); }); @@ -233,74 +260,90 @@ describe("10.x — detectOrchState: state detection with strict precedence", () it("10.13: stale orch branch — completed batch but branch deleted → falls through", () => { // R002-2: If batch says "completed" with an orchBranch, but that branch // no longer exists in git, it should NOT detect as completed-batch. - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ - phase: "completed", - orchBranch: "orch/deleted-branch", + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => + makeBatchState({ + phase: "completed", + orchBranch: "orch/deleted-branch", + }), + listOrchBranches: () => [], // branch was deleted + countPendingTasks: () => 0, }), - listOrchBranches: () => [], // branch was deleted - countPendingTasks: () => 0, - })); + ); // Falls through to no-tasks since config exists and no pending tasks expect(result.state).toBe("no-tasks"); }); it("10.14: corrupt batch state (loadBatchState throws) → falls through gracefully", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => { throw new Error("corrupt JSON"); }, - listOrchBranches: () => [], - countPendingTasks: () => 0, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => { + throw new Error("corrupt JSON"); + }, + listOrchBranches: () => [], + countPendingTasks: () => 0, + }), + ); // Error is caught, falls through to no-config check expect(result.state).toBe("no-tasks"); }); it("10.15: terminal batch states (failed, stopped, idle) are NOT active-batch", () => { for (const phase of ["failed", "stopped", "idle", "completed"]) { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => makeBatchState({ - phase, - orchBranch: "", // no orch branch → no completed-batch + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => + makeBatchState({ + phase, + orchBranch: "", // no orch branch → no completed-batch + }), + listOrchBranches: () => [], + countPendingTasks: () => 0, }), - listOrchBranches: () => [], - countPendingTasks: () => 0, - })); + ); expect(result.state, `phase "${phase}" should NOT be active-batch`).not.toBe("active-batch"); } }); it("10.16: orch branches exist but no batch state → completed-batch", () => { // Covers the "orphaned orch branch" case (batch-state.json deleted) - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => null, - listOrchBranches: () => ["orch/orphan-branch"], - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => null, + listOrchBranches: () => ["orch/orphan-branch"], + }), + ); expect(result.state).toBe("completed-batch"); expect(result.orchBranch).toBe("orch/orphan-branch"); expect(result.contextMessage).toContain("orch branch"); }); it("10.17: multiple orphaned orch branches → completed-batch with count", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => null, - listOrchBranches: () => ["orch/branch-1", "orch/branch-2"], - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => null, + listOrchBranches: () => ["orch/branch-1", "orch/branch-2"], + }), + ); expect(result.state).toBe("completed-batch"); expect(result.contextMessage).toContain("2 orch branches"); }); it("10.18: single pending task uses singular form", () => { - const result = detectOrchState(makeDeps({ - hasConfig: () => true, - loadBatchState: () => null, - listOrchBranches: () => [], - countPendingTasks: () => 1, - })); + const result = detectOrchState( + makeDeps({ + hasConfig: () => true, + loadBatchState: () => null, + listOrchBranches: () => [], + countPendingTasks: () => 1, + }), + ); expect(result.state).toBe("pending-tasks"); expect(result.pendingTaskCount).toBe(1); expect(result.contextMessage).toContain("1 pending task "); @@ -308,15 +351,18 @@ describe("10.x — detectOrchState: state detection with strict precedence", () }); it("10.19: active-batch context includes task counters", () => { - const result = detectOrchState(makeDeps({ - loadBatchState: () => makeBatchState({ - phase: "executing", - succeededTasks: 4, - failedTasks: 1, - skippedTasks: 2, - totalTasks: 10, + const result = detectOrchState( + makeDeps({ + loadBatchState: () => + makeBatchState({ + phase: "executing", + succeededTasks: 4, + failedTasks: 1, + skippedTasks: 2, + totalTasks: 10, + }), }), - })); + ); expect(result.state).toBe("active-batch"); expect(result.contextMessage).toContain("4 succeeded"); expect(result.contextMessage).toContain("1 failed"); @@ -325,14 +371,17 @@ describe("10.x — detectOrchState: state detection with strict precedence", () }); it("10.20: completed-batch context mentions integration", () => { - const result = detectOrchState(makeDeps({ - loadBatchState: () => makeBatchState({ - phase: "completed", - orchBranch: "orch/test", - baseBranch: "main", + const result = detectOrchState( + makeDeps({ + loadBatchState: () => + makeBatchState({ + phase: "completed", + orchBranch: "orch/test", + baseBranch: "main", + }), + listOrchBranches: () => ["orch/test"], }), - listOrchBranches: () => ["orch/test"], - })); + ); expect(result.state).toBe("completed-batch"); expect(result.contextMessage).toContain("integrate"); expect(result.contextMessage).toContain("main"); @@ -508,9 +557,7 @@ describe("12.x — /orch with args: existing behavior preserved", () => { expect(doOrchStartIdx).toBeGreaterThan(noArgsEnd); // The doOrchStart helper itself calls startBatchInWorker (TP-071: worker thread) - const doOrchStartBody = extSource.substring( - extSource.indexOf("async function doOrchStart("), - ); + const doOrchStartBody = extSource.substring(extSource.indexOf("async function doOrchStart(")); expect(doOrchStartBody).toContain("startBatchInWorker("); }); @@ -518,9 +565,7 @@ describe("12.x — /orch with args: existing behavior preserved", () => { const extSource = readSource("extension.ts"); // The doOrchStart helper should call startBatchInWorker then activateSupervisor (TP-071) - const doOrchStartBody = extSource.substring( - extSource.indexOf("async function doOrchStart("), - ); + const doOrchStartBody = extSource.substring(extSource.indexOf("async function doOrchStart(")); const startBatchIdx = doOrchStartBody.indexOf("startBatchInWorker("); const activateAfterBatch = doOrchStartBody.indexOf("activateSupervisor(", startBatchIdx); expect(activateAfterBatch).toBeGreaterThan(startBatchIdx); @@ -534,7 +579,10 @@ describe("12.x — /orch with args: existing behavior preserved", () => { ); // In the no-args path, activateSupervisor is called with routingState - const noArgsBlock = orchHandler.substring(0, orchHandler.indexOf("return;\n\t\t\t}\n\n\t\t\tif (!requireExecCtx")); + const noArgsBlock = orchHandler.substring( + 0, + orchHandler.indexOf("return;\n\t\t\t}\n\n\t\t\tif (!requireExecCtx"), + ); expect(noArgsBlock).toContain("activateSupervisor("); expect(noArgsBlock).toContain("routingState:"); expect(noArgsBlock).toContain("contextMessage:"); diff --git a/extensions/tests/supervisor-recovery-flows.test.ts b/extensions/tests/supervisor-recovery-flows.test.ts index 018b85f1..9400b148 100644 --- a/extensions/tests/supervisor-recovery-flows.test.ts +++ b/extensions/tests/supervisor-recovery-flows.test.ts @@ -41,8 +41,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const laneRunnerSrc = readFileSync(join(__dirname, "..", "taskplane", "lane-runner.ts"), "utf-8"); const extensionSrc = readFileSync(join(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); const engineSrc = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); -const taskWorkerSrc = readFileSync(join(__dirname, "..", "..", "templates", "agents", "task-worker.md"), "utf-8"); -const supervisorTemplateSrc = readFileSync(join(__dirname, "..", "..", "templates", "agents", "supervisor.md"), "utf-8"); +const taskWorkerSrc = readFileSync( + join(__dirname, "..", "..", "templates", "agents", "task-worker.md"), + "utf-8", +); +const supervisorTemplateSrc = readFileSync( + join(__dirname, "..", "..", "templates", "agents", "supervisor.md"), + "utf-8", +); const resumeSrc = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); function mkTmpRoot(): string { @@ -62,7 +68,11 @@ describe("TP-187 #538: drainAgentOutbox helper", () => { stateRoot = mkTmpRoot(); }); afterEach(() => { - try { rmSync(stateRoot, { recursive: true, force: true }); } catch { /* ignore */ } + try { + rmSync(stateRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); it("returns 0 when the outbox directory does not exist", () => { @@ -85,7 +95,11 @@ describe("TP-187 #538: drainAgentOutbox helper", () => { replyTo: null, }; writeFileSync(join(outbox, "m1.msg.json"), JSON.stringify(msg), "utf-8"); - writeFileSync(join(outbox, "m2.msg.json"), JSON.stringify({ ...msg, id: "m2", type: "reply" }), "utf-8"); + writeFileSync( + join(outbox, "m2.msg.json"), + JSON.stringify({ ...msg, id: "m2", type: "reply" }), + "utf-8", + ); const drained = drainAgentOutbox(stateRoot, batchId, agentId); expect(drained).toBe(2); @@ -191,9 +205,9 @@ describe("TP-187 #538: supervisor_takeover tool", () => { const fnIdx = extensionSrc.indexOf("function doSupervisorTakeover("); expect(fnIdx).not.toBe(-1); const fnBody = extensionSrc.slice(fnIdx, fnIdx + 4000); - expect(fnBody).toContain('orchBatchState.pauseSignal.paused = true'); - expect(fnBody).toContain('drainAgentOutbox(stateRoot, orchBatchState.batchId, agentId)'); - expect(fnBody).toContain('terminatedLanes.set(lane.laneNumber'); + expect(fnBody).toContain("orchBatchState.pauseSignal.paused = true"); + expect(fnBody).toContain("drainAgentOutbox(stateRoot, orchBatchState.batchId, agentId)"); + expect(fnBody).toContain("terminatedLanes.set(lane.laneNumber"); // Critical: distinct from orch_abort — must NOT call deleteBatchState/executeAbort. expect(fnBody.includes("deleteBatchState")).toBe(false); expect(fnBody.includes("executeAbort")).toBe(false); @@ -261,8 +275,16 @@ describe("TP-187 #539: batch-meta runtime artifact roundtrip", () => { let stateRoot: string; const batchId = "b-test-539-meta"; - beforeEach(() => { stateRoot = mkTmpRoot(); }); - afterEach(() => { try { rmSync(stateRoot, { recursive: true, force: true }); } catch { /* ignore */ } }); + beforeEach(() => { + stateRoot = mkTmpRoot(); + }); + afterEach(() => { + try { + rmSync(stateRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); it("save then load yields the same artifact", () => { const wavePlan = [["TP-001", "TP-002"], ["TP-003"]]; @@ -300,16 +322,20 @@ describe("TP-187 #539: batch-meta runtime artifact roundtrip", () => { it("returns null when the batchId in the file does not match", () => { const path = join(runtimeRoot(stateRoot, batchId), "batch-meta.json"); mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify({ - schemaVersion: 1, - batchId: "wrong-id", - wavePlan: [], - baseBranch: "main", - orchBranch: "", - mode: "repo", - startedAt: 1, - totalWaves: 0, - }), "utf-8"); + writeFileSync( + path, + JSON.stringify({ + schemaVersion: 1, + batchId: "wrong-id", + wavePlan: [], + baseBranch: "main", + orchBranch: "", + mode: "repo", + startedAt: 1, + totalWaves: 0, + }), + "utf-8", + ); expect(loadBatchMetaRuntimeArtifact(stateRoot, batchId)).toBeNull(); }); }); @@ -317,8 +343,16 @@ describe("TP-187 #539: batch-meta runtime artifact roundtrip", () => { describe("TP-187 #539: reconstructBatchStateFromRuntime", () => { let stateRoot: string; - beforeEach(() => { stateRoot = mkTmpRoot(); }); - afterEach(() => { try { rmSync(stateRoot, { recursive: true, force: true }); } catch { /* ignore */ } }); + beforeEach(() => { + stateRoot = mkTmpRoot(); + }); + afterEach(() => { + try { + rmSync(stateRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); function setupBatch(opts: { batchId: string; @@ -327,7 +361,7 @@ describe("TP-187 #539: reconstructBatchStateFromRuntime", () => { mode?: "repo" | "workspace"; }): void { const { batchId, tasks } = opts; - const wavePlan = opts.wavePlan ?? [tasks.map(t => t.taskId)]; + const wavePlan = opts.wavePlan ?? [tasks.map((t) => t.taskId)]; // Write batch-meta artifact. saveBatchMetaRuntimeArtifact(stateRoot, { @@ -466,7 +500,7 @@ describe("TP-187 #539: reconstructBatchStateFromRuntime", () => { tasks: [{ taskId: "T-old", laneNumber: 1, cwd: wt1 }], }); // Sleep briefly to ensure mtime differs between the two batch dirs. - await new Promise(resolve => setTimeout(resolve, 30)); + await new Promise((resolve) => setTimeout(resolve, 30)); setupBatch({ batchId: "b-new", tasks: [{ taskId: "T-new", laneNumber: 1, cwd: wt2 }], @@ -563,18 +597,37 @@ describe("TP-187: end-to-end drain coverage via discoverMailboxAgentIds", () => let stateRoot: string; const batchId = "b-e2e"; - beforeEach(() => { stateRoot = mkTmpRoot(); }); - afterEach(() => { try { rmSync(stateRoot, { recursive: true, force: true }); } catch { /* ignore */ } }); + beforeEach(() => { + stateRoot = mkTmpRoot(); + }); + afterEach(() => { + try { + rmSync(stateRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); it("discovers all per-agent outboxes and drains them in one pass", () => { - const agents = [ - "orch-test-lane-1-worker", - "orch-test-lane-2-worker", - ]; + const agents = ["orch-test-lane-1-worker", "orch-test-lane-2-worker"]; for (const a of agents) { const ob = sessionOutboxDir(stateRoot, batchId, a); mkdirSync(ob, { recursive: true }); - writeFileSync(join(ob, "m1.msg.json"), JSON.stringify({ id: "m1", batchId, from: a, to: "supervisor", timestamp: Date.now(), type: "reply", content: "x", expectsReply: false, replyTo: null }), "utf-8"); + writeFileSync( + join(ob, "m1.msg.json"), + JSON.stringify({ + id: "m1", + batchId, + from: a, + to: "supervisor", + timestamp: Date.now(), + type: "reply", + content: "x", + expectsReply: false, + replyTo: null, + }), + "utf-8", + ); } const discovered = discoverMailboxAgentIds(stateRoot, batchId).sort(); expect(discovered).toEqual(agents.slice().sort()); @@ -602,7 +655,11 @@ describe("TP-187 #538: lane-terminated/lane-respawned suppression lifecycle (beh * termination adds entries to terminatedLanes, lane-respawn removes * them. The behavior under test is independent of the IPC transport. */ - type Alert = { category: string; summary: string; context: { laneNumber?: number; agentId?: string } }; + type Alert = { + category: string; + summary: string; + context: { laneNumber?: number; agentId?: string }; + }; function makeFilter() { const terminatedLanes = new Map(); @@ -611,12 +668,19 @@ describe("TP-187 #538: lane-terminated/lane-respawned suppression lifecycle (beh const dropped: Alert[] = []; const onAlert = (alert: Alert) => { const suppressed = - (typeof alert.context?.laneNumber === "number" && terminatedLanes.has(alert.context.laneNumber)) || - (typeof alert.context?.agentId === "string" && !!alert.context.agentId && terminatedAgents.has(alert.context.agentId)); + (typeof alert.context?.laneNumber === "number" && + terminatedLanes.has(alert.context.laneNumber)) || + (typeof alert.context?.agentId === "string" && + !!alert.context.agentId && + terminatedAgents.has(alert.context.agentId)); if (suppressed) dropped.push(alert); else delivered.push(alert); }; - const onLaneTerminated = (info: { laneNumber: number; agentId: string; terminatedAt: number }) => { + const onLaneTerminated = (info: { + laneNumber: number; + agentId: string; + terminatedAt: number; + }) => { terminatedLanes.set(info.laneNumber, info.terminatedAt); if (info.agentId) terminatedAgents.set(info.agentId, info.terminatedAt); }; @@ -624,14 +688,30 @@ describe("TP-187 #538: lane-terminated/lane-respawned suppression lifecycle (beh terminatedLanes.delete(laneNumber); if (agentId) terminatedAgents.delete(agentId); }; - return { onAlert, onLaneTerminated, onLaneRespawned, delivered, dropped, terminatedLanes, terminatedAgents }; + return { + onAlert, + onLaneTerminated, + onLaneRespawned, + delivered, + dropped, + terminatedLanes, + terminatedAgents, + }; } it("alerts before termination are delivered; alerts after termination are dropped", () => { const f = makeFilter(); - f.onAlert({ category: "worker-exit-intercept", summary: "first", context: { laneNumber: 1, agentId: "a-1" } }); + f.onAlert({ + category: "worker-exit-intercept", + summary: "first", + context: { laneNumber: 1, agentId: "a-1" }, + }); f.onLaneTerminated({ laneNumber: 1, agentId: "a-1", terminatedAt: 1000 }); - f.onAlert({ category: "worker-exit-intercept", summary: "zombie", context: { laneNumber: 1, agentId: "a-1" } }); + f.onAlert({ + category: "worker-exit-intercept", + summary: "zombie", + context: { laneNumber: 1, agentId: "a-1" }, + }); expect(f.delivered.length).toBe(1); expect(f.delivered[0].summary).toBe("first"); expect(f.dropped.length).toBe(1); @@ -642,12 +722,20 @@ describe("TP-187 #538: lane-terminated/lane-respawned suppression lifecycle (beh const f = makeFilter(); // Wave 1: lane 1 terminates with agent a-1 f.onLaneTerminated({ laneNumber: 1, agentId: "a-1", terminatedAt: 1000 }); - f.onAlert({ category: "task-failure", summary: "wave1-zombie", context: { laneNumber: 1, agentId: "a-1" } }); + f.onAlert({ + category: "task-failure", + summary: "wave1-zombie", + context: { laneNumber: 1, agentId: "a-1" }, + }); expect(f.dropped.length).toBe(1); // Wave 2: lane 1 re-allocated for a fresh task with agent a-2 f.onLaneRespawned(1, "a-2", "b-test"); - f.onAlert({ category: "worker-exit-intercept", summary: "wave2-fresh", context: { laneNumber: 1, agentId: "a-2" } }); + f.onAlert({ + category: "worker-exit-intercept", + summary: "wave2-fresh", + context: { laneNumber: 1, agentId: "a-2" }, + }); expect(f.delivered.length).toBe(1); expect(f.delivered[0].summary).toBe("wave2-fresh"); }); @@ -655,7 +743,11 @@ describe("TP-187 #538: lane-terminated/lane-respawned suppression lifecycle (beh it("alerts targeting a different lane are not affected by suppression", () => { const f = makeFilter(); f.onLaneTerminated({ laneNumber: 1, agentId: "a-1", terminatedAt: 1000 }); - f.onAlert({ category: "task-failure", summary: "lane-2-alert", context: { laneNumber: 2, agentId: "a-2" } }); + f.onAlert({ + category: "task-failure", + summary: "lane-2-alert", + context: { laneNumber: 2, agentId: "a-2" }, + }); expect(f.delivered.length).toBe(1); expect(f.dropped.length).toBe(0); }); @@ -669,7 +761,10 @@ describe("TP-187 #538: lane-terminated/lane-respawned suppression lifecycle (beh }); describe("TP-187 #538: lane-respawned IPC wiring is end-to-end", () => { - const engineWorkerSrc = readFileSync(join(__dirname, "..", "taskplane", "engine-worker.ts"), "utf-8"); + const engineWorkerSrc = readFileSync( + join(__dirname, "..", "taskplane", "engine-worker.ts"), + "utf-8", + ); const executionSrc = readFileSync(join(__dirname, "..", "taskplane", "execution.ts"), "utf-8"); it("WorkerToMainMessage type declares lane-respawned", () => { @@ -688,7 +783,16 @@ describe("TP-187 #538: lane-respawned IPC wiring is end-to-end", () => { it("executeLaneV2 emits onLaneRespawned at the top of the function body before the task loop", () => { const start = executionSrc.indexOf("export async function executeLaneV2("); - const body = executionSrc.slice(start, start + 7500); + // TP-193: Window bumped from 7500 to 12000 to absorb formatter re-wrapping + // (multi-arg calls split across lines lengthens the function body). + const rawBody = executionSrc.slice(start, start + 12000); + // Whitespace-normalize so multi-arg `onLaneRespawned(\n\tlane.laneNumber,...)` + // matches the literal needle `onLaneRespawned(lane.laneNumber`. + const body = rawBody + .replace(/\s+/g, " ") + .replace(/([(\[{])\s+/g, "$1") + .replace(/\s+([)\]},])/g, "$1") + .replace(/,([)\]}])/g, "$1"); const respawnIdx = body.indexOf("onLaneRespawned(lane.laneNumber"); const forIdx = body.indexOf("for (const task of lane.tasks)"); expect(respawnIdx).not.toBe(-1); @@ -708,8 +812,16 @@ describe("TP-187 #539: end-to-end abort-then-reconstruct flow", () => { let stateRoot: string; const batchId = "b-abort-recon"; - beforeEach(() => { stateRoot = mkTmpRoot(); }); - afterEach(() => { try { rmSync(stateRoot, { recursive: true, force: true }); } catch { /* ignore */ } }); + beforeEach(() => { + stateRoot = mkTmpRoot(); + }); + afterEach(() => { + try { + rmSync(stateRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); it("after batch-state.json is deleted, reconstruction still succeeds from runtime artifacts", () => { const wt = join(stateRoot, "wt", "lane-1"); diff --git a/extensions/tests/supervisor-recovery-tools.test.ts b/extensions/tests/supervisor-recovery-tools.test.ts index 8fab52a7..350ec0ac 100644 --- a/extensions/tests/supervisor-recovery-tools.test.ts +++ b/extensions/tests/supervisor-recovery-tools.test.ts @@ -21,7 +21,11 @@ import { fileURLToPath } from "url"; import { tmpdir } from "os"; import { randomBytes } from "crypto"; import { BATCH_STATE_SCHEMA_VERSION, freshOrchBatchState } from "../taskplane/types.ts"; -import type { PersistedBatchState, PersistedTaskRecord, LaneTaskStatus } from "../taskplane/types.ts"; +import type { + PersistedBatchState, + PersistedTaskRecord, + LaneTaskStatus, +} from "../taskplane/types.ts"; import { saveBatchState, loadBatchState } from "../taskplane/persistence.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -53,14 +57,16 @@ function buildTestPersistedState(overrides?: Partial): Pers currentWaveIndex: 0, totalWaves: 1, wavePlan: [["TP-001", "TP-002", "TP-003"]], - lanes: [{ - laneNumber: 1, - laneId: "lane-1", - worktreePath: "/tmp/wt-1", - branch: "task/lane-1", - laneSessionId: "orch-lane-1", - taskIds: ["TP-001", "TP-002", "TP-003"], - }], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + worktreePath: "/tmp/wt-1", + branch: "task/lane-1", + laneSessionId: "orch-lane-1", + taskIds: ["TP-001", "TP-002", "TP-003"], + }, + ], tasks: [ buildTaskRecord("TP-001", "succeeded"), buildTaskRecord("TP-002", "failed", "Session died without .DONE"), @@ -103,7 +109,10 @@ function buildTaskRecord( status, taskFolder: `/tmp/tasks/${taskId}`, startedAt: status !== "pending" ? Date.now() - 30000 : null, - endedAt: status === "succeeded" || status === "failed" || status === "stalled" ? Date.now() - 10000 : null, + endedAt: + status === "succeeded" || status === "failed" || status === "stalled" + ? Date.now() - 10000 + : null, doneFileFound: status === "succeeded", exitReason, }; @@ -198,7 +207,7 @@ describe("1.x — orch_skip_task tool registration", () => { describe("2.x — orch_retry_task logic (persisted state)", () => { it("2.1 — retry resets failed task to pending", () => { const state = buildTestPersistedState(); - const task = state.tasks.find(t => t.taskId === "TP-002")!; + const task = state.tasks.find((t) => t.taskId === "TP-002")!; expect(task.status).toBe("failed"); // Simulate what doOrchRetryTask does @@ -225,7 +234,7 @@ describe("2.x — orch_retry_task logic (persisted state)", () => { buildTaskRecord("TP-003", "pending"), ], }); - const task = state.tasks.find(t => t.taskId === "TP-002")!; + const task = state.tasks.find((t) => t.taskId === "TP-002")!; expect(task.status).toBe("stalled"); task.status = "pending"; @@ -241,21 +250,21 @@ describe("2.x — orch_retry_task logic (persisted state)", () => { buildTaskRecord("TP-003", "pending"), ], }); - const task = state.tasks.find(t => t.taskId === "TP-001")!; + const task = state.tasks.find((t) => t.taskId === "TP-001")!; // doOrchRetryTask rejects if status is not "failed" or "stalled" expect(task.status !== "failed" && task.status !== "stalled").toBe(true); }); it("2.4 — retry rejects succeeded task", () => { const state = buildTestPersistedState(); - const task = state.tasks.find(t => t.taskId === "TP-001")!; + const task = state.tasks.find((t) => t.taskId === "TP-001")!; expect(task.status).toBe("succeeded"); expect(task.status !== "failed" && task.status !== "stalled").toBe(true); }); it("2.5 — retry rejects unknown taskId", () => { const state = buildTestPersistedState(); - const task = state.tasks.find(t => t.taskId === "TP-999"); + const task = state.tasks.find((t) => t.taskId === "TP-999"); expect(task).toBeUndefined(); }); @@ -281,7 +290,7 @@ describe("2.x — orch_retry_task logic (persisted state)", () => { // Load, modify (retry), save const loaded = loadBatchState(tempDir)!; expect(loaded).not.toBeNull(); - const task = loaded.tasks.find(t => t.taskId === "TP-002")!; + const task = loaded.tasks.find((t) => t.taskId === "TP-002")!; task.status = "pending"; task.exitReason = ""; task.doneFileFound = false; @@ -291,7 +300,7 @@ describe("2.x — orch_retry_task logic (persisted state)", () => { // Verify round-trip const reloaded = loadBatchState(tempDir)!; - const retriedTask = reloaded.tasks.find(t => t.taskId === "TP-002")!; + const retriedTask = reloaded.tasks.find((t) => t.taskId === "TP-002")!; expect(retriedTask.status).toBe("pending"); expect(retriedTask.exitReason).toBe(""); expect(retriedTask.doneFileFound).toBe(false); @@ -309,7 +318,7 @@ describe("2.x — orch_retry_task logic (persisted state)", () => { describe("3.x — orch_skip_task logic (persisted state)", () => { it("3.1 — skip marks failed task as skipped", () => { const state = buildTestPersistedState(); - const task = state.tasks.find(t => t.taskId === "TP-002")!; + const task = state.tasks.find((t) => t.taskId === "TP-002")!; expect(task.status).toBe("failed"); task.status = "skipped"; @@ -325,7 +334,7 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { it("3.2 — skip marks pending task as skipped", () => { const state = buildTestPersistedState(); - const task = state.tasks.find(t => t.taskId === "TP-003")!; + const task = state.tasks.find((t) => t.taskId === "TP-003")!; expect(task.status).toBe("pending"); task.status = "skipped"; @@ -340,23 +349,22 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { it("3.3 — skip rejects running task", () => { const state = buildTestPersistedState({ - tasks: [ - buildTaskRecord("TP-001", "running"), - buildTaskRecord("TP-002", "failed", "Some error"), - ], + tasks: [buildTaskRecord("TP-001", "running"), buildTaskRecord("TP-002", "failed", "Some error")], }); - const task = state.tasks.find(t => t.taskId === "TP-001")!; + const task = state.tasks.find((t) => t.taskId === "TP-001")!; expect(task.status).toBe("running"); // doOrchSkipTask rejects running - const isSkippable = task.status === "failed" || task.status === "stalled" || task.status === "pending"; + const isSkippable = + task.status === "failed" || task.status === "stalled" || task.status === "pending"; expect(isSkippable).toBe(false); }); it("3.4 — skip rejects succeeded task", () => { const state = buildTestPersistedState(); - const task = state.tasks.find(t => t.taskId === "TP-001")!; + const task = state.tasks.find((t) => t.taskId === "TP-001")!; expect(task.status).toBe("succeeded"); - const isSkippable = task.status === "failed" || task.status === "stalled" || task.status === "pending"; + const isSkippable = + task.status === "failed" || task.status === "stalled" || task.status === "pending"; expect(isSkippable).toBe(false); }); @@ -388,7 +396,7 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { }; // Skip TP-002 - const task = state.tasks.find(t => t.taskId === "TP-002")!; + const task = state.tasks.find((t) => t.taskId === "TP-002")!; task.status = "skipped"; task.exitReason = "Skipped by supervisor"; state.failedTasks = Math.max(0, state.failedTasks - 1); @@ -402,8 +410,8 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { if (depBlockedIdx === -1) continue; const depDeps = dependencyGraph.dependencies.get(depId) || []; - const allResolved = depDeps.every(predId => { - const predRecord = state.tasks.find(t => t.taskId === predId); + const allResolved = depDeps.every((predId) => { + const predRecord = state.tasks.find((t) => t.taskId === predId); if (!predRecord) return true; return predRecord.status === "succeeded" || predRecord.status === "skipped"; }); @@ -453,7 +461,7 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { }; // Skip TP-002 - const task = state.tasks.find(t => t.taskId === "TP-002")!; + const task = state.tasks.find((t) => t.taskId === "TP-002")!; task.status = "skipped"; state.failedTasks = Math.max(0, state.failedTasks - 1); state.skippedTasks = (state.skippedTasks || 0) + 1; @@ -466,8 +474,8 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { if (depBlockedIdx === -1) continue; const depDeps = dependencyGraph.dependencies.get(depId) || []; - const allResolved = depDeps.every(predId => { - const predRecord = state.tasks.find(t => t.taskId === predId); + const allResolved = depDeps.every((predId) => { + const predRecord = state.tasks.find((t) => t.taskId === predId); if (!predRecord) return true; return predRecord.status === "succeeded" || predRecord.status === "skipped"; }); @@ -492,7 +500,7 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { saveBatchState(JSON.stringify(state, null, 2), tempDir); const loaded = loadBatchState(tempDir)!; - const task = loaded.tasks.find(t => t.taskId === "TP-002")!; + const task = loaded.tasks.find((t) => t.taskId === "TP-002")!; task.status = "skipped"; task.exitReason = "Skipped by supervisor"; task.endedAt = Date.now(); @@ -502,7 +510,7 @@ describe("3.x — orch_skip_task logic (persisted state)", () => { saveBatchState(JSON.stringify(loaded, null, 2), tempDir); const reloaded = loadBatchState(tempDir)!; - const skippedTask = reloaded.tasks.find(t => t.taskId === "TP-002")!; + const skippedTask = reloaded.tasks.find((t) => t.taskId === "TP-002")!; expect(skippedTask.status).toBe("skipped"); expect(skippedTask.exitReason).toBe("Skipped by supervisor"); expect(reloaded.failedTasks).toBe(0); @@ -533,7 +541,7 @@ describe("4.x — Counter consistency after retry and skip operations", () => { }); // Retry TP-002 - const tp002 = state.tasks.find(t => t.taskId === "TP-002")!; + const tp002 = state.tasks.find((t) => t.taskId === "TP-002")!; tp002.status = "pending"; tp002.exitReason = ""; state.failedTasks = Math.max(0, state.failedTasks - 1); @@ -541,7 +549,7 @@ describe("4.x — Counter consistency after retry and skip operations", () => { expect(state.failedTasks).toBe(1); // Skip TP-003 - const tp003 = state.tasks.find(t => t.taskId === "TP-003")!; + const tp003 = state.tasks.find((t) => t.taskId === "TP-003")!; tp003.status = "skipped"; tp003.exitReason = "Skipped by supervisor"; state.failedTasks = Math.max(0, state.failedTasks - 1); @@ -577,9 +585,7 @@ describe("4.x — Counter consistency after retry and skip operations", () => { it("4.3 — skip from stalled status decrements failedTasks", () => { // Stalled tasks are counted in failedTasks const state = buildTestPersistedState({ - tasks: [ - buildTaskRecord("TP-001", "stalled", "No progress"), - ], + tasks: [buildTaskRecord("TP-001", "stalled", "No progress")], totalTasks: 1, failedTasks: 1, skippedTasks: 0, @@ -597,9 +603,7 @@ describe("4.x — Counter consistency after retry and skip operations", () => { it("4.4 — skip from pending status does not decrement failedTasks", () => { const state = buildTestPersistedState({ - tasks: [ - buildTaskRecord("TP-003", "pending"), - ], + tasks: [buildTaskRecord("TP-003", "pending")], totalTasks: 1, failedTasks: 0, skippedTasks: 0, @@ -822,7 +826,7 @@ describe("6.x — Phase transition after retry/skip", () => { const loaded = loadBatchState(tempDir)!; // Apply skip - const task = loaded.tasks.find(t => t.taskId === "TP-002")!; + const task = loaded.tasks.find((t) => t.taskId === "TP-002")!; task.status = "skipped"; task.exitReason = "Skipped by supervisor"; loaded.failedTasks = Math.max(0, loaded.failedTasks - 1); diff --git a/extensions/tests/supervisor-template.test.ts b/extensions/tests/supervisor-template.test.ts index c2a6cdef..4f85333f 100644 --- a/extensions/tests/supervisor-template.test.ts +++ b/extensions/tests/supervisor-template.test.ts @@ -80,9 +80,18 @@ describe("1.x — Template file existence", () => { describe("2.x — Template content: required sections and placeholders", () => { // Normalize CRLF→LF for cross-platform compatibility - const supervisorTemplate = readFileSync(join(TEMPLATES_DIR, "supervisor.md"), "utf-8").replace(/\r\n/g, "\n"); - const routingTemplate = readFileSync(join(TEMPLATES_DIR, "supervisor-routing.md"), "utf-8").replace(/\r\n/g, "\n"); - const localTemplate = readFileSync(join(TEMPLATES_DIR, "local", "supervisor.md"), "utf-8").replace(/\r\n/g, "\n"); + const supervisorTemplate = readFileSync(join(TEMPLATES_DIR, "supervisor.md"), "utf-8").replace( + /\r\n/g, + "\n", + ); + const routingTemplate = readFileSync( + join(TEMPLATES_DIR, "supervisor-routing.md"), + "utf-8", + ).replace(/\r\n/g, "\n"); + const localTemplate = readFileSync(join(TEMPLATES_DIR, "local", "supervisor.md"), "utf-8").replace( + /\r\n/g, + "\n", + ); it("2.1: supervisor template has frontmatter with name", () => { expect(supervisorTemplate).toMatch(/^---\n/); @@ -162,11 +171,14 @@ describe("3.x — Template composition: base + local override", () => { it("3.2: composes base + local override", () => { const agentDir = join(tmpDir, ".pi", "agents"); mkdirSync(agentDir, { recursive: true }); - writeFileSync(join(agentDir, "supervisor.md"), `--- + writeFileSync( + join(agentDir, "supervisor.md"), + `--- name: supervisor --- Always run the linter before integration. -`); +`, + ); const result = loadSupervisorTemplate("supervisor", tmpDir); expect(result).not.toBeNull(); @@ -180,12 +192,15 @@ Always run the linter before integration. it("3.3: standalone mode uses local only, ignores base", () => { const agentDir = join(tmpDir, ".pi", "agents"); mkdirSync(agentDir, { recursive: true }); - writeFileSync(join(agentDir, "supervisor.md"), `--- + writeFileSync( + join(agentDir, "supervisor.md"), + `--- name: supervisor standalone: true --- Custom standalone supervisor prompt. -`); +`, + ); const result = loadSupervisorTemplate("supervisor", tmpDir); expect(result).not.toBeNull(); @@ -246,11 +261,14 @@ describe("4.x — Prompt builder: template loading + variable replacement", () = it("4.2: buildSupervisorSystemPrompt includes local override content", () => { const agentDir = join(tmpDir, ".pi", "agents"); mkdirSync(agentDir, { recursive: true }); - writeFileSync(join(agentDir, "supervisor.md"), `--- + writeFileSync( + join(agentDir, "supervisor.md"), + `--- name: supervisor --- Check CI dashboard at https://ci.example.com before approving merges. -`); +`, + ); const batchState = makeTestBatchState(); const config = DEFAULT_ORCHESTRATOR_CONFIG; diff --git a/extensions/tests/supervisor.test.ts b/extensions/tests/supervisor.test.ts index 15db920e..91f25f1e 100644 --- a/extensions/tests/supervisor.test.ts +++ b/extensions/tests/supervisor.test.ts @@ -17,7 +17,15 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import { expect } from "./expect.ts"; -import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; import { join, dirname } from "path"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; @@ -127,10 +135,38 @@ function makePersistedBatchState(overrides?: Partial): Pers wavePlan: [["T-001", "T-002"], ["T-003"], ["T-004"]], lanes: [], tasks: [ - { taskId: "T-001", status: "succeeded", laneNumber: 1, waveIndex: 0, startedAt: 0, endedAt: 0 } as any, - { taskId: "T-002", status: "failed", laneNumber: 2, waveIndex: 0, startedAt: 0, endedAt: 0 } as any, - { taskId: "T-003", status: "running", laneNumber: 1, waveIndex: 1, startedAt: 0, endedAt: null } as any, - { taskId: "T-004", status: "pending", laneNumber: 0, waveIndex: 2, startedAt: 0, endedAt: null } as any, + { + taskId: "T-001", + status: "succeeded", + laneNumber: 1, + waveIndex: 0, + startedAt: 0, + endedAt: 0, + } as any, + { + taskId: "T-002", + status: "failed", + laneNumber: 2, + waveIndex: 0, + startedAt: 0, + endedAt: 0, + } as any, + { + taskId: "T-003", + status: "running", + laneNumber: 1, + waveIndex: 1, + startedAt: 0, + endedAt: null, + } as any, + { + taskId: "T-004", + status: "pending", + laneNumber: 0, + waveIndex: 2, + startedAt: 0, + endedAt: null, + } as any, ], mergeResults: [], totalTasks: 4, @@ -432,7 +468,11 @@ describe("2.x — Lockfile: write/read/remove + field validation", () => { expect(existsSync(dir)).toBe(false); writeLockfile(tmpDir, { - pid: 1, sessionId: "s", batchId: "b", startedAt: "t", heartbeat: "t", + pid: 1, + sessionId: "s", + batchId: "b", + startedAt: "t", + heartbeat: "t", }); expect(existsSync(dir)).toBe(true); @@ -552,7 +592,7 @@ describe("3.x — Heartbeat: isLockStale detection", () => { mock.timers.tick(HEARTBEAT_INTERVAL_MS + 5); // TP-070: heartbeat is now async — allow async I/O to settle mock.timers.reset(); - await new Promise(r => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 200)); const after = readLockfile(dir)?.heartbeat; expect(after).toBeDefined(); expect(after).not.toBe(before); @@ -780,8 +820,19 @@ describe("4.x — buildTakeoverSummary", () => { describe("5.x — Event JSONL parsing: parseJsonlLines", () => { it("5.1: parses complete JSONL lines", () => { - const line1 = JSON.stringify({ timestamp: "t1", type: "wave_start", batchId: "b1", waveIndex: 0 }); - const line2 = JSON.stringify({ timestamp: "t2", type: "task_complete", batchId: "b1", waveIndex: 0, taskId: "T-001" }); + const line1 = JSON.stringify({ + timestamp: "t1", + type: "wave_start", + batchId: "b1", + waveIndex: 0, + }); + const line2 = JSON.stringify({ + timestamp: "t2", + type: "task_complete", + batchId: "b1", + waveIndex: 0, + taskId: "T-001", + }); const data = line1 + "\n" + line2 + "\n"; const [events, remaining] = parseJsonlLines(data, ""); @@ -792,7 +843,12 @@ describe("5.x — Event JSONL parsing: parseJsonlLines", () => { }); it("5.2: handles partial lines (no trailing newline)", () => { - const line1 = JSON.stringify({ timestamp: "t1", type: "wave_start", batchId: "b1", waveIndex: 0 }); + const line1 = JSON.stringify({ + timestamp: "t1", + type: "wave_start", + batchId: "b1", + waveIndex: 0, + }); const partial = '{"timestamp":"t2","type":"task_com'; const data = line1 + "\n" + partial; @@ -838,8 +894,12 @@ describe("5.x — Event JSONL parsing: parseJsonlLines", () => { describe("5.x — formatEventNotification", () => { it("5.7: formats wave_start correctly", () => { const event = { - timestamp: "t", type: "wave_start" as any, batchId: "b", waveIndex: 1, - taskIds: ["T-1", "T-2", "T-3"], laneCount: 3, + timestamp: "t", + type: "wave_start" as any, + batchId: "b", + waveIndex: 1, + taskIds: ["T-1", "T-2", "T-3"], + laneCount: 3, }; const text = formatEventNotification(event, "supervised"); expect(text).toContain("Wave 2"); // waveIndex 1 = wave 2 @@ -850,8 +910,12 @@ describe("5.x — formatEventNotification", () => { it("5.8: formats merge_success correctly", () => { const event = { - timestamp: "t", type: "merge_success" as any, batchId: "b", waveIndex: 0, - testCount: 42, totalWaves: 3, + timestamp: "t", + type: "merge_success" as any, + batchId: "b", + waveIndex: 0, + testCount: 42, + totalWaves: 3, }; const text = formatEventNotification(event, "supervised"); expect(text).toContain("✅"); @@ -861,7 +925,10 @@ describe("5.x — formatEventNotification", () => { it("5.9: formats merge_failed differently for autonomous vs interactive", () => { const event = { - timestamp: "t", type: "merge_failed" as any, batchId: "b", waveIndex: 0, + timestamp: "t", + type: "merge_failed" as any, + batchId: "b", + waveIndex: 0, reason: "conflict in src/app.ts", }; @@ -874,8 +941,13 @@ describe("5.x — formatEventNotification", () => { it("5.10: formats batch_complete with summary", () => { const event = { - timestamp: "t", type: "batch_complete" as any, batchId: "b", waveIndex: -1, - succeededTasks: 10, failedTasks: 2, skippedTasks: 1, + timestamp: "t", + type: "batch_complete" as any, + batchId: "b", + waveIndex: -1, + succeededTasks: 10, + failedTasks: 2, + skippedTasks: 1, batchDurationMs: 3661000, // 1h 1m 1s }; const text = formatEventNotification(event, "supervised"); @@ -888,8 +960,12 @@ describe("5.x — formatEventNotification", () => { it("5.11: formats tier0_escalation with pattern and suggestion", () => { const event = { - timestamp: "t", type: "tier0_escalation" as any, batchId: "b", waveIndex: 0, - pattern: "WORKER_CRASH", suggestion: "Check lane 2 logs", + timestamp: "t", + type: "tier0_escalation" as any, + batchId: "b", + waveIndex: 0, + pattern: "WORKER_CRASH", + suggestion: "Check lane 2 logs", }; const interText = formatEventNotification(event, "interactive"); @@ -904,7 +980,10 @@ describe("5.x — formatEventNotification", () => { it("5.12: formats batch_paused differently by autonomy", () => { const event = { - timestamp: "t", type: "batch_paused" as any, batchId: "b", waveIndex: 0, + timestamp: "t", + type: "batch_paused" as any, + batchId: "b", + waveIndex: 0, reason: "merge conflict", }; @@ -919,7 +998,12 @@ describe("5.x — formatEventNotification", () => { describe("5.x — shouldNotify filtering", () => { it("5.13: always notifies for terminal/failure events regardless of autonomy", () => { - const criticalTypes = ["batch_complete", "batch_paused", "merge_failed", "tier0_escalation"] as const; + const criticalTypes = [ + "batch_complete", + "batch_paused", + "merge_failed", + "tier0_escalation", + ] as const; for (const type of criticalTypes) { expect(shouldNotify(type, "interactive")).toBe(true); expect(shouldNotify(type, "supervised")).toBe(true); @@ -943,26 +1027,50 @@ describe("5.x — shouldNotify filtering", () => { describe("5.x — formatTaskDigest", () => { it("5.16: returns null for empty buffer", () => { - const buf = { completed: [], failed: [], recoveryAttempts: 0, recoverySuccesses: 0, recoveryExhausted: 0 }; + const buf = { + completed: [], + failed: [], + recoveryAttempts: 0, + recoverySuccesses: 0, + recoveryExhausted: 0, + }; expect(formatTaskDigest(buf, "supervised")).toBeNull(); }); it("5.17: formats completed tasks", () => { - const buf = { completed: ["T-1", "T-2"], failed: [], recoveryAttempts: 0, recoverySuccesses: 0, recoveryExhausted: 0 }; + const buf = { + completed: ["T-1", "T-2"], + failed: [], + recoveryAttempts: 0, + recoverySuccesses: 0, + recoveryExhausted: 0, + }; const text = formatTaskDigest(buf, "supervised"); expect(text).not.toBeNull(); expect(text).toContain("2 task(s) completed"); }); it("5.18: interactive mode shows individual task IDs for completed", () => { - const buf = { completed: ["T-1", "T-2"], failed: [], recoveryAttempts: 0, recoverySuccesses: 0, recoveryExhausted: 0 }; + const buf = { + completed: ["T-1", "T-2"], + failed: [], + recoveryAttempts: 0, + recoverySuccesses: 0, + recoveryExhausted: 0, + }; const text = formatTaskDigest(buf, "interactive"); expect(text).toContain("T-1"); expect(text).toContain("T-2"); }); it("5.19: always shows failed task IDs", () => { - const buf = { completed: [], failed: ["T-3"], recoveryAttempts: 0, recoverySuccesses: 0, recoveryExhausted: 0 }; + const buf = { + completed: [], + failed: ["T-3"], + recoveryAttempts: 0, + recoverySuccesses: 0, + recoveryExhausted: 0, + }; const text = formatTaskDigest(buf, "autonomous"); expect(text).not.toBeNull(); expect(text).toContain("T-3"); @@ -970,7 +1078,13 @@ describe("5.x — formatTaskDigest", () => { }); it("5.20: formats recovery budget exhausted", () => { - const buf = { completed: [], failed: [], recoveryAttempts: 0, recoverySuccesses: 0, recoveryExhausted: 2 }; + const buf = { + completed: [], + failed: [], + recoveryAttempts: 0, + recoverySuccesses: 0, + recoveryExhausted: 2, + }; const text = formatTaskDigest(buf, "supervised"); expect(text).not.toBeNull(); expect(text).toContain("2 recovery budget(s) exhausted"); @@ -983,8 +1097,20 @@ describe("5.x — processEvents: batch-scoped filtering + routing", () => { tailer.batchId = "batch-A"; const events = [ - { timestamp: "t1", type: "wave_start" as any, batchId: "batch-A", waveIndex: 0, taskIds: ["T-1"] }, - { timestamp: "t2", type: "wave_start" as any, batchId: "batch-B", waveIndex: 0, taskIds: ["T-2"] }, + { + timestamp: "t1", + type: "wave_start" as any, + batchId: "batch-A", + waveIndex: 0, + taskIds: ["T-1"], + }, + { + timestamp: "t2", + type: "wave_start" as any, + batchId: "batch-B", + waveIndex: 0, + taskIds: ["T-2"], + }, ]; const notifications: string[] = []; @@ -999,7 +1125,13 @@ describe("5.x — processEvents: batch-scoped filtering + routing", () => { tailer.batchId = ""; const events = [ - { timestamp: "t1", type: "wave_start" as any, batchId: "batch-A", waveIndex: 0, taskIds: ["T-1"] }, + { + timestamp: "t1", + type: "wave_start" as any, + batchId: "batch-A", + waveIndex: 0, + taskIds: ["T-1"], + }, ]; const notifications: string[] = []; @@ -1015,8 +1147,20 @@ describe("5.x — processEvents: batch-scoped filtering + routing", () => { tailer.batchId = "batch-A"; const events = [ - { timestamp: "t1", type: "task_complete" as any, batchId: "batch-A", waveIndex: 0, taskId: "T-1" }, - { timestamp: "t2", type: "task_complete" as any, batchId: "batch-A", waveIndex: 0, taskId: "T-2" }, + { + timestamp: "t1", + type: "task_complete" as any, + batchId: "batch-A", + waveIndex: 0, + taskId: "T-1", + }, + { + timestamp: "t2", + type: "task_complete" as any, + batchId: "batch-A", + waveIndex: 0, + taskId: "T-2", + }, ]; const notifications: string[] = []; @@ -1062,8 +1206,10 @@ describe("5.x — readNewBytes + event tailer file operations", () => { it("5.26: readNewBytes reads from byte offset", () => { const path = join(tmpDir, "events.jsonl"); - const line1 = JSON.stringify({ timestamp: "t1", type: "wave_start", batchId: "b1", waveIndex: 0 }) + "\n"; - const line2 = JSON.stringify({ timestamp: "t2", type: "merge_success", batchId: "b1", waveIndex: 0 }) + "\n"; + const line1 = + JSON.stringify({ timestamp: "t1", type: "wave_start", batchId: "b1", waveIndex: 0 }) + "\n"; + const line2 = + JSON.stringify({ timestamp: "t2", type: "merge_success", batchId: "b1", waveIndex: 0 }) + "\n"; writeFileSync(path, line1 + line2, "utf-8"); @@ -1079,7 +1225,8 @@ describe("5.x — readNewBytes + event tailer file operations", () => { it("5.27: readNewBytes returns empty when no new data", () => { const path = join(tmpDir, "events.jsonl"); - const line1 = JSON.stringify({ timestamp: "t1", type: "wave_start", batchId: "b1", waveIndex: 0 }) + "\n"; + const line1 = + JSON.stringify({ timestamp: "t1", type: "wave_start", batchId: "b1", waveIndex: 0 }) + "\n"; writeFileSync(path, line1, "utf-8"); const fileSize = Buffer.byteLength(line1, "utf-8"); @@ -1155,14 +1302,24 @@ describe("6.x — Audit trail: appendAuditEntry + readAuditTrail", () => { it("6.2: appendAuditEntry appends multiple entries", () => { appendAuditEntry(tmpDir, { - ts: "t1", action: "read_state", classification: "diagnostic", - context: "checking batch state", command: "read batch-state.json", - result: "success", detail: "ok", batchId: "b1", + ts: "t1", + action: "read_state", + classification: "diagnostic", + context: "checking batch state", + command: "read batch-state.json", + result: "success", + detail: "ok", + batchId: "b1", }); appendAuditEntry(tmpDir, { - ts: "t2", action: "kill_session", classification: "destructive", - context: "stale session", command: "tmux kill-session -t lane-2", - result: "pending", detail: "", batchId: "b1", + ts: "t2", + action: "kill_session", + classification: "destructive", + context: "stale session", + command: "tmux kill-session -t lane-2", + result: "pending", + detail: "", + batchId: "b1", }); const entries = readAuditTrail(tmpDir); @@ -1178,13 +1335,23 @@ describe("6.x — Audit trail: appendAuditEntry + readAuditTrail", () => { it("6.4: readAuditTrail filters by batchId", () => { appendAuditEntry(tmpDir, { - ts: "t1", action: "a1", classification: "diagnostic", - context: "c", command: "cmd", result: "success", detail: "d", + ts: "t1", + action: "a1", + classification: "diagnostic", + context: "c", + command: "cmd", + result: "success", + detail: "d", batchId: "batch-A", }); appendAuditEntry(tmpDir, { - ts: "t2", action: "a2", classification: "diagnostic", - context: "c", command: "cmd", result: "success", detail: "d", + ts: "t2", + action: "a2", + classification: "diagnostic", + context: "c", + command: "cmd", + result: "success", + detail: "d", batchId: "batch-B", }); @@ -1196,8 +1363,13 @@ describe("6.x — Audit trail: appendAuditEntry + readAuditTrail", () => { it("6.5: readAuditTrail respects limit (tail)", () => { for (let i = 0; i < 10; i++) { appendAuditEntry(tmpDir, { - ts: `t${i}`, action: `action-${i}`, classification: "diagnostic", - context: "c", command: "cmd", result: "success", detail: "d", + ts: `t${i}`, + action: `action-${i}`, + classification: "diagnostic", + context: "c", + command: "cmd", + result: "success", + detail: "d", batchId: "b1", }); } @@ -1214,7 +1386,11 @@ describe("6.x — Audit trail: appendAuditEntry + readAuditTrail", () => { const dir = join(tmpDir, ".pi", "supervisor"); mkdirSync(dir, { recursive: true }); const path = join(dir, "actions.jsonl"); - writeFileSync(path, '{"ts":"t1","action":"a1","classification":"diagnostic","context":"c","command":"cmd","result":"success","detail":"d","batchId":"b1"}\nnot-json\n{"ts":"t2","action":"a2","classification":"diagnostic","context":"c","command":"cmd","result":"success","detail":"d","batchId":"b1"}\n', "utf-8"); + writeFileSync( + path, + '{"ts":"t1","action":"a1","classification":"diagnostic","context":"c","command":"cmd","result":"success","detail":"d","batchId":"b1"}\nnot-json\n{"ts":"t2","action":"a2","classification":"diagnostic","context":"c","command":"cmd","result":"success","detail":"d","batchId":"b1"}\n', + "utf-8", + ); const entries = readAuditTrail(tmpDir); expect(entries).toHaveLength(2); @@ -1260,10 +1436,18 @@ describe("6.x — Audit trail: appendAuditEntry + readAuditTrail", () => { it("6.10: audit entry supports optional fields (waveIndex, laneNumber, taskId, durationMs)", () => { appendAuditEntry(tmpDir, { - ts: "t1", action: "merge_retry", classification: "tier0_known", - context: "wave 2 merge timeout", command: "git merge", - result: "success", detail: "ok", batchId: "b1", - waveIndex: 1, laneNumber: 3, taskId: "T-005", durationMs: 4500, + ts: "t1", + action: "merge_retry", + classification: "tier0_known", + context: "wave 2 merge timeout", + command: "git merge", + result: "success", + detail: "ok", + batchId: "b1", + waveIndex: 1, + laneNumber: 3, + taskId: "T-005", + durationMs: 4500, }); const entries = readAuditTrail(tmpDir); @@ -1462,7 +1646,10 @@ describe("9.x — Config integration", () => { }); it("9.3: settings-tui includes supervisor section", () => { - const settingsSource = readFileSync(join(__dirname, "..", "taskplane", "settings-tui.ts"), "utf-8").replace(/\r\n/g, "\n"); + const settingsSource = readFileSync( + join(__dirname, "..", "taskplane", "settings-tui.ts"), + "utf-8", + ).replace(/\r\n/g, "\n"); expect(settingsSource).toContain("supervisor"); }); }); diff --git a/extensions/tests/task-runner-review-skip.test.ts b/extensions/tests/task-runner-review-skip.test.ts index 4a2cf393..4b368fe5 100644 --- a/extensions/tests/task-runner-review-skip.test.ts +++ b/extensions/tests/task-runner-review-skip.test.ts @@ -133,14 +133,14 @@ describe("2.x: Edge cases", () => { }); it("2.5: Three-step task — only Step 1 is NOT low-risk", () => { - expect(isLowRiskStep(0, 3)).toBe(true); // first + expect(isLowRiskStep(0, 3)).toBe(true); // first expect(isLowRiskStep(1, 3)).toBe(false); // middle - expect(isLowRiskStep(2, 3)).toBe(true); // last + expect(isLowRiskStep(2, 3)).toBe(true); // last }); it("2.6: Large task (10 steps) — only first and last are low-risk", () => { - expect(isLowRiskStep(0, 10)).toBe(true); // first - expect(isLowRiskStep(9, 10)).toBe(true); // last + expect(isLowRiskStep(0, 10)).toBe(true); // first + expect(isLowRiskStep(9, 10)).toBe(true); // last // All middle steps for (let i = 1; i < 9; i++) { expect(isLowRiskStep(i, 10)).toBe(false); @@ -167,12 +167,20 @@ describe("3.x: Review gating decision matrix", () => { type ReviewDecision = "skip" | "review" | "no-gate"; - function planReviewDecision(reviewLevel: number, stepNumber: number, totalSteps: number): ReviewDecision { + function planReviewDecision( + reviewLevel: number, + stepNumber: number, + totalSteps: number, + ): ReviewDecision { if (reviewLevel < 1) return "no-gate"; return isLowRiskStep(stepNumber, totalSteps) ? "skip" : "review"; } - function codeReviewDecision(reviewLevel: number, stepNumber: number, totalSteps: number): ReviewDecision { + function codeReviewDecision( + reviewLevel: number, + stepNumber: number, + totalSteps: number, + ): ReviewDecision { if (reviewLevel < 2) return "no-gate"; return isLowRiskStep(stepNumber, totalSteps) ? "skip" : "review"; } diff --git a/extensions/tests/tier0-watchdog.test.ts b/extensions/tests/tier0-watchdog.test.ts index 8676f57e..08c1d6fb 100644 --- a/extensions/tests/tier0-watchdog.test.ts +++ b/extensions/tests/tier0-watchdog.test.ts @@ -22,10 +22,7 @@ import { join, dirname } from "path"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; -import { - emitTier0Event, - buildTier0EventBase, -} from "../taskplane/persistence.ts"; +import { emitTier0Event, buildTier0EventBase } from "../taskplane/persistence.ts"; import type { Tier0Event, Tier0EventType } from "../taskplane/persistence.ts"; @@ -61,8 +58,8 @@ function readEvents(stateRoot: string): Tier0Event[] { const content = readFileSync(eventsPath, "utf-8"); return content .split("\n") - .filter(line => line.trim().length > 0) - .map(line => JSON.parse(line) as Tier0Event); + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Tier0Event); } // ══════════════════════════════════════════════════════════════════════ @@ -212,11 +209,11 @@ describe("2.x — Retry exhaustion pauses batch with escalation event", () => { expect(mergeExhaustIdx).not.toBe(-1); // Search for merge_timeout pattern escalation expect(engineSource).toContain('"merge_timeout"'); - // Find the merge timeout handling section and verify escalation - const mergeSection = engineSource.substring( - engineSource.indexOf("applyMergeRetryLoop"), - ); - const mergeEscalation = mergeSection.match(/emitTier0Escalation.*merge_timeout/g); + // Find the merge timeout handling section and verify escalation. + // TP-193: Use [\s\S] (or normalize) so the formatter-induced newlines + // between `emitTier0Escalation(` and `merge_timeout` don't break the match. + const mergeSection = engineSource.substring(engineSource.indexOf("applyMergeRetryLoop")); + const mergeEscalation = mergeSection.match(/emitTier0Escalation[\s\S]*?merge_timeout/g); expect(mergeEscalation).not.toBeNull(); }); }); @@ -315,14 +312,19 @@ describe("2.7+ — Merge timeout triggers automatic retry (not immediate pause)" const failedResult = buildFailedMergeResult(0, "Unable to create lock file"); const succeededResult = buildSucceededMergeResult(0); - const outcome = await applyMergeRetryLoop(failedResult, 0, {}, { - performMerge: () => succeededResult, - persist: () => {}, - log: () => {}, - notify: () => {}, - updateMergeResult: () => {}, - sleep: () => {}, - }); + const outcome = await applyMergeRetryLoop( + failedResult, + 0, + {}, + { + performMerge: () => succeededResult, + persist: () => {}, + log: () => {}, + notify: () => {}, + updateMergeResult: () => {}, + sleep: () => {}, + }, + ); expect(outcome.kind).toBe("retry_succeeded"); if (outcome.kind === "retry_succeeded") { @@ -337,17 +339,14 @@ describe("2.7+ — Merge timeout triggers automatic retry (not immediate pause)" const retryCountByScope: Record = {}; // First call — will succeed on retry, consuming attempt 1 - const firstOutcome = await applyMergeRetryLoop( - failedResult, 0, retryCountByScope, - { - performMerge: () => buildFailedMergeResult(0, "Unable to create lock file"), - persist: () => {}, - log: () => {}, - notify: () => {}, - updateMergeResult: () => {}, - sleep: () => {}, - }, - ); + const firstOutcome = await applyMergeRetryLoop(failedResult, 0, retryCountByScope, { + performMerge: () => buildFailedMergeResult(0, "Unable to create lock file"), + persist: () => {}, + log: () => {}, + notify: () => {}, + updateMergeResult: () => {}, + sleep: () => {}, + }); // After first attempt fails again and second attempt also fails, // the loop should exhaust both attempts @@ -379,14 +378,22 @@ describe("2.7+ — Merge timeout triggers automatic retry (not immediate pause)" }; let performMergeCalled = false; - const outcome = await applyMergeRetryLoop(failedResult, 0, {}, { - performMerge: () => { performMergeCalled = true; return failedResult; }, - persist: () => {}, - log: () => {}, - notify: () => {}, - updateMergeResult: () => {}, - sleep: () => {}, - }); + const outcome = await applyMergeRetryLoop( + failedResult, + 0, + {}, + { + performMerge: () => { + performMergeCalled = true; + return failedResult; + }, + persist: () => {}, + log: () => {}, + notify: () => {}, + updateMergeResult: () => {}, + sleep: () => {}, + }, + ); expect(outcome.kind).toBe("no_retry"); expect(performMergeCalled).toBe(false); // No retry attempt made @@ -841,8 +848,9 @@ describe("8.x — Per-pattern exhaustion coverage", () => { // Find the section handling this pattern const sectionIdx = engineSource.indexOf(sourceSection); expect(sectionIdx).not.toBe(-1); - // From that section, find exhausted event - const section = engineSource.substring(sectionIdx, sectionIdx + 5000); + // From that section, find exhausted event. + // TP-193: Window bumped from 5000 to 8000 to absorb formatter re-wrapping. + const section = engineSource.substring(sectionIdx, sectionIdx + 8000); expect(section).toContain("tier0_recovery_exhausted"); }); @@ -850,8 +858,9 @@ describe("8.x — Per-pattern exhaustion coverage", () => { const engineSource = readSource("engine.ts"); const sectionIdx = engineSource.indexOf(sourceSection); expect(sectionIdx).not.toBe(-1); - const section = engineSource.substring(sectionIdx, sectionIdx + 5000); - expect(section).toContain("emitTier0Escalation("); + // TP-193: Window bumped from 5000 to 8000 to absorb formatter re-wrapping. + const section = engineSource.substring(sectionIdx, sectionIdx + 8000); + expect(section).toContainNormalized("emitTier0Escalation("); }); } diff --git a/extensions/tests/tmux-compat.test.ts b/extensions/tests/tmux-compat.test.ts index 39efea9d..7c160ca3 100644 --- a/extensions/tests/tmux-compat.test.ts +++ b/extensions/tests/tmux-compat.test.ts @@ -1,10 +1,7 @@ import { describe, it } from "node:test"; import { expect } from "./expect.ts"; -import { - normalizeLaneSessionAlias, - readLaneSessionAliases, -} from "../taskplane/tmux-compat.ts"; +import { normalizeLaneSessionAlias, readLaneSessionAliases } from "../taskplane/tmux-compat.ts"; describe("tmux compatibility shim (migration-only)", () => { describe("lane session alias", () => { diff --git a/extensions/tests/tmux-reference-guard.test.ts b/extensions/tests/tmux-reference-guard.test.ts index 6a5d72f1..acc14953 100644 --- a/extensions/tests/tmux-reference-guard.test.ts +++ b/extensions/tests/tmux-reference-guard.test.ts @@ -74,7 +74,7 @@ describe("TMUX reference guard", () => { "types/contracts", ]); - const files = parsed.byFile.map(entry => entry.file); + const files = parsed.byFile.map((entry) => entry.file); const sortedFiles = [...files].sort((a, b) => a.localeCompare(b)); expect(files).toEqual(sortedFiles); for (const file of files) { diff --git a/extensions/tests/transactional-merge.test.ts b/extensions/tests/transactional-merge.test.ts index 6d70779b..75367613 100644 --- a/extensions/tests/transactional-merge.test.ts +++ b/extensions/tests/transactional-merge.test.ts @@ -222,7 +222,10 @@ describe("2.x — Rollback: verification_new_failure triggers rollback", () => { // Successful rollback should NOT set blockAdvancement // Only failed rollback should set it const rolledBackSection = mergeSource.indexOf('txnStatus = "rolled_back"'); - const successRollbackSection = mergeSource.substring(rolledBackSection - 200, rolledBackSection + 200); + const successRollbackSection = mergeSource.substring( + rolledBackSection - 200, + rolledBackSection + 200, + ); // blockAdvancement should NOT appear in the successful rollback path expect(successRollbackSection).not.toContain("blockAdvancement = true"); }); @@ -371,7 +374,8 @@ describe("4.x — Transaction record persistence", () => { // Count calls to persistTransactionRecord const successCallCount = (mergeSource.match(/persistTransactionRecord\(txnRecord/g) || []).length; - const errorCallCount = (mergeSource.match(/persistTransactionRecord\(errorTxnRecord/g) || []).length; + const errorCallCount = (mergeSource.match(/persistTransactionRecord\(errorTxnRecord/g) || []) + .length; // Should be called at least once for success and once for error expect(successCallCount).toBeGreaterThanOrEqual(1); diff --git a/extensions/tests/ux-integrate-visibility.test.ts b/extensions/tests/ux-integrate-visibility.test.ts index 21be48a8..fa22034c 100644 --- a/extensions/tests/ux-integrate-visibility.test.ts +++ b/extensions/tests/ux-integrate-visibility.test.ts @@ -24,8 +24,14 @@ import type { OrchBatchRuntimeState } from "../taskplane/types.ts"; describe("1.x — orchBatchComplete integrate guidance", () => { it("1.1: includes /orch-integrate command when orch branch exists and tasks succeeded", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 3, 0, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 3, + 0, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); expect(msg).toContain("/orch-integrate"); expect(msg).toContain("/orch-integrate --pr"); @@ -33,8 +39,14 @@ describe("1.x — orchBatchComplete integrate guidance", () => { it("1.2: includes visual box separator for integrate guidance", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 3, 0, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 3, + 0, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); // Check for the box drawing characters expect(msg).toContain("┌─"); @@ -44,40 +56,62 @@ describe("1.x — orchBatchComplete integrate guidance", () => { it("1.3: shows orch branch name in integrate guidance", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 3, 0, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 3, + 0, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); expect(msg).toContain("orch/op-batch-123"); }); it("1.4: includes preview command with base branch", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 3, 0, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 3, + 0, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); expect(msg).toContain("git log main..orch/op-batch-123"); }); it("1.5: omits integrate guidance when no orch branch", () => { - const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 3, 0, 0, 0, 120, - ); + const msg = ORCH_MESSAGES.orchBatchComplete("batch-123", 3, 0, 0, 0, 120); expect(msg).not.toContain("/orch-integrate"); expect(msg).not.toContain("┌─"); }); it("1.6: omits integrate guidance when no succeeded tasks", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 0, 3, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 0, + 3, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); expect(msg).not.toContain("/orch-integrate"); }); it("1.7: shows failure guidance when tasks failed", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 2, 1, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 2, + 1, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); // Should have both failure guidance and integrate guidance (partial success) expect(msg).toContain("/orch-status"); @@ -86,8 +120,14 @@ describe("1.x — orchBatchComplete integrate guidance", () => { it("1.8: mentions working branch was not modified", () => { const msg = ORCH_MESSAGES.orchBatchComplete( - "batch-123", 3, 0, 0, 0, 120, - "orch/op-batch-123", "main", + "batch-123", + 3, + 0, + 0, + 0, + 120, + "orch/op-batch-123", + "main", ); expect(msg).toContain("main branch was not modified"); }); @@ -132,11 +172,7 @@ describe("2.x — branch protection detection", () => { succeededTasks: 3, failedTasks: 0, }; - const plan = buildIntegrationPlan( - batchState as OrchBatchRuntimeState, - process.cwd(), - "unknown", - ); + const plan = buildIntegrationPlan(batchState as OrchBatchRuntimeState, process.cwd(), "unknown"); expect(plan).not.toBeNull(); // TP-149: unknown protection now falls through to FF/merge instead of defaulting to PR expect(["ff", "merge"]).toContain(plan!.mode); @@ -168,13 +204,17 @@ describe("3.x — protection hint in merge failure messages", () => { deleteBatchState: () => {}, }; - const result = executeIntegration("ff", { - orchBranch: "orch/test", - baseBranch: "main", - batchId: "test-123", - currentBranch: "main", - notices: [], - }, deps); + const result = executeIntegration( + "ff", + { + orchBranch: "orch/test", + baseBranch: "main", + batchId: "test-123", + currentBranch: "main", + notices: [], + }, + deps, + ); expect(result.success).toBe(false); expect(result.error).toContain("--pr"); @@ -200,13 +240,17 @@ describe("3.x — protection hint in merge failure messages", () => { deleteBatchState: () => {}, }; - const result = executeIntegration("ff", { - orchBranch: "orch/test", - baseBranch: "main", - batchId: "test-123", - currentBranch: "main", - notices: [], - }, deps); + const result = executeIntegration( + "ff", + { + orchBranch: "orch/test", + baseBranch: "main", + batchId: "test-123", + currentBranch: "main", + notices: [], + }, + deps, + ); expect(result.success).toBe(false); expect(result.error).toContain("--pr"); @@ -232,13 +276,17 @@ describe("3.x — protection hint in merge failure messages", () => { deleteBatchState: () => {}, }; - const result = executeIntegration("merge", { - orchBranch: "orch/test", - baseBranch: "main", - batchId: "test-123", - currentBranch: "main", - notices: [], - }, deps); + const result = executeIntegration( + "merge", + { + orchBranch: "orch/test", + baseBranch: "main", + batchId: "test-123", + currentBranch: "main", + notices: [], + }, + deps, + ); expect(result.success).toBe(false); expect(result.error).toContain("--pr"); diff --git a/extensions/tests/verification-baseline.test.ts b/extensions/tests/verification-baseline.test.ts index 433a9250..93ca95d0 100644 --- a/extensions/tests/verification-baseline.test.ts +++ b/extensions/tests/verification-baseline.test.ts @@ -33,33 +33,22 @@ import { type VerificationBaseline, } from "../taskplane/verification.ts"; -import { - DEFAULT_ORCHESTRATOR_SECTION, -} from "../taskplane/config-schema.ts"; +import { DEFAULT_ORCHESTRATOR_SECTION } from "../taskplane/config-schema.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); // ── Helpers ────────────────────────────────────────────────────────── function readMergeTs(): string { - return readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + return readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); } function readEngineTs(): string { - return readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + return readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); } function readResumeTs(): string { - return readFileSync( - join(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + return readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); } /** Build a test fingerprint */ @@ -110,7 +99,7 @@ describe("merge.ts verification gating patterns (source verification)", () => { it("1.5: baseline capture failure → strict mode returns merge failure", () => { const source = readMergeTs(); // Strict mode on capture exception should return failure - expect(source).toContain('baseline capture failed — strict mode: failing merge'); + expect(source).toContain("baseline capture failed — strict mode: failing merge"); expect(source).toContain("Verification baseline capture failed (strict mode)"); }); @@ -228,9 +217,7 @@ describe("diffFingerprints with verification mode patterns", () => { }); it("5.2: genuinely new failure is detected", () => { - const baseline = [ - fp("test", "src/a.test.ts", "old test", "assertion_error", "old failure"), - ]; + const baseline = [fp("test", "src/a.test.ts", "old test", "assertion_error", "old failure")]; const postMerge = [ fp("test", "src/a.test.ts", "old test", "assertion_error", "old failure"), fp("test", "src/b.test.ts", "new test", "assertion_error", "new failure"), @@ -244,9 +231,7 @@ describe("diffFingerprints with verification mode patterns", () => { }); it("5.3: fixed failures detected when baseline failure disappears", () => { - const baseline = [ - fp("test", "src/a.test.ts", "was broken", "assertion_error", "old failure"), - ]; + const baseline = [fp("test", "src/a.test.ts", "was broken", "assertion_error", "old failure")]; const postMerge: TestFingerprint[] = []; const diff = diffFingerprints(baseline, postMerge); @@ -277,9 +262,7 @@ describe("diffFingerprints with verification mode patterns", () => { }); it("5.6: duplicates in postMerge are deduplicated before comparison", () => { - const baseline = [ - fp("test", "src/a.test.ts", "test", "assertion_error", "fail"), - ]; + const baseline = [fp("test", "src/a.test.ts", "test", "assertion_error", "fail")]; const postMerge = [ fp("test", "src/a.test.ts", "test", "assertion_error", "fail"), fp("test", "src/a.test.ts", "test", "assertion_error", "fail"), // duplicate @@ -291,12 +274,8 @@ describe("diffFingerprints with verification mode patterns", () => { }); it("5.7: different commandIds make otherwise identical fingerprints distinct", () => { - const baseline = [ - fp("test-unit", "src/a.test.ts", "test", "assertion_error", "fail"), - ]; - const postMerge = [ - fp("test-e2e", "src/a.test.ts", "test", "assertion_error", "fail"), - ]; + const baseline = [fp("test-unit", "src/a.test.ts", "test", "assertion_error", "fail")]; + const postMerge = [fp("test-e2e", "src/a.test.ts", "test", "assertion_error", "fail")]; const diff = diffFingerprints(baseline, postMerge); expect(diff.newFailures).toHaveLength(1); @@ -323,14 +302,18 @@ describe("parseTestOutput for flaky rerun scenarios", () => { it("6.2: non-zero exit with failures produces fingerprints for diff", () => { const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/math.test.ts", - assertionResults: [{ - fullName: "math > should add", - status: "failed", - failureMessages: ["AssertionError: expected 2 to be 3"], - }], - }], + testResults: [ + { + name: "src/math.test.ts", + assertionResults: [ + { + fullName: "math > should add", + status: "failed", + failureMessages: ["AssertionError: expected 2 to be 3"], + }, + ], + }, + ], }); const result: CommandResult = { diff --git a/extensions/tests/verification-mode.test.ts b/extensions/tests/verification-mode.test.ts index 7d8023fb..43ade5e7 100644 --- a/extensions/tests/verification-mode.test.ts +++ b/extensions/tests/verification-mode.test.ts @@ -31,10 +31,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); /** Load merge.ts source for pattern verification */ function getMergeSource(): string { - return readFileSync( - join(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + return readFileSync(join(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); } // ── 1. Feature Flag Gating (verification.enabled) ─────────────────── @@ -71,9 +68,7 @@ describe("verification.enabled feature flag gating (TP-032)", () => { expect(hasTestingLine).not.toBeNull(); // And that verificationEnabled is a SEPARATE variable read from config - const enabledLine = source.match( - /const verificationEnabled\s*=\s*config\.verification\.enabled/, - ); + const enabledLine = source.match(/const verificationEnabled\s*=\s*config\.verification\.enabled/); expect(enabledLine).not.toBeNull(); }); }); @@ -93,9 +88,7 @@ describe("strict mode: enabled + no commands → merge failure (TP-032)", () => it("2.2: strict mode failure includes diagnostic reason", () => { const source = getMergeSource(); // The failure reason must include clear context about why it failed - expect(source).toContain( - "Verification enabled (strict mode) but no testing commands configured", - ); + expect(source).toContain("Verification enabled (strict mode) but no testing commands configured"); }); it("2.3: strict mode cleans up worktree before returning failure", () => { @@ -134,17 +127,13 @@ describe("strict mode: enabled + no commands → merge failure (TP-032)", () => describe("permissive mode: enabled + no commands → continue (TP-032)", () => { it("3.1: permissive mode with no commands logs warning and continues", () => { const source = getMergeSource(); - expect(source).toContain( - "permissive mode: continuing without verification", - ); + expect(source).toContain("permissive mode: continuing without verification"); }); it("3.2: permissive mode does NOT return failure when no commands configured", () => { const source = getMergeSource(); // Find the permissive no-commands path - const permissiveNoCommands = source.indexOf( - "permissive mode: continuing without verification", - ); + const permissiveNoCommands = source.indexOf("permissive mode: continuing without verification"); expect(permissiveNoCommands).toBeGreaterThan(-1); // After this log message, there should NOT be an immediate return statement @@ -212,9 +201,7 @@ describe("flakyReruns configuration wiring (TP-032)", () => { const source = getMergeSource(); expect(source).toContain("config.verification.flaky_reruns"); // And stored in a local variable - const flakyLine = source.match( - /const flakyReruns\s*=\s*config\.verification\.flaky_reruns/, - ); + const flakyLine = source.match(/const flakyReruns\s*=\s*config\.verification\.flaky_reruns/); expect(flakyLine).not.toBeNull(); }); @@ -278,10 +265,7 @@ describe("flakyReruns configuration wiring (TP-032)", () => { describe("engine.ts and resume.ts verification_new_failure handling (TP-032)", () => { it("6.1: engine.ts excludes verification_new_failure lanes from success counts", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // TP-032 R006-3 comment expect(engineSource).toContain("TP-032 R006-3"); expect(engineSource).toContain("verification_new_failure"); @@ -290,20 +274,16 @@ describe("engine.ts and resume.ts verification_new_failure handling (TP-032)", ( }); it("6.2: engine.ts excludes verification_new_failure lanes from branch cleanup", () => { - const engineSource = readFileSync( - join(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSource = readFileSync(join(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // Branch cleanup must check !lr.error before deleting branches - const branchCleanupComment = engineSource.indexOf("Exclude verification_new_failure lanes from branch cleanup"); + const branchCleanupComment = engineSource.indexOf( + "Exclude verification_new_failure lanes from branch cleanup", + ); expect(branchCleanupComment).toBeGreaterThan(-1); }); it("6.3: resume.ts handles verification_new_failure lanes consistently", () => { - const resumeSource = readFileSync( - join(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + const resumeSource = readFileSync(join(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); // Resume path must also handle verification failures expect(resumeSource).toContain("!lr.error"); }); diff --git a/extensions/tests/verification-step4.test.ts b/extensions/tests/verification-step4.test.ts index d55a80f5..c72c1d08 100644 --- a/extensions/tests/verification-step4.test.ts +++ b/extensions/tests/verification-step4.test.ts @@ -66,12 +66,14 @@ function cmdResult(overrides: Partial & { commandId: string }): C describe("R009-1: Parser edge cases — suite-level vitest failures", () => { it("1.1: suite-level failure with no assertionResults emits runtime_error fingerprint", () => { const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/broken.test.ts", - status: "failed", - message: "Cannot find module './missing'", - assertionResults: [], - }], + testResults: [ + { + name: "src/broken.test.ts", + status: "failed", + message: "Cannot find module './missing'", + assertionResults: [], + }, + ], }); const fps = parseVitestOutput("test", vitestOutput); @@ -86,11 +88,13 @@ describe("R009-1: Parser edge cases — suite-level vitest failures", () => { it("1.2: suite-level failure with undefined assertionResults emits runtime_error", () => { const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/setup-crash.test.ts", - status: "failed", - message: "SyntaxError: Unexpected token", - }], + testResults: [ + { + name: "src/setup-crash.test.ts", + status: "failed", + message: "SyntaxError: Unexpected token", + }, + ], }); const fps = parseVitestOutput("test", vitestOutput); @@ -104,16 +108,20 @@ describe("R009-1: Parser edge cases — suite-level vitest failures", () => { // Edge case: file-level status = "failed" but all assertions passed // (e.g., afterAll hook failure) const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/hook-crash.test.ts", - status: "failed", - message: "afterAll hook failed", - assertionResults: [{ - fullName: "should pass", - status: "passed", - failureMessages: [], - }], - }], + testResults: [ + { + name: "src/hook-crash.test.ts", + status: "failed", + message: "afterAll hook failed", + assertionResults: [ + { + fullName: "should pass", + status: "passed", + failureMessages: [], + }, + ], + }, + ], }); const fps = parseVitestOutput("test", vitestOutput); @@ -126,16 +134,20 @@ describe("R009-1: Parser edge cases — suite-level vitest failures", () => { it("1.4: suite-level failure with failed assertionResults does NOT emit extra suite fingerprint", () => { // When we already have assertion-level failures, don't add a redundant suite fingerprint const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/mixed.test.ts", - status: "failed", - message: "Some tests failed", - assertionResults: [{ - fullName: "should add", + testResults: [ + { + name: "src/mixed.test.ts", status: "failed", - failureMessages: ["AssertionError: expected 2 to be 3"], - }], - }], + message: "Some tests failed", + assertionResults: [ + { + fullName: "should add", + status: "failed", + failureMessages: ["AssertionError: expected 2 to be 3"], + }, + ], + }, + ], }); const fps = parseVitestOutput("test", vitestOutput); @@ -147,12 +159,14 @@ describe("R009-1: Parser edge cases — suite-level vitest failures", () => { it("1.5: suite-level failure with no message uses fallback message", () => { const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/mystery.test.ts", - status: "failed", - // No message field at all - assertionResults: [], - }], + testResults: [ + { + name: "src/mystery.test.ts", + status: "failed", + // No message field at all + assertionResults: [], + }, + ], }); const fps = parseVitestOutput("test", vitestOutput); @@ -191,15 +205,19 @@ describe("R009-1: Parser edge cases — non-zero exit with empty parsed output it("1.7: non-zero exit with valid JSON but no failures falls back to command_error", () => { // Vitest JSON is valid but testResults has no failed entries const vitestOutput = JSON.stringify({ - testResults: [{ - name: "src/ok.test.ts", - status: "passed", - assertionResults: [{ - fullName: "should work", + testResults: [ + { + name: "src/ok.test.ts", status: "passed", - failureMessages: [], - }], - }], + assertionResults: [ + { + fullName: "should work", + status: "passed", + failureMessages: [], + }, + ], + }, + ], }); const result = cmdResult({ @@ -273,7 +291,15 @@ describe("R009-1: Parser edge cases — non-zero exit with empty parsed output const result = cmdResult({ commandId: "test", exitCode: -1, - stdout: JSON.stringify({ testResults: [{ name: "a.ts", status: "failed", assertionResults: [{ fullName: "x", status: "failed", failureMessages: ["fail"] }] }] }), + stdout: JSON.stringify({ + testResults: [ + { + name: "a.ts", + status: "failed", + assertionResults: [{ fullName: "x", status: "failed", failureMessages: ["fail"] }], + }, + ], + }), stderr: "", error: "Spawn error: ENOENT", }); @@ -326,7 +352,7 @@ describe("R009-2: Rollback/advancement safety — merge.ts (source verification) it("2.5: blockAdvancement prevents anySuccess determination", () => { // anySuccess must check !blockAdvancement first - expect(mergeSource).toContain("const anySuccess = !blockAdvancement &&"); + expect(mergeSource).toContainNormalized("const anySuccess = !blockAdvancement &&"); }); it("2.6: blockAdvancement true logs branch advancement BLOCKED message", () => { @@ -335,7 +361,7 @@ describe("R009-2: Rollback/advancement safety — merge.ts (source verification) it("2.7: verification_new_failure sets laneResult.error", () => { // The lane error must be set so engine.ts/resume.ts can filter it - expect(mergeSource).toContain('laneResult.error = `verification_new_failure:'); + expect(mergeSource).toContain("laneResult.error = `verification_new_failure:"); }); it("2.8: verification_new_failure sets failedLane and failureReason", () => { @@ -375,8 +401,12 @@ describe("R009-2: Engine.ts counting + cleanup parity (source verification)", () // Both engine.ts and merge.ts should use the same success determination pattern const mergeSource = readSource("merge.ts"); // Both should have: !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED") - expect(engineSource).toContain('!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")'); - expect(mergeSource).toContain('!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")'); + expect(engineSource).toContain( + '!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")', + ); + expect(mergeSource).toContain( + '!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")', + ); }); }); @@ -397,8 +427,12 @@ describe("R009-2: Resume.ts counting + cleanup parity (source verification)", () it("2.15: resume.ts anySuccess pattern matches engine.ts pattern", () => { const engineSource = readSource("engine.ts"); // Both should use the same success determination pattern - expect(resumeSource).toContain('!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")'); - expect(engineSource).toContain('!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")'); + expect(resumeSource).toContain( + '!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")', + ); + expect(engineSource).toContain( + '!r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED")', + ); }); }); @@ -427,7 +461,9 @@ describe("R009-3: Workspace mode artifact naming — per-repo repoId suffix", () }); it("3.4: post-merge file naming pattern includes repoSuffix and laneNumber", () => { - expect(mergeSource).toContain("`post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`"); + expect(mergeSource).toContain( + "`post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`", + ); }); it("3.5: repoId parameter is threaded to mergeWave from mergeWaveByRepo", () => { @@ -447,7 +483,9 @@ describe("R009-3: Workspace mode artifact naming — per-repo repoId suffix", () const byRepoSection = mergeSource.indexOf("mergeWaveByRepo"); expect(byRepoSection).toBeGreaterThan(-1); const afterByRepo = mergeSource.slice(byRepoSection); - expect(afterByRepo).toContain("Exclude verification_new_failure lanes from success determination"); + expect(afterByRepo).toContain( + "Exclude verification_new_failure lanes from success determination", + ); }); }); @@ -473,9 +511,7 @@ describe("Diff algorithm comprehensive tests", () => { }); it("4.2: new failures correctly detected when mixed with pre-existing", () => { - const baseline = [ - fp("test", "src/old.test.ts", "old failure", "assertion_error", "old msg"), - ]; + const baseline = [fp("test", "src/old.test.ts", "old failure", "assertion_error", "old msg")]; const postMerge = [ fp("test", "src/old.test.ts", "old failure", "assertion_error", "old msg"), fp("test", "src/new.test.ts", "new regression", "assertion_error", "new msg"), @@ -492,9 +528,7 @@ describe("Diff algorithm comprehensive tests", () => { fp("test", "src/a.test.ts", "was broken", "assertion_error", "fixed now"), fp("test", "src/b.test.ts", "still broken", "assertion_error", "still bad"), ]; - const postMerge = [ - fp("test", "src/b.test.ts", "still broken", "assertion_error", "still bad"), - ]; + const postMerge = [fp("test", "src/b.test.ts", "still broken", "assertion_error", "still bad")]; const diff = diffFingerprints(baseline, postMerge); expect(diff.newFailures).toHaveLength(0); @@ -520,9 +554,7 @@ describe("Diff algorithm comprehensive tests", () => { }); it("4.5: composite key uses all five fields — same file/case but different kind is new", () => { - const baseline = [ - fp("test", "a.ts", "test1", "assertion_error", "msg"), - ]; + const baseline = [fp("test", "a.ts", "test1", "assertion_error", "msg")]; const postMerge = [ fp("test", "a.ts", "test1", "runtime_error", "msg"), // different kind ]; @@ -533,9 +565,7 @@ describe("Diff algorithm comprehensive tests", () => { }); it("4.6: composite key uses all five fields — same file/case but different commandId is new", () => { - const baseline = [ - fp("unit", "a.ts", "test1", "assertion_error", "msg"), - ]; + const baseline = [fp("unit", "a.ts", "test1", "assertion_error", "msg")]; const postMerge = [ fp("e2e", "a.ts", "test1", "assertion_error", "msg"), // different commandId ]; @@ -546,9 +576,7 @@ describe("Diff algorithm comprehensive tests", () => { }); it("4.7: composite key uses all five fields — same except messageNorm is new", () => { - const baseline = [ - fp("test", "a.ts", "test1", "assertion_error", "expected 1 to be 2"), - ]; + const baseline = [fp("test", "a.ts", "test1", "assertion_error", "expected 1 to be 2")]; const postMerge = [ fp("test", "a.ts", "test1", "assertion_error", "expected 1 to be 3"), // different msg ]; @@ -614,13 +642,15 @@ describe("Flaky handling: flakyReruns control paths (source verification)", () = it("5.4: flaky re-run only re-runs commands that produced new failures", () => { // Must extract failed commandIds from diff.newFailures expect(mergeSource).toContain("failedCommandIds"); - expect(mergeSource).toContain("diff.newFailures.map(fp => fp.commandId)"); + expect(mergeSource).toContainNormalized("diff.newFailures.map((fp) => fp.commandId)"); }); it("5.5: flaky re-run re-diffs against baseline (not full post-merge)", () => { // The re-run diff should compare baseline against re-run, not against original post-merge expect(mergeSource).toContain("baselineForRerun"); - expect(mergeSource).toContain("baseline.fingerprints.filter(fp => failedCommandIds.has(fp.commandId))"); + expect(mergeSource).toContainNormalized( + "baseline.fingerprints.filter((fp) => failedCommandIds.has(fp.commandId))", + ); }); it("5.6: flakyReruns > 1 iterates up to N times with early break", () => { @@ -662,14 +692,18 @@ describe("Mode behavior: strict/permissive (source verification)", () => { it("6.3: strict mode on baseline capture failure → returns merge failure", () => { expect(mergeSource).toContain("Verification baseline capture failed (strict mode):"); - const captureFailStrict = mergeSource.indexOf("baseline capture failed — strict mode: failing merge"); + const captureFailStrict = mergeSource.indexOf( + "baseline capture failed — strict mode: failing merge", + ); expect(captureFailStrict).toBeGreaterThan(-1); const afterCaptureFail = mergeSource.slice(captureFailStrict, captureFailStrict + 500); expect(afterCaptureFail).toContain('status: "failed"'); }); it("6.4: permissive mode on baseline capture failure → sets baseline = null, continues", () => { - const permCaptureFail = mergeSource.indexOf("permissive mode: continuing without baseline verification"); + const permCaptureFail = mergeSource.indexOf( + "permissive mode: continuing without baseline verification", + ); expect(permCaptureFail).toBeGreaterThan(-1); const afterPermCapture = mergeSource.slice(permCaptureFail, permCaptureFail + 500); expect(afterPermCapture).toContain("baseline = null"); diff --git a/extensions/tests/waves-repo-scoped.test.ts b/extensions/tests/waves-repo-scoped.test.ts index dcbdf6a2..06889d80 100644 --- a/extensions/tests/waves-repo-scoped.test.ts +++ b/extensions/tests/waves-repo-scoped.test.ts @@ -31,15 +31,13 @@ import { } from "../taskplane/waves.ts"; import { buildSegmentFrontierWaves } from "../taskplane/engine.ts"; -import type { - WorkspaceConfig, - WorkspaceRepoConfig, - ParsedTask, -} from "../taskplane/types.ts"; +import type { WorkspaceConfig, WorkspaceRepoConfig, ParsedTask } from "../taskplane/types.ts"; // ── Test Helpers ────────────────────────────────────────────────────── -function makeWorkspaceConfig(repos: Record): WorkspaceConfig { +function makeWorkspaceConfig( + repos: Record, +): WorkspaceConfig { const repoMap = new Map(); for (const [id, cfg] of Object.entries(repos)) { repoMap.set(id, { id, path: cfg.path, defaultBranch: cfg.defaultBranch }); @@ -237,7 +235,9 @@ describe("generateLaneSessionId", () => { it("generates workspace-mode format with opId when repoId is set", () => { expect(generateLaneSessionId("orch", 1, "henrylach", "api")).toBe("orch-henrylach-api-lane-1"); - expect(generateLaneSessionId("orch", 2, "ci-runner", "frontend")).toBe("orch-ci-runner-frontend-lane-2"); + expect(generateLaneSessionId("orch", 2, "ci-runner", "frontend")).toBe( + "orch-ci-runner-frontend-lane-2", + ); }); it("uses custom prefix with opId", () => { @@ -306,8 +306,14 @@ describe("segment planning", () => { it("falls back to singleton repo segment when there are no multi-repo signals", () => { const pending = new Map([ - ["TP-901", makeParsedTask("TP-901", { resolvedRepoId: "backend", fileScope: [], dependencies: [] })], - ["TP-902", makeParsedTask("TP-902", { fileScope: ["src/index.ts", "lib/util.ts"], dependencies: [] })], + [ + "TP-901", + makeParsedTask("TP-901", { resolvedRepoId: "backend", fileScope: [], dependencies: [] }), + ], + [ + "TP-902", + makeParsedTask("TP-902", { fileScope: ["src/index.ts", "lib/util.ts"], dependencies: [] }), + ], ]); const plans = buildTaskSegmentPlans(pending); @@ -486,14 +492,16 @@ describe("TP-166 global lane cap regression", () => { globalLane: offset + i, localLane: i, repoId, - assignments: [{ - taskId, - lane: i, - task: makeParsedTask(taskId, { - resolvedRepoId: repoId, - fileScope: [`${repoId}/src/module${i}.ts`], - }), - }], + assignments: [ + { + taskId, + lane: i, + task: makeParsedTask(taskId, { + resolvedRepoId: repoId, + fileScope: [`${repoId}/src/module${i}.ts`], + }), + }, + ], }); } offset += 4; @@ -507,15 +515,15 @@ describe("TP-166 global lane cap regression", () => { expect(entries.length).toBe(4); // All 12 task IDs should still be present - const allTaskIds = entries.flatMap(e => e.assignments.map(a => a.taskId)).sort(); + const allTaskIds = entries.flatMap((e) => e.assignments.map((a) => a.taskId)).sort(); expect(allTaskIds.length).toBe(12); // Each repo should have at least 1 lane - const repoIds = new Set(entries.map(e => e.repoId)); + const repoIds = new Set(entries.map((e) => e.repoId)); expect(repoIds.size).toBe(3); // Global lane numbers should be sequential 1..4 - expect(entries.map(e => e.globalLane)).toEqual([1, 2, 3, 4]); + expect(entries.map((e) => e.globalLane)).toEqual([1, 2, 3, 4]); }); it("single-repo mode (no repoId) stays within maxLanes", () => { @@ -531,18 +539,20 @@ describe("TP-166 global lane cap regression", () => { globalLane: i, localLane: i, repoId: undefined, - assignments: [{ - taskId: `TP-${String(i).padStart(3, '0')}`, - lane: i, - task: makeParsedTask(`TP-${String(i).padStart(3, '0')}`), - }], + assignments: [ + { + taskId: `TP-${String(i).padStart(3, "0")}`, + lane: i, + task: makeParsedTask(`TP-${String(i).padStart(3, "0")}`), + }, + ], }); } enforceGlobalLaneCap(entries, 3); expect(entries.length).toBe(3); - const allTaskIds = entries.flatMap(e => e.assignments.map(a => a.taskId)).sort(); + const allTaskIds = entries.flatMap((e) => e.assignments.map((a) => a.taskId)).sort(); expect(allTaskIds.length).toBe(6); }); }); @@ -554,14 +564,58 @@ describe("TP-166 wave count regression", () => { // Wave 2: TP-004 (depends on 001,002), TP-005 (depends on 002,003) // Wave 3: TP-006 (depends on 004), TP-007 (depends on 004,005), TP-008 (depends on 005) const pending = new Map(); - pending.set("TP-001", makeParsedTask("TP-001", { resolvedRepoId: "api", fileScope: ["api/src/a.ts"] })); - pending.set("TP-002", makeParsedTask("TP-002", { resolvedRepoId: "web", fileScope: ["web/src/b.ts"] })); - pending.set("TP-003", makeParsedTask("TP-003", { resolvedRepoId: "api", fileScope: ["api/src/c.ts"] })); - pending.set("TP-004", makeParsedTask("TP-004", { resolvedRepoId: "api", dependencies: ["TP-001", "TP-002"], fileScope: ["api/src/d.ts", "web/src/d.ts"] })); - pending.set("TP-005", makeParsedTask("TP-005", { resolvedRepoId: "web", dependencies: ["TP-002", "TP-003"], fileScope: ["web/src/e.ts", "api/src/e.ts"] })); - pending.set("TP-006", makeParsedTask("TP-006", { resolvedRepoId: "api", dependencies: ["TP-004"], fileScope: ["api/src/f.ts"] })); - pending.set("TP-007", makeParsedTask("TP-007", { resolvedRepoId: "web", dependencies: ["TP-004", "TP-005"], fileScope: ["web/src/g.ts", "api/src/g.ts"] })); - pending.set("TP-008", makeParsedTask("TP-008", { resolvedRepoId: "api", dependencies: ["TP-005"], fileScope: ["api/src/h.ts", "web/src/h.ts"] })); + pending.set( + "TP-001", + makeParsedTask("TP-001", { resolvedRepoId: "api", fileScope: ["api/src/a.ts"] }), + ); + pending.set( + "TP-002", + makeParsedTask("TP-002", { resolvedRepoId: "web", fileScope: ["web/src/b.ts"] }), + ); + pending.set( + "TP-003", + makeParsedTask("TP-003", { resolvedRepoId: "api", fileScope: ["api/src/c.ts"] }), + ); + pending.set( + "TP-004", + makeParsedTask("TP-004", { + resolvedRepoId: "api", + dependencies: ["TP-001", "TP-002"], + fileScope: ["api/src/d.ts", "web/src/d.ts"], + }), + ); + pending.set( + "TP-005", + makeParsedTask("TP-005", { + resolvedRepoId: "web", + dependencies: ["TP-002", "TP-003"], + fileScope: ["web/src/e.ts", "api/src/e.ts"], + }), + ); + pending.set( + "TP-006", + makeParsedTask("TP-006", { + resolvedRepoId: "api", + dependencies: ["TP-004"], + fileScope: ["api/src/f.ts"], + }), + ); + pending.set( + "TP-007", + makeParsedTask("TP-007", { + resolvedRepoId: "web", + dependencies: ["TP-004", "TP-005"], + fileScope: ["web/src/g.ts", "api/src/g.ts"], + }), + ); + pending.set( + "TP-008", + makeParsedTask("TP-008", { + resolvedRepoId: "api", + dependencies: ["TP-005"], + fileScope: ["api/src/h.ts", "web/src/h.ts"], + }), + ); const completed = new Set(); const graph = buildDependencyGraph(pending, completed); @@ -606,8 +660,14 @@ describe("TP-166 wave count regression", () => { const pending = new Map(); pending.set("TP-001", makeParsedTask("TP-001", { resolvedRepoId: "default" })); pending.set("TP-002", makeParsedTask("TP-002", { resolvedRepoId: "default" })); - pending.set("TP-003", makeParsedTask("TP-003", { resolvedRepoId: "default", dependencies: ["TP-001"] })); - pending.set("TP-004", makeParsedTask("TP-004", { resolvedRepoId: "default", dependencies: ["TP-002"] })); + pending.set( + "TP-003", + makeParsedTask("TP-003", { resolvedRepoId: "default", dependencies: ["TP-001"] }), + ); + pending.set( + "TP-004", + makeParsedTask("TP-004", { resolvedRepoId: "default", dependencies: ["TP-002"] }), + ); const completed = new Set(); const graph = buildDependencyGraph(pending, completed); diff --git a/extensions/tests/windows-worktree-cleanup-behavioral.test.ts b/extensions/tests/windows-worktree-cleanup-behavioral.test.ts index 2f88e676..705c97c9 100644 --- a/extensions/tests/windows-worktree-cleanup-behavioral.test.ts +++ b/extensions/tests/windows-worktree-cleanup-behavioral.test.ts @@ -64,23 +64,21 @@ const execCalls: ExecCall[] = []; let currentHandler: ExecHandler = () => Buffer.from(""); const realChildProcess = await import("node:child_process"); -const mockExecFileSync = mock.fn( - (cmd: string, args?: readonly string[]): Buffer => { - const safeArgs = args ?? []; - execCalls.push({ cmd, args: safeArgs }); - const result = currentHandler(cmd, safeArgs); - if (Buffer.isBuffer(result)) return result; - const err = new Error("mocked subprocess failure") as Error & { - stderr?: Buffer; - stdout?: Buffer; - status?: number; - }; - err.stderr = Buffer.from(result.stderr); - err.stdout = Buffer.from(result.stdout ?? ""); - err.status = 1; - throw err; - }, -); +const mockExecFileSync = mock.fn((cmd: string, args?: readonly string[]): Buffer => { + const safeArgs = args ?? []; + execCalls.push({ cmd, args: safeArgs }); + const result = currentHandler(cmd, safeArgs); + if (Buffer.isBuffer(result)) return result; + const err = new Error("mocked subprocess failure") as Error & { + stderr?: Buffer; + stdout?: Buffer; + status?: number; + }; + err.stderr = Buffer.from(result.stderr); + err.stdout = Buffer.from(result.stdout ?? ""); + err.status = 1; + throw err; +}); mock.module("child_process", { namedExports: { @@ -141,7 +139,8 @@ function makeRepoRoot(): string { */ function porcelainList(paths: string[]): Buffer { const blocks = paths.map( - (p) => `worktree ${p}\nHEAD 0000000000000000000000000000000000000000\nbranch refs/heads/task/lane-1`, + (p) => + `worktree ${p}\nHEAD 0000000000000000000000000000000000000000\nbranch refs/heads/task/lane-1`, ); return Buffer.from(blocks.join("\n\n") + "\n"); } @@ -159,9 +158,7 @@ describe("TP-189-A4 — removeWorktree() Windows fallback decision branches", () if (cmd === "git" && args[0] === "worktree" && args[1] === "list") { listCallCount++; // Pre-removal: target IS registered. Post-prune: target is gone. - return listCallCount === 1 - ? porcelainList([wt.path]) - : Buffer.from(""); + return listCallCount === 1 ? porcelainList([wt.path]) : Buffer.from(""); } if (cmd === "git" && args[0] === "worktree" && args[1] === "remove") { return { kind: "throw", stderr: "error: failed to delete 'foo': Filename too long" }; @@ -199,10 +196,7 @@ describe("TP-189-A4 — removeWorktree() Windows fallback decision branches", () `expected exactly 1 cmd /c rd /s /q invocation, got ${cmdRdCalls.length}`, ); // And it must have used the documented arg shape with backslash-normalized path. - assert.deepStrictEqual( - cmdRdCalls[0].args.slice(0, 4), - ["/c", "rd", "/s", "/q"], - ); + assert.deepStrictEqual(cmdRdCalls[0].args.slice(0, 4), ["/c", "rd", "/s", "/q"]); assert.strictEqual( cmdRdCalls[0].args[4], wt.path.replace(/\//g, "\\"), diff --git a/extensions/tests/windows-worktree-cleanup-fallback.test.ts b/extensions/tests/windows-worktree-cleanup-fallback.test.ts index 00994047..8f60e6d7 100644 --- a/extensions/tests/windows-worktree-cleanup-fallback.test.ts +++ b/extensions/tests/windows-worktree-cleanup-fallback.test.ts @@ -127,9 +127,7 @@ describe("TP-188 sub-fix B (#543): worktree.ts source patterns", () => { const body = worktreeSrc.slice(fnStart, fnEnd > -1 ? fnEnd : undefined); // When the cmd rd fallback also fails, the operator should see both // the original git error and the rescue's stderr in the throw message. - expect(body).toMatch( - /git worktree remove failed[\s\S]{0,200}cmd rd[\s\S]{0,200}fallback failed/, - ); + expect(body).toMatch(/git worktree remove failed[\s\S]{0,200}cmd rd[\s\S]{0,200}fallback failed/); }); }); diff --git a/extensions/tests/worker-model.test.ts b/extensions/tests/worker-model.test.ts index 57f41f20..81974707 100644 --- a/extensions/tests/worker-model.test.ts +++ b/extensions/tests/worker-model.test.ts @@ -59,8 +59,11 @@ describe("buildWorkerEnv", () => { model: "gpt-4o", excludeExtensions: ["some-package"], }); - assert.strictEqual(result.TASKPLANE_WORKER_EXCLUDE_EXTENSIONS, undefined, - "buildWorkerEnv should not set exclude extensions — buildWorkerExcludeEnv owns that var"); + assert.strictEqual( + result.TASKPLANE_WORKER_EXCLUDE_EXTENSIONS, + undefined, + "buildWorkerEnv should not set exclude extensions — buildWorkerExcludeEnv owns that var", + ); }); it("handles all fields simultaneously", () => { diff --git a/extensions/tests/worker-step-completion-protocol.test.ts b/extensions/tests/worker-step-completion-protocol.test.ts index 8d43c40b..f98386ce 100644 --- a/extensions/tests/worker-step-completion-protocol.test.ts +++ b/extensions/tests/worker-step-completion-protocol.test.ts @@ -38,21 +38,19 @@ describe("1.x — task-worker.md prompt: TP-186 sections", () => { // Heading uses the warning emoji + "Order of Operations" phrase. expect(WORKER_PROMPT).toContain("Order of Operations for steps with code review"); // The MUST NOT prohibition that the entire fix hinges on. - expect(WORKER_PROMPT).toContain( - "Workers MUST NOT mark a step `Status: ✅ Complete`", - ); + expect(WORKER_PROMPT).toContain("Workers MUST NOT mark a step `Status: ✅ Complete`"); // 5–6 step numbered sequence: implement, commit, call review_step, // handle REVISE, mark Complete on APPROVE, move on. expect(WORKER_PROMPT).toContain("1. **Implement**"); expect(WORKER_PROMPT).toContain("2. **Commit**"); - expect(WORKER_PROMPT).toContain("3. **Call** `review_step(step=N, type=\"code\""); + expect(WORKER_PROMPT).toContain('3. **Call** `review_step(step=N, type="code"'); expect(WORKER_PROMPT).toContain("5. If the verdict is **APPROVE**"); expect(WORKER_PROMPT).toContain("6. **Move to step N+1.**"); }); it("1.2 — contains the Recovery Recipe with the keyword 'revert'", () => { expect(WORKER_PROMPT).toContain( - "Recovery: \"I marked the step Complete, then the reviewer returned REVISE\"", + 'Recovery: "I marked the step Complete, then the reviewer returned REVISE"', ); // The recipe must explicitly use "revert" — that's the operative verb // the engine guard's refusal message also points at. @@ -89,9 +87,7 @@ describe("1.x — task-worker.md prompt: TP-186 sections", () => { // where the step is NOT actually done until the code reviewer // returns APPROVE. The fix splits step 6 by Review Level. Guard // against accidental drift back to the pre-TP-189 wording. - const stepSixIdx = WORKER_PROMPT.indexOf( - "6. When a step's checkbox items are all checked", - ); + const stepSixIdx = WORKER_PROMPT.indexOf("6. When a step's checkbox items are all checked"); expect(stepSixIdx).toBeGreaterThan(-1); const stepSixEnd = WORKER_PROMPT.indexOf("\n7. ", stepSixIdx); expect(stepSixEnd).toBeGreaterThan(stepSixIdx); @@ -227,10 +223,7 @@ describe("2.x — isStepMarkedComplete helper", () => { // Worker queries step 99, which has no `### Step 99:` heading. // Must not refuse on unusual STATUS structures — the prompt-side // recipe is the primary defense. - const status = [ - "### Step 1: Only step", - "**Status:** ✅ Complete", - ].join("\n"); + const status = ["### Step 1: Only step", "**Status:** ✅ Complete"].join("\n"); withTempStatus(status, (statusPath) => { expect(isStepMarkedComplete(statusPath, 99)).toBe(false); }); @@ -454,7 +447,7 @@ describe("3.x — Recovery Recipe / refusal message wording consistency", () => // 3. Re-call — prompt wraps the line, but the operative phrase // `review_step(step=N, type="code")` again` is uninterrupted. expect(engineSrc).toContain("Re-call review_step"); - expect(WORKER_PROMPT).toContain("`review_step(step=N, type=\"code\")` again"); + expect(WORKER_PROMPT).toContain('`review_step(step=N, type="code")` again'); }); it("3.2 — engine refusal carries the literal token REFUSED and references the Order of Operations rule", () => { @@ -469,6 +462,6 @@ describe("3.x — Recovery Recipe / refusal message wording consistency", () => // reviews fire pre-implementation, when an empty STATUS is correct. const enginePath = join(REPO_ROOT, "extensions", "taskplane", "agent-bridge-extension.ts"); const engineSrc = readFileSync(enginePath, "utf-8"); - expect(engineSrc).toContain("if (reviewType !== \"plan\" && isStepMarkedComplete("); + expect(engineSrc).toContain('if (reviewType !== "plan" && isStepMarkedComplete('); }); }); diff --git a/extensions/tests/worker-tools-allowlist.test.ts b/extensions/tests/worker-tools-allowlist.test.ts index a7d9d1c4..534daa3b 100644 --- a/extensions/tests/worker-tools-allowlist.test.ts +++ b/extensions/tests/worker-tools-allowlist.test.ts @@ -28,38 +28,31 @@ import { describe("buildWorkerToolsAllowlist", () => { it("undefined input → returns DEFAULT_WORKER_USER_TOOLS + bridge tools", () => { const result = buildWorkerToolsAllowlist(undefined); - const expected = - DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); + const expected = DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); assert.strictEqual(result, expected); }); it("null input → same as undefined", () => { const result = buildWorkerToolsAllowlist(null); - const expected = - DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); + const expected = DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); assert.strictEqual(result, expected); }); it("empty string input → same as undefined", () => { const result = buildWorkerToolsAllowlist(""); - const expected = - DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); + const expected = DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); assert.strictEqual(result, expected); }); it("whitespace-only string input → same as undefined", () => { const result = buildWorkerToolsAllowlist(" \t "); - const expected = - DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); + const expected = DEFAULT_WORKER_USER_TOOLS + "," + ENGINE_BRIDGE_TOOLS.join(","); assert.strictEqual(result, expected); }); it("custom user tools → returns user tools + bridge tools (in order)", () => { const result = buildWorkerToolsAllowlist("read,write"); - assert.strictEqual( - result, - "read,write," + ENGINE_BRIDGE_TOOLS.join(","), - ); + assert.strictEqual(result, "read,write," + ENGINE_BRIDGE_TOOLS.join(",")); }); it("user tools that already include a bridge tool → no duplication", () => { @@ -104,8 +97,7 @@ describe("buildWorkerToolsAllowlist", () => { const result = buildWorkerToolsAllowlist("read,write,read,bash"); const tokens = result.split(","); const unique = new Set(tokens); - assert.strictEqual(tokens.length, unique.size, - "each tool should appear exactly once"); + assert.strictEqual(tokens.length, unique.size, "each tool should appear exactly once"); }); it("delimiter-only input → falls back to default user tools (regression for sage-flagged empty-list bug)", () => { @@ -147,10 +139,12 @@ describe("ENGINE_BRIDGE_TOOLS", () => { }); it("entries are exactly review_step, notify_supervisor, escalate_to_supervisor, request_segment_expansion", () => { - assert.deepStrictEqual( - [...ENGINE_BRIDGE_TOOLS].sort(), - ["escalate_to_supervisor", "notify_supervisor", "request_segment_expansion", "review_step"], - ); + assert.deepStrictEqual([...ENGINE_BRIDGE_TOOLS].sort(), [ + "escalate_to_supervisor", + "notify_supervisor", + "request_segment_expansion", + "review_step", + ]); }); it("each entry is registered as a tool name in agent-bridge-extension.ts", () => { diff --git a/extensions/tests/workspace-config.integration.test.ts b/extensions/tests/workspace-config.integration.test.ts index 8404d54c..41055038 100644 --- a/extensions/tests/workspace-config.integration.test.ts +++ b/extensions/tests/workspace-config.integration.test.ts @@ -19,13 +19,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import { expect } from "./expect.ts"; -import { - mkdirSync, - writeFileSync, - rmSync, - existsSync, - readFileSync, -} from "fs"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"; import { join, resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { execFileSync } from "child_process"; @@ -53,7 +47,6 @@ import { saveBatchState, loadBatchState, deleteBatchState } from "../taskplane/p // ── Test Fixtures ──────────────────────────────────────────────────── - const __dirname = dirname(fileURLToPath(import.meta.url)); let testRoot: string; let counter = 0; @@ -132,7 +125,10 @@ const mockLoadRunnerConfig = (_root: string, _pointerConfigRoot?: string) => moc // ── Setup / Teardown ───────────────────────────────────────────────── beforeEach(() => { - testRoot = join(tmpdir(), `tp-workspace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + testRoot = join( + tmpdir(), + `tp-workspace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(testRoot, { recursive: true }); counter = 0; }); @@ -207,7 +203,10 @@ describe("loadWorkspaceConfig", () => { it("1.6: throws WORKSPACE_REPO_PATH_MISSING on repo without path", () => { const dir = makeTestDir("no-path"); - writeWorkspaceConfig(dir, "repos:\n api:\n branch: main\nrouting:\n tasks_root: ./tasks\n default_repo: api\n"); + writeWorkspaceConfig( + dir, + "repos:\n api:\n branch: main\nrouting:\n tasks_root: ./tasks\n default_repo: api\n", + ); try { loadWorkspaceConfig(dir); expect.unreachable("should have thrown"); @@ -220,7 +219,10 @@ describe("loadWorkspaceConfig", () => { it("1.7: throws WORKSPACE_REPO_PATH_NOT_FOUND on non-existent repo path", () => { const dir = makeTestDir("bad-path"); - writeWorkspaceConfig(dir, "repos:\n api:\n path: ./nonexistent-repo\nrouting:\n tasks_root: ./tasks\n default_repo: api\n"); + writeWorkspaceConfig( + dir, + "repos:\n api:\n path: ./nonexistent-repo\nrouting:\n tasks_root: ./tasks\n default_repo: api\n", + ); try { loadWorkspaceConfig(dir); expect.unreachable("should have thrown"); @@ -235,7 +237,10 @@ describe("loadWorkspaceConfig", () => { const dir = makeTestDir("not-git"); const repoDir = join(dir, "not-a-repo"); mkdirSync(repoDir, { recursive: true }); - writeWorkspaceConfig(dir, `repos:\n api:\n path: ${repoDir}\nrouting:\n tasks_root: ./tasks\n default_repo: api\n`); + writeWorkspaceConfig( + dir, + `repos:\n api:\n path: ${repoDir}\nrouting:\n tasks_root: ./tasks\n default_repo: api\n`, + ); try { loadWorkspaceConfig(dir); expect.unreachable("should have thrown"); @@ -252,9 +257,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n frontend:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n`, ); try { loadWorkspaceConfig(dir); @@ -269,7 +275,10 @@ describe("loadWorkspaceConfig", () => { const dir = makeTestDir("no-tasks-root"); const repoDir = join(dir, "repo-a"); initGitRepo(repoDir); - writeWorkspaceConfig(dir, `repos:\n api:\n path: ${repoDir}\nrouting:\n default_repo: api\n`); + writeWorkspaceConfig( + dir, + `repos:\n api:\n path: ${repoDir}\nrouting:\n default_repo: api\n`, + ); try { loadWorkspaceConfig(dir); expect.unreachable("should have thrown"); @@ -283,9 +292,10 @@ describe("loadWorkspaceConfig", () => { const dir = makeTestDir("bad-tasks-root"); const repoDir = join(dir, "repo-a"); initGitRepo(repoDir); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ./nonexistent-tasks\n default_repo: api\n` + `routing:\n tasks_root: ./nonexistent-tasks\n default_repo: api\n`, ); try { loadWorkspaceConfig(dir); @@ -302,9 +312,9 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, - `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n` + writeWorkspaceConfig( + dir, + `repos:\n api:\n path: ${repoDir}\n` + `routing:\n tasks_root: ${tasksDir}\n`, ); try { loadWorkspaceConfig(dir); @@ -321,9 +331,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: nonexistent\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: nonexistent\n`, ); try { loadWorkspaceConfig(dir); @@ -340,9 +351,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n default_branch: develop\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n`, ); const config = loadWorkspaceConfig(dir); @@ -390,9 +402,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoB); const tasksDir = join(repoA, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoA}\n frontend:\n path: ${repoB}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n`, ); const config = loadWorkspaceConfig(dir); @@ -410,9 +423,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: true\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: true\n`, ); const config = loadWorkspaceConfig(dir); @@ -426,9 +440,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: false\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: false\n`, ); const config = loadWorkspaceConfig(dir); @@ -442,9 +457,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n`, ); const config = loadWorkspaceConfig(dir); @@ -458,9 +474,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: "yes"\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: "yes"\n`, ); expect(() => loadWorkspaceConfig(dir)).toThrow(WorkspaceConfigError); @@ -479,9 +496,10 @@ describe("loadWorkspaceConfig", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: 1\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: 1\n`, ); expect(() => loadWorkspaceConfig(dir)).toThrow(WorkspaceConfigError); @@ -500,9 +518,10 @@ describe("loadWorkspaceConfig", () => { const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); // In YAML, bare `strict:` or `strict: null` produces null - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: null\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n strict: null\n`, ); expect(() => loadWorkspaceConfig(dir)).toThrow(WorkspaceConfigError); @@ -535,7 +554,9 @@ describe("buildExecutionContext", () => { it("2.1b: non-git cwd + no workspace config throws WORKSPACE_SETUP_REQUIRED", () => { const dir = makeTestDir("repo-mode-non-git"); - expect(() => buildExecutionContext(dir, mockLoadOrchConfig, mockLoadRunnerConfig)).toThrow(WorkspaceConfigError); + expect(() => buildExecutionContext(dir, mockLoadOrchConfig, mockLoadRunnerConfig)).toThrow( + WorkspaceConfigError, + ); try { buildExecutionContext(dir, mockLoadOrchConfig, mockLoadRunnerConfig); } catch (err) { @@ -551,9 +572,10 @@ describe("buildExecutionContext", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n api:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: api\n`, ); const ctx = buildExecutionContext(dir, mockLoadOrchConfig, mockLoadRunnerConfig); @@ -634,10 +656,7 @@ describe("WorkspaceConfigError", () => { }); it("4.2: repoId and relatedPath are optional", () => { - const err = new WorkspaceConfigError( - "WORKSPACE_SCHEMA_INVALID", - "Bad schema", - ); + const err = new WorkspaceConfigError("WORKSPACE_SCHEMA_INVALID", "Bad schema"); expect(err.code).toBe("WORKSPACE_SCHEMA_INVALID"); expect(err.repoId).toBeUndefined(); expect(err.relatedPath).toBeUndefined(); @@ -670,18 +689,9 @@ describe("root-consistency regression", () => { // These tests verify source code patterns to ensure the root threading // from TP-001 is correct and consistent across modules. - const extensionSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", - ); - const engineSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); - const resumeSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "resume.ts"), - "utf-8", - ); + const extensionSrc = readFileSync(resolve(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); + const engineSrc = readFileSync(resolve(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); + const resumeSrc = readFileSync(resolve(__dirname, "..", "taskplane", "resume.ts"), "utf-8"); it("5.1: extension.ts has execCtx variable initialized to null", () => { expect(extensionSrc).toContain("let execCtx: ExecutionContext | null = null"); @@ -708,7 +718,7 @@ describe("root-consistency regression", () => { // - doOrchStatus/tool fallback (ctx.cwd passed as fallback parameter) // Verify no ctx.cwd in discovery/state/orphan patterns const lines = extensionSrc.split("\n"); - const cwdLines = lines.filter(l => l.includes("ctx.cwd") && !l.trim().startsWith("//")); + const cwdLines = lines.filter((l) => l.includes("ctx.cwd") && !l.trim().startsWith("//")); for (const line of cwdLines) { const isBuildContext = line.includes("buildExecutionContext"); const isAbortFallback = line.includes("execCtx?.repoRoot ?? ctx.cwd"); @@ -734,8 +744,8 @@ describe("root-consistency regression", () => { expect(extensionSrc).toContain("execCtx!.repoRoot"); // Should appear in the resume handler context const lines = extensionSrc.split("\n"); - const resumeLines = lines.filter(l => - l.includes("resumeOrchBatch") || l.includes("execCtx!.repoRoot"), + const resumeLines = lines.filter( + (l) => l.includes("resumeOrchBatch") || l.includes("execCtx!.repoRoot"), ); expect(resumeLines.length).toBeGreaterThan(0); }); @@ -763,9 +773,9 @@ describe("root-consistency regression", () => { const lines = extensionSrc.split("\n"); // Find the orch-status handler range - const statusRegIdx = lines.findIndex(l => l.includes('"orch-status"')); - const pauseRegIdx = lines.findIndex(l => l.includes('"orch-pause"')); - const sessionsRegIdx = lines.findIndex(l => l.includes('"orch-sessions"')); + const statusRegIdx = lines.findIndex((l) => l.includes('"orch-status"')); + const pauseRegIdx = lines.findIndex((l) => l.includes('"orch-pause"')); + const sessionsRegIdx = lines.findIndex((l) => l.includes('"orch-sessions"')); // orch-status handler should not call requireExecCtx expect(statusRegIdx).toBeGreaterThan(-1); @@ -791,9 +801,7 @@ describe("resolvePointer", () => { * Helper to build a minimal WorkspaceConfig with the given repos. * Repo paths should be absolute. */ - function makeWorkspaceConfig( - repos: Record, - ): WorkspaceConfig { + function makeWorkspaceConfig(repos: Record): WorkspaceConfig { const repoMap = new Map(); for (const [id, repoPath] of Object.entries(repos)) { repoMap.set(id, { @@ -1229,7 +1237,10 @@ describe("resolvePointer", () => { const repoDir = join(dir, "config-repo"); mkdirSync(repoDir, { recursive: true }); const wsConfig = makeWorkspaceConfig("config", repoDir); - writePointerFile(dir, JSON.stringify({ config_repo: "config", config_path: "foo/../../../escape" })); + writePointerFile( + dir, + JSON.stringify({ config_repo: "config", config_path: "foo/../../../escape" }), + ); const result = resolvePointer(dir, wsConfig); expect(result!.used).toBe(false); @@ -1292,7 +1303,10 @@ describe("resolvePointer", () => { const repoDir = join(dir, "config-repo"); mkdirSync(repoDir, { recursive: true }); const wsConfig = makeWorkspaceConfig("config", repoDir); - writePointerFile(dir, JSON.stringify({ config_repo: "config", config_path: "deep/nested/config" })); + writePointerFile( + dir, + JSON.stringify({ config_repo: "config", config_path: "deep/nested/config" }), + ); const result = resolvePointer(dir, wsConfig); expect(result!.used).toBe(true); @@ -1350,9 +1364,10 @@ describe("orchestrator pointer threading", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n myrepo:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: myrepo\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: myrepo\n`, ); // Write a valid pointer file — config_repo points to the same repo for simplicity writePointerFile(dir, JSON.stringify({ config_repo: "myrepo", config_path: ".taskplane" })); @@ -1413,9 +1428,10 @@ describe("orchestrator pointer threading", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n myrepo:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: myrepo\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: myrepo\n`, ); // No pointer file written @@ -1439,10 +1455,7 @@ describe("orchestrator pointer threading", () => { it("7.6: spawnMergeAgentV2 signature accepts agentRoot, separate from stateRoot", () => { // Verify the Runtime V2 merge spawner includes both agentRoot and stateRoot - const mergeSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSrc = readFileSync(resolve(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); const funcStart = mergeSrc.indexOf("export async function spawnMergeAgentV2"); expect(funcStart).toBeGreaterThan(-1); @@ -1454,34 +1467,32 @@ describe("orchestrator pointer threading", () => { // The system prompt resolution should use agentRoot for task-merger.md when available. const mergerRefLines = mergeSrc .split("\n") - .filter(l => l.includes("task-merger.md") && l.includes("agentRoot")); + .filter((l) => l.includes("task-merger.md") && l.includes("agentRoot")); expect(mergerRefLines.length).toBeGreaterThan(0); }); it("7.7: merge request/result files use stateRoot (piDir), not agentRoot", () => { // Verify merge.ts uses piDir (= stateRoot ?? repoRoot) for merge result files, // NOT agentRoot or configRoot from pointer - const mergeSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "merge.ts"), - "utf-8", - ); + const mergeSrc = readFileSync(resolve(__dirname, "..", "taskplane", "merge.ts"), "utf-8"); // The piDir variable should be set from stateRoot - const piDirLine = mergeSrc.split("\n").find(l => l.includes("const piDir") && l.includes("stateRoot")); + const piDirLine = mergeSrc + .split("\n") + .find((l) => l.includes("const piDir") && l.includes("stateRoot")); expect(piDirLine).toBeDefined(); // resultFilePath and requestFilePath should use piDir - const resultLines = mergeSrc.split("\n").filter(l => - (l.includes("resultFilePath") || l.includes("requestFilePath")) && l.includes("piDir"), - ); + const resultLines = mergeSrc + .split("\n") + .filter( + (l) => (l.includes("resultFilePath") || l.includes("requestFilePath")) && l.includes("piDir"), + ); expect(resultLines.length).toBeGreaterThan(0); }); it("7.8: executeOrchBatch accepts and threads agentRoot to mergeWaveByRepo", () => { - const engineSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "engine.ts"), - "utf-8", - ); + const engineSrc = readFileSync(resolve(__dirname, "..", "taskplane", "engine.ts"), "utf-8"); // executeOrchBatch should accept agentRoot parameter const funcStart = engineSrc.indexOf("function executeOrchBatch"); @@ -1499,7 +1510,10 @@ describe("orchestrator pointer threading", () => { if (engineSrc[i] === "(") depth++; if (engineSrc[i] === ")") { depth--; - if (depth === 0) { endIdx = i; break; } + if (depth === 0) { + endIdx = i; + break; + } } } const mergeCallBlock = engineSrc.substring(mergeCallIdx, endIdx + 1); @@ -1507,10 +1521,7 @@ describe("orchestrator pointer threading", () => { }); it("7.9: extension.ts passes execCtx.pointer.agentRoot through worker data to engine", () => { - const extensionSrc = readFileSync( - resolve(__dirname, "..", "taskplane", "extension.ts"), - "utf-8", - ); + const extensionSrc = readFileSync(resolve(__dirname, "..", "taskplane", "extension.ts"), "utf-8"); // TP-071: The engine now runs in a worker thread. doOrchStart builds // EngineWorkerData with agentRoot extracted from execCtx.pointer?.agentRoot, @@ -1536,7 +1547,10 @@ describe("orchestrator pointer threading", () => { if (workerSrc[i] === "(") depth++; if (workerSrc[i] === ")") { depth--; - if (depth === 0) { endIdx = i; break; } + if (depth === 0) { + endIdx = i; + break; + } } } const orchBatchCall = workerSrc.substring(orchBatchCallIdx, endIdx + 1); @@ -1549,9 +1563,10 @@ describe("orchestrator pointer threading", () => { initGitRepo(repoDir); const tasksDir = join(repoDir, "tasks"); mkdirSync(tasksDir, { recursive: true }); - writeWorkspaceConfig(dir, + writeWorkspaceConfig( + dir, `repos:\n myrepo:\n path: ${repoDir}\n` + - `routing:\n tasks_root: ${tasksDir}\n default_repo: myrepo\n` + `routing:\n tasks_root: ${tasksDir}\n default_repo: myrepo\n`, ); // No pointer file — should trigger warning @@ -1565,7 +1580,7 @@ describe("orchestrator pointer threading", () => { buildExecutionContext(dir, mockLoadOrchConfig, mockLoadRunnerConfig); // Should have logged exactly one pointer warning - const pointerWarnings = consoleErrors.filter(m => m.includes("[taskplane] pointer warning")); + const pointerWarnings = consoleErrors.filter((m) => m.includes("[taskplane] pointer warning")); expect(pointerWarnings.length).toBe(1); expect(pointerWarnings[0]).toContain("Pointer file not found"); } finally { @@ -1596,17 +1611,19 @@ describe("orchestrator pointer threading", () => { totalWaves: 1, wavePlan: [["TASK-001"]], lanes: [], - tasks: [{ - taskId: "TASK-001", - status: "running", - laneNumber: 1, - sessionName: "orch-lane-1", - taskFolder: "/workspace/tasks/TASK-001", - exitReason: "", - startedAt: now, - endedAt: null, - doneFileFound: false, - }], + tasks: [ + { + taskId: "TASK-001", + status: "running", + laneNumber: 1, + sessionName: "orch-lane-1", + taskFolder: "/workspace/tasks/TASK-001", + exitReason: "", + startedAt: now, + endedAt: null, + doneFileFound: false, + }, + ], mergeResults: [], totalTasks: 1, succeededTasks: 0, diff --git a/extensions/tests/worktree-lifecycle.integration.test.ts b/extensions/tests/worktree-lifecycle.integration.test.ts index d9995fa9..95d0c4c5 100644 --- a/extensions/tests/worktree-lifecycle.integration.test.ts +++ b/extensions/tests/worktree-lifecycle.integration.test.ts @@ -22,7 +22,16 @@ */ import { execSync } from "child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, +} from "fs"; import { join, resolve, basename } from "path"; import { tmpdir } from "os"; @@ -31,13 +40,10 @@ import { type WorktreeInfo, type CreateWorktreeOptions, type ParsedWorktreeEntry, - // Error class WorktreeError, - // Git runner runGit, - // Pure helpers generateBranchName, generateWorktreePath, @@ -45,21 +51,17 @@ import { isRegisteredWorktree, escapeRegex, isRetriableRemoveError, - // CRUD operations createWorktree, resetWorktree, removeWorktree, - // Bulk operations listWorktrees, createLaneWorktrees, removeAllWorktrees, - // Branch protection hasUnmergedCommits, preserveBranch, - // Batch container helpers (TP-021) generateMergeWorktreePath, generateBatchContainerPath, @@ -145,7 +147,11 @@ function initTestRepo(name: string = "test-repo"): string { const repoDir = join(tempBase, name); execSync(`git init "${repoDir}"`, { encoding: "utf-8", stdio: "pipe" }); - execSync("git config user.email test@test.com", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git config user.email test@test.com", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); execSync("git config user.name Test", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); // Create initial commit @@ -156,7 +162,9 @@ function initTestRepo(name: string = "test-repo"): string { // Rename default branch to main if needed and create develop try { execSync("git branch -M main", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - } catch { /* might already be main */ } + } catch { + /* might already be main */ + } execSync("git branch develop", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); return repoDir; @@ -169,7 +177,9 @@ function initTestRepo(name: string = "test-repo"): string { function addCommit(repoDir: string, branch: string, filename: string, content: string): string { // If we're not on the right branch, check it out const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }).trim(); if (currentBranch !== branch) { @@ -180,7 +190,11 @@ function addCommit(repoDir: string, branch: string, filename: string, content: s execSync(`git add "${filename}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); execSync(`git commit -m "add ${filename}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const sha = execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }).trim(); + const sha = execSync("git rev-parse HEAD", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }).trim(); // Switch back to main/develop to keep worktree paths free if (currentBranch !== branch) { @@ -199,7 +213,9 @@ function cleanupTestRepo(repoDir: string): void { // First, remove any worktrees registered with this repo try { const worktrees = execSync("git worktree list --porcelain", { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); for (const line of worktrees.split("\n")) { @@ -207,17 +223,25 @@ function cleanupTestRepo(repoDir: string): void { const wtPath = line.slice("worktree ".length).trim(); try { execSync(`git worktree remove --force "${wtPath}"`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } } - } catch { /* repo might already be gone */ } + } catch { + /* repo might already be gone */ + } // Then remove the parent temp directory try { rmSync(parentDir, { recursive: true, force: true }); - } catch { /* Windows may need a moment */ } + } catch { + /* Windows may need a moment */ + } } /** @@ -225,7 +249,9 @@ function cleanupTestRepo(repoDir: string): void { */ function getCommitSha(repoDir: string, branch: string): string { return execSync(`git rev-parse refs/heads/${branch}`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }).trim(); } @@ -294,7 +320,12 @@ describe("5.1 isRetriableRemoveError", () => { }); test("returns true for 'used by another process' (Windows)", () => { - assert(isRetriableRemoveError("The process cannot access the file because it is used by another process"), "should be retriable"); + assert( + isRetriableRemoveError( + "The process cannot access the file because it is used by another process", + ), + "should be retriable", + ); }); test("returns true for 'directory not empty'", () => { @@ -358,13 +389,16 @@ describe("5.2 createWorktree — happy path", () => { test("creates worktree with correct directory, branch, and .git file", () => { repoDir = initTestRepo("create-happy"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "test001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "test001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Directory exists assert(existsSync(wt.path), `worktree dir should exist: ${wt.path}`); @@ -381,12 +415,18 @@ describe("5.2 createWorktree — happy path", () => { // Correct branch is checked out const headBranch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: wt.path, encoding: "utf-8", stdio: "pipe", + cwd: wt.path, + encoding: "utf-8", + stdio: "pipe", }).trim(); assertEqual(headBranch, "task/test-lane-1-test001", "checked out branch"); // Branch points to develop HEAD - const wtHead = execSync("git rev-parse HEAD", { cwd: wt.path, encoding: "utf-8", stdio: "pipe" }).trim(); + const wtHead = execSync("git rev-parse HEAD", { + cwd: wt.path, + encoding: "utf-8", + stdio: "pipe", + }).trim(); const devHead = getCommitSha(repoDir, "develop"); assertEqual(wtHead, devHead, "worktree HEAD should match develop HEAD"); @@ -396,13 +436,16 @@ describe("5.2 createWorktree — happy path", () => { test("handles worktree paths containing spaces", () => { repoDir = initTestRepo("create-space-path"); - const wt = createWorktree({ - laneNumber: 2, - batchId: "space001", - baseBranch: "develop", - opId: "test", - prefix: `${basename(repoDir)} with space`, - }, repoDir); + const wt = createWorktree( + { + laneNumber: 2, + batchId: "space001", + baseBranch: "develop", + opId: "test", + prefix: `${basename(repoDir)} with space`, + }, + repoDir, + ); assert(existsSync(wt.path), `worktree dir should exist: ${wt.path}`); // New batch-scoped path: {basePath}/test-space001/lane-2 @@ -411,9 +454,15 @@ describe("5.2 createWorktree — happy path", () => { // Verify the worktree is fully functional with spaced paths const headBranch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: wt.path, encoding: "utf-8", stdio: "pipe", + cwd: wt.path, + encoding: "utf-8", + stdio: "pipe", }).trim(); - assertEqual(headBranch, "task/test-lane-2-space001", "checked out branch in spaced path worktree"); + assertEqual( + headBranch, + "task/test-lane-2-space001", + "checked out branch in spaced path worktree", + ); const removeResult = removeWorktree(wt, repoDir); assertEqual(removeResult.removed, true, "spaced-path worktree should remove cleanly"); @@ -428,13 +477,16 @@ describe("5.2 createWorktree — error paths", () => { test("WORKTREE_INVALID_BASE for nonexistent base branch", () => { repoDir = initTestRepo("create-invalid-base"); const err = assertThrows(() => { - createWorktree({ - laneNumber: 1, - batchId: "test002", - baseBranch: "nonexistent-branch", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + createWorktree( + { + laneNumber: 1, + batchId: "test002", + baseBranch: "nonexistent-branch", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); }, "WORKTREE_INVALID_BASE"); assert(err.message.includes("nonexistent-branch"), "error should mention branch name"); cleanupTestRepo(repoDir); @@ -443,13 +495,16 @@ describe("5.2 createWorktree — error paths", () => { test("WORKTREE_PATH_IS_WORKTREE for existing worktree at same path", () => { repoDir = initTestRepo("create-collision"); // Create first worktree — this occupies {basePath}/test-test003/lane-1 - const wt1 = createWorktree({ - laneNumber: 1, - batchId: "test003", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt1 = createWorktree( + { + laneNumber: 1, + batchId: "test003", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Try creating at the same path by using the same opId/batchId/laneNumber // but a different branch trick: delete the branch first so pre-check 4 @@ -457,13 +512,16 @@ describe("5.2 createWorktree — error paths", () => { // Actually, same params produce both same path AND same branch name, so // pre-check 2 fires first (path is already a registered worktree). const err = assertThrows(() => { - createWorktree({ - laneNumber: 1, - batchId: "test003", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + createWorktree( + { + laneNumber: 1, + batchId: "test003", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); }, "WORKTREE_PATH_IS_WORKTREE"); assert(err.message.includes("already registered"), "error should mention registration"); @@ -473,25 +531,35 @@ describe("5.2 createWorktree — error paths", () => { test("WORKTREE_BRANCH_EXISTS for duplicate branch name", () => { repoDir = initTestRepo("create-dup-branch"); // Create first worktree - createWorktree({ - laneNumber: 1, - batchId: "test005", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); - - // Try creating at different lane but same batchId (different path, same branch format) - // Actually we need same branch name. Create a branch manually that matches lane-2's pattern - execSync("git branch task/test-lane-2-test005", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); - const err = assertThrows(() => { - createWorktree({ - laneNumber: 2, + createWorktree( + { + laneNumber: 1, batchId: "test005", baseBranch: "develop", opId: "test", prefix: basename(repoDir), - }, repoDir); + }, + repoDir, + ); + + // Try creating at different lane but same batchId (different path, same branch format) + // Actually we need same branch name. Create a branch manually that matches lane-2's pattern + execSync("git branch task/test-lane-2-test005", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); + const err = assertThrows(() => { + createWorktree( + { + laneNumber: 2, + batchId: "test005", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); }, "WORKTREE_BRANCH_EXISTS"); assert(err.message.includes("task/test-lane-2-test005"), "error should mention branch"); @@ -510,13 +578,16 @@ describe("5.3 resetWorktree — happy path", () => { repoDir = initTestRepo("reset-happy"); // Create worktree based on develop - const wt = createWorktree({ - laneNumber: 1, - batchId: "reset001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "reset001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); const developHead1 = getCommitSha(repoDir, "develop"); @@ -534,7 +605,11 @@ describe("5.3 resetWorktree — happy path", () => { assertEqual(updated.laneNumber, 1, "lane number preserved"); // HEAD matches new develop - const wtHead = execSync("git rev-parse HEAD", { cwd: updated.path, encoding: "utf-8", stdio: "pipe" }).trim(); + const wtHead = execSync("git rev-parse HEAD", { + cwd: updated.path, + encoding: "utf-8", + stdio: "pipe", + }).trim(); assertEqual(wtHead, developHead2, "worktree HEAD should match new develop HEAD"); cleanupTestRepo(repoDir); @@ -543,19 +618,26 @@ describe("5.3 resetWorktree — happy path", () => { test("idempotent: resetting to same commit succeeds", () => { repoDir = initTestRepo("reset-idempotent"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "reset002", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "reset002", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Reset to same branch (same commit) const updated = resetWorktree(wt, "develop", repoDir); assertEqual(updated.branch, wt.branch, "branch unchanged"); - const wtHead = execSync("git rev-parse HEAD", { cwd: updated.path, encoding: "utf-8", stdio: "pipe" }).trim(); + const wtHead = execSync("git rev-parse HEAD", { + cwd: updated.path, + encoding: "utf-8", + stdio: "pipe", + }).trim(); const devHead = getCommitSha(repoDir, "develop"); assertEqual(wtHead, devHead, "HEAD still matches develop"); @@ -569,13 +651,16 @@ describe("5.3 resetWorktree — error paths", () => { test("WORKTREE_DIRTY for uncommitted changes", () => { repoDir = initTestRepo("reset-dirty"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "reset003", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "reset003", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Create a dirty file in worktree writeFileSync(join(wt.path, "dirty.txt"), "uncommitted content"); @@ -591,13 +676,16 @@ describe("5.3 resetWorktree — error paths", () => { test("WORKTREE_INVALID_BASE for nonexistent target branch", () => { repoDir = initTestRepo("reset-invalid"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "reset004", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "reset004", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); const err = assertThrows(() => { resetWorktree(wt, "nonexistent-target", repoDir); @@ -633,13 +721,16 @@ describe("5.4 removeWorktree — happy path", () => { test("removes worktree directory and deletes branch", () => { repoDir = initTestRepo("remove-happy"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "rem001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "rem001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); assert(existsSync(wt.path), "worktree should exist before removal"); @@ -669,13 +760,16 @@ describe("5.4 removeWorktree — idempotent", () => { test("already-removed returns alreadyRemoved=true (path + branch both missing)", () => { repoDir = initTestRepo("remove-idempotent"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "rem002", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "rem002", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Remove once removeWorktree(wt, repoDir); @@ -692,16 +786,23 @@ describe("5.4 removeWorktree — idempotent", () => { test("stale branch cleanup: path missing but branch exists", () => { repoDir = initTestRepo("remove-stale-branch"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "rem003", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "rem003", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Manually remove path but leave branch - execSync(`git worktree remove --force "${wt.path}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree remove --force "${wt.path}"`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); // Branch should still exist const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${wt.branch}`], repoDir); assert(branchCheck.ok, "branch should still exist after worktree remove"); @@ -725,18 +826,25 @@ describe("5.4 removeWorktree — unmerged branch", () => { test("force-deletes unmerged branch", () => { repoDir = initTestRepo("remove-unmerged"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "rem004", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "rem004", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Add a commit to the worktree branch (making it diverge/unmerged) writeFileSync(join(wt.path, "wt-only.txt"), "worktree-only content"); execSync("git add -A", { cwd: wt.path, encoding: "utf-8", stdio: "pipe" }); - execSync('git commit -m "worktree-only commit"', { cwd: wt.path, encoding: "utf-8", stdio: "pipe" }); + execSync('git commit -m "worktree-only commit"', { + cwd: wt.path, + encoding: "utf-8", + stdio: "pipe", + }); // Remove — should still succeed with force-delete const result = removeWorktree(wt, repoDir); @@ -757,13 +865,16 @@ describe("5.4b removeWorktree — branch protection with targetBranch", () => { test("preserves branch with unmerged commits when targetBranch provided", () => { repoDir = initTestRepo("remove-preserve"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "pres001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "pres001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Add unmerged commit to worktree branch writeFileSync(join(wt.path, "unmerged.txt"), "unmerged content"); @@ -793,13 +904,16 @@ describe("5.4b removeWorktree — branch protection with targetBranch", () => { test("deletes fully-merged branch normally when targetBranch provided", () => { repoDir = initTestRepo("remove-merged"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "merge001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "merge001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // No extra commits on worktree branch — it's fully merged into develop @@ -821,13 +935,16 @@ describe("5.4b removeWorktree — branch protection with targetBranch", () => { test("idempotent: second removeWorktree succeeds after preservation", () => { repoDir = initTestRepo("remove-idempotent-pres"); - const wt = createWorktree({ - laneNumber: 1, - batchId: "idem001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "idem001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Add unmerged commit writeFileSync(join(wt.path, "unmerged.txt"), "unmerged"); @@ -1021,13 +1138,16 @@ describe("5.5 Full lifecycle: create → verify → remove → verify", () => { repoDir = initTestRepo("lifecycle"); // Create - const wt = createWorktree({ - laneNumber: 1, - batchId: "life001", - baseBranch: "develop", - opId: "test", - prefix: basename(repoDir), - }, repoDir); + const wt = createWorktree( + { + laneNumber: 1, + batchId: "life001", + baseBranch: "develop", + opId: "test", + prefix: basename(repoDir), + }, + repoDir, + ); // Verify creation artifacts assert(existsSync(wt.path), "worktree dir should exist"); @@ -1067,9 +1187,18 @@ describe("5.6 listWorktrees — prefix filtering", () => { const prefix = basename(repoDir); // Create 3 worktrees - const wt1 = createWorktree({ laneNumber: 1, batchId: "list001", baseBranch: "develop", opId: "test", prefix }, repoDir); - const wt2 = createWorktree({ laneNumber: 2, batchId: "list001", baseBranch: "develop", opId: "test", prefix }, repoDir); - const wt3 = createWorktree({ laneNumber: 3, batchId: "list001", baseBranch: "develop", opId: "test", prefix }, repoDir); + const wt1 = createWorktree( + { laneNumber: 1, batchId: "list001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + const wt2 = createWorktree( + { laneNumber: 2, batchId: "list001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + const wt3 = createWorktree( + { laneNumber: 3, batchId: "list001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); const found = listWorktrees(prefix, repoDir, "test"); @@ -1086,12 +1215,17 @@ describe("5.6 listWorktrees — prefix filtering", () => { const prefix = basename(repoDir); // Create one orchestrator worktree - createWorktree({ laneNumber: 1, batchId: "list002", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "list002", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Create a non-orchestrator worktree manually (different naming) const otherPath = resolve(repoDir, "..", "random-worktree"); execSync(`git worktree add -b other-branch "${otherPath}" develop`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); const found = listWorktrees(prefix, repoDir, "test"); @@ -1099,7 +1233,11 @@ describe("5.6 listWorktrees — prefix filtering", () => { assertEqual(found[0].laneNumber, 1, "should be lane 1"); // Cleanup non-orchestrator worktree - execSync(`git worktree remove --force "${otherPath}"`, { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync(`git worktree remove --force "${otherPath}"`, { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); cleanupTestRepo(repoDir); }); @@ -1135,7 +1273,13 @@ describe("5.6 createLaneWorktrees — bulk creation", () => { assignment: { strategy: "affinity-first" as const, size_weights: { S: 1, M: 2, L: 4 } }, pre_warm: { auto_detect: true, commands: {}, always: [] }, merge: { model: "", tools: "", verify: [], order: "fewest-files-first" as const }, - failure: { on_task_failure: "skip-dependents" as const, on_merge_failure: "pause" as const, stall_timeout: 30, max_worker_minutes: 30, abort_grace_period: 60 }, + failure: { + on_task_failure: "skip-dependents" as const, + on_merge_failure: "pause" as const, + stall_timeout: 30, + max_worker_minutes: 30, + abort_grace_period: 60, + }, monitoring: { poll_interval: 5 }, }; @@ -1148,7 +1292,11 @@ describe("5.6 createLaneWorktrees — bulk creation", () => { // Verify naming for (let i = 0; i < 3; i++) { assertEqual(result.worktrees[i].laneNumber, i + 1, `lane ${i + 1} number`); - assertEqual(result.worktrees[i].branch, `task/test-lane-${i + 1}-bulk001`, `lane ${i + 1} branch`); + assertEqual( + result.worktrees[i].branch, + `task/test-lane-${i + 1}-bulk001`, + `lane ${i + 1} branch`, + ); assert(existsSync(result.worktrees[i].path), `lane ${i + 1} dir should exist`); } @@ -1160,7 +1308,11 @@ describe("5.6 createLaneWorktrees — bulk creation", () => { const prefix = basename(repoDir); // Pre-create a branch that will conflict with lane 2 - execSync("git branch task/test-lane-2-bulkfail", { cwd: repoDir, encoding: "utf-8", stdio: "pipe" }); + execSync("git branch task/test-lane-2-bulkfail", { + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", + }); const config = { orchestrator: { @@ -1176,7 +1328,13 @@ describe("5.6 createLaneWorktrees — bulk creation", () => { assignment: { strategy: "affinity-first" as const, size_weights: { S: 1, M: 2, L: 4 } }, pre_warm: { auto_detect: true, commands: {}, always: [] }, merge: { model: "", tools: "", verify: [], order: "fewest-files-first" as const }, - failure: { on_task_failure: "skip-dependents" as const, on_merge_failure: "pause" as const, stall_timeout: 30, max_worker_minutes: 30, abort_grace_period: 60 }, + failure: { + on_task_failure: "skip-dependents" as const, + on_merge_failure: "pause" as const, + stall_timeout: 30, + max_worker_minutes: 30, + abort_grace_period: 60, + }, monitoring: { poll_interval: 5 }, }; @@ -1185,7 +1343,11 @@ describe("5.6 createLaneWorktrees — bulk creation", () => { assertEqual(result.success, false, "should fail"); assert(result.errors.length > 0, "should have errors"); assertEqual(result.errors[0].laneNumber, 2, "lane 2 should fail"); - assertEqual(result.errors[0].code, "WORKTREE_BRANCH_EXISTS", "error code should be WORKTREE_BRANCH_EXISTS"); + assertEqual( + result.errors[0].code, + "WORKTREE_BRANCH_EXISTS", + "error code should be WORKTREE_BRANCH_EXISTS", + ); assertEqual(result.worktrees.length, 0, "no worktrees after rollback"); assertEqual(result.rolledBack, true, "should have rolled back"); @@ -1205,9 +1367,18 @@ describe("5.6 removeAllWorktrees — bulk removal", () => { const prefix = basename(repoDir); // Create 3 worktrees - createWorktree({ laneNumber: 1, batchId: "rmall001", baseBranch: "develop", opId: "test", prefix }, repoDir); - createWorktree({ laneNumber: 2, batchId: "rmall001", baseBranch: "develop", opId: "test", prefix }, repoDir); - createWorktree({ laneNumber: 3, batchId: "rmall001", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "rmall001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + createWorktree( + { laneNumber: 2, batchId: "rmall001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + createWorktree( + { laneNumber: 3, batchId: "rmall001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Verify they exist assertEqual(listWorktrees(prefix, repoDir, "test").length, 3, "should have 3 before removal"); @@ -1250,12 +1421,24 @@ describe("5.7 Batch-scoped isolation — same opId, different batchIds", () => { const prefix = basename(repoDir); // Create worktrees in batch A - createWorktree({ laneNumber: 1, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, repoDir); - createWorktree({ laneNumber: 2, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + createWorktree( + { laneNumber: 2, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Create worktrees in batch B (same opId, different batchId) - createWorktree({ laneNumber: 1, batchId: "batchB", baseBranch: "develop", opId: "test", prefix }, repoDir); - createWorktree({ laneNumber: 3, batchId: "batchB", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "batchB", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + createWorktree( + { laneNumber: 3, batchId: "batchB", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // List only batch A — should get exactly 2 const batchAWts = listWorktrees(prefix, repoDir, "test", "batchA"); @@ -1281,11 +1464,20 @@ describe("5.7 Batch-scoped isolation — same opId, different batchIds", () => { const prefix = basename(repoDir); // Create worktrees in batch A - createWorktree({ laneNumber: 1, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, repoDir); - createWorktree({ laneNumber: 2, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); + createWorktree( + { laneNumber: 2, batchId: "batchA", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Create worktrees in batch B - createWorktree({ laneNumber: 1, batchId: "batchB", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "batchB", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Remove only batch A const result = removeAllWorktrees(prefix, repoDir, "test", undefined, "batchA"); @@ -1309,7 +1501,10 @@ describe("5.7 Batch-scoped isolation — same opId, different batchIds", () => { const prefix = basename(repoDir); // Create worktrees in batch A - const wt1 = createWorktree({ laneNumber: 1, batchId: "batchClean", baseBranch: "develop", opId: "test", prefix }, repoDir); + const wt1 = createWorktree( + { laneNumber: 1, batchId: "batchClean", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // The container dir should exist const containerPath = resolve(wt1.path, ".."); @@ -1319,7 +1514,10 @@ describe("5.7 Batch-scoped isolation — same opId, different batchIds", () => { removeAllWorktrees(prefix, repoDir, "test", undefined, "batchClean"); // The container directory should be removed (empty after worktree removal) - assert(!existsSync(containerPath), "batch container should be removed after all worktrees cleaned up"); + assert( + !existsSync(containerPath), + "batch container should be removed after all worktrees cleaned up", + ); cleanupTestRepo(repoDir); }); @@ -1337,12 +1535,17 @@ describe("5.8 Transition compatibility — legacy flat and new nested coexistenc const prefix = basename(repoDir); // Create a new nested worktree (batch-scoped) - createWorktree({ laneNumber: 1, batchId: "new001", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "new001", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Manually create a legacy flat worktree: {basePath}/{prefix}-{opId}-{N} const legacyPath = resolve(repoDir, ".worktrees", `${prefix}-test-5`); execSync(`git worktree add -b task/test-lane-5-legacybatch "${legacyPath}" develop`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); // List without batchId — should find both @@ -1350,7 +1553,7 @@ describe("5.8 Transition compatibility — legacy flat and new nested coexistenc assertEqual(allWts.length, 2, "should find both legacy and new worktrees"); // The new one has laneNumber 1, the legacy one has laneNumber 5 - const lanes = allWts.map(w => w.laneNumber).sort((a, b) => a - b); + const lanes = allWts.map((w) => w.laneNumber).sort((a, b) => a - b); assertEqual(lanes[0], 1, "new nested worktree lane 1"); assertEqual(lanes[1], 5, "legacy flat worktree lane 5"); @@ -1362,12 +1565,17 @@ describe("5.8 Transition compatibility — legacy flat and new nested coexistenc const prefix = basename(repoDir); // Create a new nested worktree (batch-scoped) - createWorktree({ laneNumber: 1, batchId: "new002", baseBranch: "develop", opId: "test", prefix }, repoDir); + createWorktree( + { laneNumber: 1, batchId: "new002", baseBranch: "develop", opId: "test", prefix }, + repoDir, + ); // Manually create a legacy flat worktree const legacyPath = resolve(repoDir, ".worktrees", `${prefix}-test-7`); execSync(`git worktree add -b task/test-lane-7-legacybatch2 "${legacyPath}" develop`, { - cwd: repoDir, encoding: "utf-8", stdio: "pipe", + cwd: repoDir, + encoding: "utf-8", + stdio: "pipe", }); // List with specific batchId — should only find the new one @@ -1457,13 +1665,16 @@ describe("5.9 createWorktree — no empty container on pre-check failure", () => // Attempt to create worktree with nonexistent base branch — should fail at pre-check 1 try { - createWorktree({ - laneNumber: 1, - batchId: "failbatch", - baseBranch: "nonexistent-branch", - opId: "test", - prefix, - }, repoDir); + createWorktree( + { + laneNumber: 1, + batchId: "failbatch", + baseBranch: "nonexistent-branch", + opId: "test", + prefix, + }, + repoDir, + ); assert(false, "should have thrown"); } catch (err) { assert(err instanceof WorktreeError, "should throw WorktreeError"); @@ -1487,7 +1698,14 @@ describe("5.9 generateWorktreePath — subdirectory vs sibling with batch-scoped test("sibling mode: {repoRoot}/../{opId}-{batchId}/lane-{N}", () => { const siblingConfig = { orchestrator: { worktree_location: "sibling" as const } } as any; - const result = generateWorktreePath("unused", 2, "/some/path/repo", "bob", siblingConfig, "batch42"); + const result = generateWorktreePath( + "unused", + 2, + "/some/path/repo", + "bob", + siblingConfig, + "batch42", + ); const expected = resolve("/some/path/repo", "..", "bob-batch42", "lane-2"); assertEqual(result, expected, "sibling batch-scoped path"); }); diff --git a/scripts/local-build.mjs b/scripts/local-build.mjs index d75a1d18..3af8d24d 100644 --- a/scripts/local-build.mjs +++ b/scripts/local-build.mjs @@ -23,97 +23,97 @@ const DRY = process.argv.includes("--dry") || process.argv.includes("--dry-run") // Resolve global install target function resolveGlobalTarget() { - const npmRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); - const target = path.join(npmRoot, "taskplane"); - if (!fs.existsSync(target)) { - console.error(`❌ Global taskplane not found at ${target}`); - console.error(" Run: npm install -g taskplane"); - process.exit(1); - } - return target; + const npmRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); + const target = path.join(npmRoot, "taskplane"); + if (!fs.existsSync(target)) { + console.error(`❌ Global taskplane not found at ${target}`); + console.error(" Run: npm install -g taskplane"); + process.exit(1); + } + return target; } // Read package.json#files to get the publishable file patterns function getPublishablePatterns() { - const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf-8")); - return pkg.files || []; + const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf-8")); + return pkg.files || []; } // Recursively list all files under a directory function listFiles(dir, base = "") { - const results = []; - if (!fs.existsSync(dir)) return results; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const rel = path.join(base, entry.name); - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...listFiles(full, rel)); - } else { - results.push(rel); - } - } - return results; + const results = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = path.join(base, entry.name); + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...listFiles(full, rel)); + } else { + results.push(rel); + } + } + return results; } // Collect all files that match package.json#files patterns function collectSourceFiles(patterns) { - const files = new Set(); - // Always include package.json and README.md - for (const always of ["package.json", "README.md", "LICENSE"]) { - if (fs.existsSync(path.join(PROJECT_ROOT, always))) { - files.add(always); - } - } - for (const pattern of patterns) { - const fullPath = path.join(PROJECT_ROOT, pattern); - if (!fs.existsSync(fullPath)) continue; - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - for (const file of listFiles(fullPath, pattern)) { - files.add(file); - } - } else { - files.add(pattern); - } - } - return [...files].sort(); + const files = new Set(); + // Always include package.json and README.md + for (const always of ["package.json", "README.md", "LICENSE"]) { + if (fs.existsSync(path.join(PROJECT_ROOT, always))) { + files.add(always); + } + } + for (const pattern of patterns) { + const fullPath = path.join(PROJECT_ROOT, pattern); + if (!fs.existsSync(fullPath)) continue; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + for (const file of listFiles(fullPath, pattern)) { + files.add(file); + } + } else { + files.add(pattern); + } + } + return [...files].sort(); } // Compare and copy function syncFiles(sourceFiles, target) { - let copied = 0; - let skipped = 0; - let created = 0; - - for (const relFile of sourceFiles) { - const src = path.join(PROJECT_ROOT, relFile); - const dst = path.join(target, relFile); - - if (!fs.existsSync(src)) continue; - - const srcStat = fs.statSync(src); - const dstExists = fs.existsSync(dst); - - if (!FORCE && dstExists) { - const dstStat = fs.statSync(dst); - // Skip if destination is same size and not older - if (dstStat.size === srcStat.size && dstStat.mtimeMs >= srcStat.mtimeMs) { - skipped++; - continue; - } - } - - if (DRY) { - console.log(` ${dstExists ? "update" : "create"} ${relFile}`); - } else { - fs.mkdirSync(path.dirname(dst), { recursive: true }); - fs.copyFileSync(src, dst); - } - if (dstExists) copied++; - else created++; - } - - return { copied, skipped, created }; + let copied = 0; + let skipped = 0; + let created = 0; + + for (const relFile of sourceFiles) { + const src = path.join(PROJECT_ROOT, relFile); + const dst = path.join(target, relFile); + + if (!fs.existsSync(src)) continue; + + const srcStat = fs.statSync(src); + const dstExists = fs.existsSync(dst); + + if (!FORCE && dstExists) { + const dstStat = fs.statSync(dst); + // Skip if destination is same size and not older + if (dstStat.size === srcStat.size && dstStat.mtimeMs >= srcStat.mtimeMs) { + skipped++; + continue; + } + } + + if (DRY) { + console.log(` ${dstExists ? "update" : "create"} ${relFile}`); + } else { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + } + if (dstExists) copied++; + else created++; + } + + return { copied, skipped, created }; } // Main @@ -130,7 +130,7 @@ console.log(); const { copied, skipped, created } = syncFiles(sourceFiles, target); if (DRY) { - console.log(`\n Would copy: ${copied} updated + ${created} new (${skipped} unchanged)`); + console.log(`\n Would copy: ${copied} updated + ${created} new (${skipped} unchanged)`); } else { - console.log(` ✅ ${copied} updated, ${created} new, ${skipped} unchanged`); + console.log(` ✅ ${copied} updated, ${created} new, ${skipped} unchanged`); } diff --git a/scripts/runtime-v2-lab/run-lab.mjs b/scripts/runtime-v2-lab/run-lab.mjs index ca3218b8..a687a298 100644 --- a/scripts/runtime-v2-lab/run-lab.mjs +++ b/scripts/runtime-v2-lab/run-lab.mjs @@ -187,11 +187,7 @@ async function runPiAgent(options) { error: event.message?.errorMessage || null, }); maybeDeliverMailbox(); - if ( - requestSessionStats && - !statsRequested && - event.message?.role === "assistant" - ) { + if (requestSessionStats && !statsRequested && event.message?.role === "assistant") { statsRequested = true; proc.stdin.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } @@ -242,8 +238,18 @@ async function runPiAgent(options) { async function experimentCloseStrategies() { const prompt = "Reply with exactly OK and then stop."; - const immediate = await runPiAgent({ prompt, closeDelayMs: 0, requestSessionStats: false, timeoutMs: 20_000 }); - const delayed = await runPiAgent({ prompt, closeDelayMs: 100, requestSessionStats: true, timeoutMs: 20_000 }); + const immediate = await runPiAgent({ + prompt, + closeDelayMs: 0, + requestSessionStats: false, + timeoutMs: 20_000, + }); + const delayed = await runPiAgent({ + prompt, + closeDelayMs: 100, + requestSessionStats: true, + timeoutMs: 20_000, + }); return { name: "close-strategy", immediate: { @@ -263,11 +269,22 @@ async function experimentSequentialParallelReliability() { const prompt = "Reply with exactly OK and then stop."; const sequentialRuns = []; for (let i = 0; i < 5; i++) { - const result = await runPiAgent({ prompt, closeDelayMs: 100, requestSessionStats: true, timeoutMs: 20_000 }); - sequentialRuns.push({ exitCode: result.exitCode, contextUsagePresent: !!result.contextUsage, stderrTail: result.stderr.trim().slice(-120) }); + const result = await runPiAgent({ + prompt, + closeDelayMs: 100, + requestSessionStats: true, + timeoutMs: 20_000, + }); + sequentialRuns.push({ + exitCode: result.exitCode, + contextUsagePresent: !!result.contextUsage, + stderrTail: result.stderr.trim().slice(-120), + }); } const parallel = await Promise.all( - [1, 2, 3].map(() => runPiAgent({ prompt, closeDelayMs: 100, requestSessionStats: true, timeoutMs: 20_000 })), + [1, 2, 3].map(() => + runPiAgent({ prompt, closeDelayMs: 100, requestSessionStats: true, timeoutMs: 20_000 }), + ), ); return { name: "reliability", @@ -279,7 +296,11 @@ async function experimentSequentialParallelReliability() { parallel: { runs: parallel.length, successes: parallel.filter((r) => r.exitCode === 0 && r.contextUsage).length, - results: parallel.map((r) => ({ exitCode: r.exitCode, contextUsagePresent: !!r.contextUsage, stderrTail: r.stderr.trim().slice(-120) })), + results: parallel.map((r) => ({ + exitCode: r.exitCode, + contextUsagePresent: !!r.contextUsage, + stderrTail: r.stderr.trim().slice(-120), + })), }, }; } @@ -289,8 +310,14 @@ async function experimentMailboxSteering() { const batchId = "lab-batch"; const agentId = "lab-agent"; const mailboxDir = ensureDir(join(tempRoot, ".pi", "mailbox", batchId, agentId)); - const message = writeMailboxMessage(mailboxDir, batchId, agentId, "Reply with exactly STEER-ACK and then stop."); - const prompt = "First reply with exactly READY. If you later receive a steering message, follow it exactly and then stop."; + const message = writeMailboxMessage( + mailboxDir, + batchId, + agentId, + "Reply with exactly STEER-ACK and then stop.", + ); + const prompt = + "First reply with exactly READY. If you later receive a steering message, follow it exactly and then stop."; const result = await runPiAgent({ prompt, mailboxDir, @@ -350,17 +377,24 @@ async function experimentPacketPaths() { name: "packet-paths", attempts, successes: attempts.filter((attempt) => attempt.exitCode === 0 && attempt.doneExists).length, - note: "A passing attempt demonstrates explicit packet paths are viable outside cwd assumptions; a failing/hanging attempt indicates tool-heavy multi-step prompts still need robust retry/timeout handling in Runtime V2.", + note: + "A passing attempt demonstrates explicit packet paths are viable outside cwd assumptions; a failing/hanging attempt indicates tool-heavy multi-step prompts still need robust retry/timeout handling in Runtime V2.", }; } async function experimentBridgeFeasibility() { const prompt = "Reply with exactly BRIDGE-DEFERRED and then stop."; - const result = await runPiAgent({ prompt, closeDelayMs: 100, requestSessionStats: true, timeoutMs: 20_000 }); + const result = await runPiAgent({ + prompt, + closeDelayMs: 100, + requestSessionStats: true, + timeoutMs: 20_000, + }); return { name: "bridge-feasibility", status: "open", - note: "A true synchronous file-bridge callback was not validated in this first lab run. The mailbox and packet-path experiments reduce risk, but bridge semantics still need a dedicated proof task during TP-106/TP-105 work.", + note: + "A true synchronous file-bridge callback was not validated in this first lab run. The mailbox and packet-path experiments reduce risk, but bridge semantics still need a dedicated proof task during TP-106/TP-105 work.", smokeExitCode: result.exitCode, smokeAssistantTexts: result.messages.filter((m) => m.role === "assistant").map((m) => m.text), }; diff --git a/scripts/tmux-reference-audit.mjs b/scripts/tmux-reference-audit.mjs index 37121828..78d78feb 100644 --- a/scripts/tmux-reference-audit.mjs +++ b/scripts/tmux-reference-audit.mjs @@ -17,12 +17,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const CATEGORY_ORDER = [ - "compat-code", - "user-facing strings", - "comments/docs", - "types/contracts", -]; +const CATEGORY_ORDER = ["compat-code", "user-facing strings", "comments/docs", "types/contracts"]; const STRICT_FAILURE_EXIT_CODE = 2; const SCAN_ROOTS = ["extensions", "bin", "templates", "dashboard", "skills"]; @@ -48,10 +43,7 @@ const USER_FACING_FILES = new Set([ "supervisor.ts", ]); -const TYPES_CONTRACT_FILES = new Set([ - "types.ts", - "config-schema.ts", -]); +const TYPES_CONTRACT_FILES = new Set(["types.ts", "config-schema.ts"]); const FUNCTIONAL_PATTERNS = [ { @@ -102,6 +94,32 @@ function isCommentLine(trimmed, inBlockComment) { return false; } +/** + * Test files contain string-literal assertions of the form + * expect(src).not.toContain('execSync("tmux ...")') + * which look identical to real functional TMUX usage to a regex scanner. + * These are NEGATIVE assertions verifying that production code does NOT + * call into TMUX, not actual TMUX calls. Skip functional-usage detection + * inside test files; they're not shipped and never trigger TMUX execution. + * + * (The line is still counted as a `tmux` reference under compat-code; only + * the strict-mode functional-usage gate is skipped.) + * + * Added under TP-193 because Biome's `quoteStyle: "double"` rule rewrote + * test assertions like `'execSync(\'tmux ...\')'` to `"execSync('tmux ...')"`, + * removing the escaped quotes that previously hid these literal strings + * from FUNCTIONAL_PATTERNS regex matching. + */ +function isTestSourceFile(relPath) { + return ( + relPath.includes("/tests/") || + relPath.endsWith(".test.ts") || + relPath.endsWith(".test.tsx") || + relPath.endsWith(".test.mjs") || + relPath.endsWith(".integration.test.ts") + ); +} + function detectFunctionalUsage(line) { for (const pattern of FUNCTIONAL_PATTERNS) { if (pattern.regex.test(line)) return pattern.id; @@ -117,7 +135,8 @@ function isUserFacingLine(fileName, line) { } if (fileName === "worktree.ts") { - const hasDisplayContext = line.includes("message:") || line.includes("hint:") || /["'`]/.test(line); + const hasDisplayContext = + line.includes("message:") || line.includes("hint:") || /["'`]/.test(line); return hasDisplayContext && /tmux/i.test(line); } @@ -150,8 +169,9 @@ function collectFilesRecursive(repoRoot, rootRel, out) { const stack = [absRoot]; while (stack.length > 0) { const current = stack.pop(); - const entries = readdirSync(current, { withFileTypes: true }) - .sort((a, b) => a.name.localeCompare(b.name)); + const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + ); for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; @@ -177,7 +197,9 @@ function buildAudit() { collectFilesRecursive(repoRoot, scanRoot, entriesAbs); } - entriesAbs.sort((a, b) => normalizeRepoPath(relative(repoRoot, a)).localeCompare(normalizeRepoPath(relative(repoRoot, b)))); + entriesAbs.sort((a, b) => + normalizeRepoPath(relative(repoRoot, a)).localeCompare(normalizeRepoPath(relative(repoRoot, b))), + ); const totalsByCategory = createCategoryCounter(); const byFile = []; @@ -210,7 +232,7 @@ function buildAudit() { fileByCategory[category] += matchCount; totalsByCategory[category] += matchCount; - if (executableFile && !commentLine) { + if (executableFile && !commentLine && !isTestSourceFile(relPath)) { const patternId = detectFunctionalUsage(line); if (patternId) { const firstIndex = line.toLowerCase().indexOf("tmux"); @@ -247,7 +269,7 @@ function buildAudit() { return a.pattern.localeCompare(b.pattern); }); - const filesWithReferences = byFile.filter(entry => entry.references > 0).length; + const filesWithReferences = byFile.filter((entry) => entry.references > 0).length; return { schemaVersion: 2, @@ -296,7 +318,7 @@ function main() { } const known = new Set(["--json", "--strict", "--help"]); - const unknown = args.filter(arg => !known.has(arg)); + const unknown = args.filter((arg) => !known.has(arg)); if (unknown.length > 0) { console.error(`[tmux-reference-audit] Unknown option(s): ${unknown.join(", ")}`); printUsage(); diff --git a/scripts/tmux-spawn-test.mjs b/scripts/tmux-spawn-test.mjs index fddd0832..77877560 100644 --- a/scripts/tmux-spawn-test.mjs +++ b/scripts/tmux-spawn-test.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node /** * tmux-spawn-test.mjs — Standalone test for tmux session spawn reliability. - * + * * Tests rapid sequential tmux session creation to reproduce the startup crash * pattern (#335) without running a full batch. - * + * * Usage: * node scripts/tmux-spawn-test.mjs [--rounds 10] [--delay 0] [--command "echo hello"] [--wait-after 300] - * + * * Options: * --rounds N Number of create/destroy cycles (default: 20) * --delay N Milliseconds between destroy and next create (default: 0) @@ -24,9 +24,9 @@ import { tmpdir } from "os"; const args = process.argv.slice(2); function getArg(name, defaultVal) { - const idx = args.indexOf(name); - if (idx === -1) return defaultVal; - return args[idx + 1]; + const idx = args.indexOf(name); + if (idx === -1) return defaultVal; + return args[idx + 1]; } const hasFlag = (name) => args.includes(name); @@ -40,47 +40,47 @@ const VERBOSE = hasFlag("--verbose"); const SESSION_NAME = "tp-spawn-test"; function sleep(ms) { - if (ms <= 0) return; - spawnSync("sleep", [`${ms / 1000}`], { shell: true }); + if (ms <= 0) return; + spawnSync("sleep", [`${ms / 1000}`], { shell: true }); } function killSession() { - spawnSync("tmux", ["kill-session", "-t", SESSION_NAME]); + spawnSync("tmux", ["kill-session", "-t", SESSION_NAME]); } function hasSession() { - const r = spawnSync("tmux", ["has-session", "-t", SESSION_NAME]); - return r.status === 0; + const r = spawnSync("tmux", ["has-session", "-t", SESSION_NAME]); + return r.status === 0; } function createSession(cmd) { - const r = spawnSync("tmux", ["new-session", "-d", "-s", SESSION_NAME, cmd]); - return { ok: r.status === 0, stderr: r.stderr?.toString().trim() || "" }; + const r = spawnSync("tmux", ["new-session", "-d", "-s", SESSION_NAME, cmd]); + return { ok: r.status === 0, stderr: r.stderr?.toString().trim() || "" }; } // Build the pi command if --pi flag is set function buildPiCommand() { - const rpcWrapper = resolve("bin/rpc-wrapper.mjs"); - const sidecarDir = join(tmpdir(), "tp-spawn-test"); - mkdirSync(sidecarDir, { recursive: true }); - const sidecarPath = join(sidecarDir, "test-sidecar.jsonl"); - const exitPath = join(sidecarDir, "test-exit.json"); - const sysPrompt = join(tmpdir(), "tp-spawn-test-sys.txt"); - const promptFile = join(tmpdir(), "tp-spawn-test-prompt.txt"); - writeFileSync(sysPrompt, "You are a test. Reply with 'hello' then exit."); - writeFileSync(promptFile, "Say hello."); - - return [ - `TERM=xterm-256color node`, - `'${rpcWrapper}'`, - `--sidecar-path '${sidecarPath}'`, - `--exit-summary-path '${exitPath}'`, - `--model anthropic/claude-sonnet-4-20250514`, - `--system-prompt-file '${sysPrompt}'`, - `--prompt-file '${promptFile}'`, - `--tools read,bash`, - `-- --thinking off --no-extensions --no-skills`, - ].join(" "); + const rpcWrapper = resolve("bin/rpc-wrapper.mjs"); + const sidecarDir = join(tmpdir(), "tp-spawn-test"); + mkdirSync(sidecarDir, { recursive: true }); + const sidecarPath = join(sidecarDir, "test-sidecar.jsonl"); + const exitPath = join(sidecarDir, "test-exit.json"); + const sysPrompt = join(tmpdir(), "tp-spawn-test-sys.txt"); + const promptFile = join(tmpdir(), "tp-spawn-test-prompt.txt"); + writeFileSync(sysPrompt, "You are a test. Reply with 'hello' then exit."); + writeFileSync(promptFile, "Say hello."); + + return [ + `TERM=xterm-256color node`, + `'${rpcWrapper}'`, + `--sidecar-path '${sidecarPath}'`, + `--exit-summary-path '${exitPath}'`, + `--model anthropic/claude-sonnet-4-20250514`, + `--system-prompt-file '${sysPrompt}'`, + `--prompt-file '${promptFile}'`, + `--tools read,bash`, + `-- --thinking off --no-extensions --no-skills`, + ].join(" "); } // ── Main test loop ────────────────────────────────────────────────── @@ -99,44 +99,45 @@ const results = { success: 0, fail: 0, createFail: 0, times: [] }; const cmd = USE_PI ? buildPiCommand() : COMMAND; for (let i = 0; i < ROUNDS; i++) { - const t0 = Date.now(); - - // Create session - const { ok, stderr } = createSession(cmd); - if (!ok) { - results.createFail++; - console.log(` Round ${i + 1}: ❌ tmux create failed: ${stderr}`); - sleep(DELAY_MS); - continue; - } - - // Wait for session to stabilize - sleep(WAIT_AFTER_MS); - - // Check if session is alive - const alive = hasSession(); - const elapsed = Date.now() - t0; - results.times.push(elapsed); - - if (alive) { - results.success++; - if (VERBOSE) console.log(` Round ${i + 1}: ✅ alive (${elapsed}ms)`); - } else { - results.fail++; - console.log(` Round ${i + 1}: ❌ died within ${WAIT_AFTER_MS}ms (${elapsed}ms total)`); - } - - // Cleanup - killSession(); - sleep(DELAY_MS); + const t0 = Date.now(); + + // Create session + const { ok, stderr } = createSession(cmd); + if (!ok) { + results.createFail++; + console.log(` Round ${i + 1}: ❌ tmux create failed: ${stderr}`); + sleep(DELAY_MS); + continue; + } + + // Wait for session to stabilize + sleep(WAIT_AFTER_MS); + + // Check if session is alive + const alive = hasSession(); + const elapsed = Date.now() - t0; + results.times.push(elapsed); + + if (alive) { + results.success++; + if (VERBOSE) console.log(` Round ${i + 1}: ✅ alive (${elapsed}ms)`); + } else { + results.fail++; + console.log(` Round ${i + 1}: ❌ died within ${WAIT_AFTER_MS}ms (${elapsed}ms total)`); + } + + // Cleanup + killSession(); + sleep(DELAY_MS); } // ── Results ────────────────────────────────────────────────────────── const pct = ((results.success / ROUNDS) * 100).toFixed(1); -const avgMs = results.times.length > 0 - ? (results.times.reduce((a, b) => a + b, 0) / results.times.length).toFixed(0) - : "N/A"; +const avgMs = + results.times.length > 0 + ? (results.times.reduce((a, b) => a + b, 0) / results.times.length).toFixed(0) + : "N/A"; console.log(); console.log(`📊 Results:`); @@ -146,38 +147,38 @@ console.log(` Create failed: ${results.createFail}`); console.log(` Avg cycle time: ${avgMs}ms`); if (results.fail > 0) { - console.log(); - console.log(`💡 Suggestions:`); - console.log(` Try increasing --wait-after (current: ${WAIT_AFTER_MS}ms)`); - console.log(` Try increasing --delay (current: ${DELAY_MS}ms)`); - console.log(` Example: node scripts/tmux-spawn-test.mjs --delay 500 --wait-after 1000`); + console.log(); + console.log(`💡 Suggestions:`); + console.log(` Try increasing --wait-after (current: ${WAIT_AFTER_MS}ms)`); + console.log(` Try increasing --delay (current: ${DELAY_MS}ms)`); + console.log(` Example: node scripts/tmux-spawn-test.mjs --delay 500 --wait-after 1000`); } // ── Sweep test: find the minimum delay for 100% success ────────── if (hasFlag("--sweep")) { - console.log(); - console.log(`\n🔍 Delay sweep (finding minimum reliable delay)...`); - const SWEEP_ROUNDS = 10; - - for (const delay of [0, 100, 200, 500, 1000, 2000]) { - let ok = 0; - for (let i = 0; i < SWEEP_ROUNDS; i++) { - killSession(); - sleep(delay); - createSession(COMMAND); - sleep(WAIT_AFTER_MS); - if (hasSession()) ok++; - killSession(); - } - const rate = ((ok / SWEEP_ROUNDS) * 100).toFixed(0); - const icon = ok === SWEEP_ROUNDS ? "✅" : ok > SWEEP_ROUNDS / 2 ? "⚠️" : "❌"; - console.log(` ${icon} delay=${delay}ms: ${ok}/${SWEEP_ROUNDS} (${rate}%)`); - if (ok === SWEEP_ROUNDS) { - console.log(` → Minimum reliable delay: ${delay}ms`); - break; - } - } + console.log(); + console.log(`\n🔍 Delay sweep (finding minimum reliable delay)...`); + const SWEEP_ROUNDS = 10; + + for (const delay of [0, 100, 200, 500, 1000, 2000]) { + let ok = 0; + for (let i = 0; i < SWEEP_ROUNDS; i++) { + killSession(); + sleep(delay); + createSession(COMMAND); + sleep(WAIT_AFTER_MS); + if (hasSession()) ok++; + killSession(); + } + const rate = ((ok / SWEEP_ROUNDS) * 100).toFixed(0); + const icon = ok === SWEEP_ROUNDS ? "✅" : ok > SWEEP_ROUNDS / 2 ? "⚠️" : "❌"; + console.log(` ${icon} delay=${delay}ms: ${ok}/${SWEEP_ROUNDS} (${rate}%)`); + if (ok === SWEEP_ROUNDS) { + console.log(` → Minimum reliable delay: ${delay}ms`); + break; + } + } } process.exit(results.fail > 0 ? 1 : 0); diff --git a/taskplane-tasks/TP-193-cq-format-adoption/.DONE b/taskplane-tasks/TP-193-cq-format-adoption/.DONE new file mode 100644 index 00000000..d5cade0f --- /dev/null +++ b/taskplane-tasks/TP-193-cq-format-adoption/.DONE @@ -0,0 +1,2 @@ +Completed: 2026-05-10T17:32:35.606Z +Task: TP-193 diff --git a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md index 67eb5ad2..ab8d0915 100644 --- a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md +++ b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md @@ -1,11 +1,11 @@ # TP-193: Code-quality formatter adoption — Status -**Current Step:** Not Started -**Status:** 🔵 Ready for Execution +**Current Step:** Step 5: Testing & Verification (final) +**Status:** ✅ Complete **Last Updated:** 2026-05-10 **Review Level:** 0 (None) **Review Counter:** 0 -**Iteration:** 0 +**Iteration:** 1 **Size:** S (mechanically L) > **Hydration:** This task is a mechanical pass — STATUS.md tracks outcomes @@ -20,65 +20,66 @@ --- ### Step 0: Preflight -**Status:** ⬜ Not Started +**Status:** ✅ Complete -- [ ] On `main` (lane worktree, fresh from TP-191 + TP-192 merges) -- [ ] TP-191 + TP-192 confirmed merged (`npm run lint` exits 0; `npm run format:check` exits non-zero) -- [ ] **Operator freeze-window confirmation captured in Discoveries** (CRITICAL gate) -- [ ] Baseline test count recorded (3624+ passing) +- [x] On `main` (lane worktree, fresh from TP-191 + TP-192 merges) +- [x] TP-191 + TP-192 confirmed merged (`npm run lint` exits 0; `npm run format:check` exits 0 trivially because formatter is currently disabled — see Discoveries note) +- [x] **Operator freeze-window confirmation captured in Discoveries** (pre-recorded in row already present) +- [x] Baseline test count recorded (3624 passing / 1 skipped / 0 failed; 3625 total) --- ### Step 1: Configure formatter rules in biome.json -**Status:** ⬜ Not Started +**Status:** ✅ Complete -- [ ] `biome.json` formatter block updated per spec 6.3.1 -- [ ] `javascript.formatter` block updated per spec 6.3.1 -- [ ] `npm run format:check` reports many files needing formatting (sanity) -- [ ] Commit: `chore(TP-193): configure Biome formatter rules` +- [x] `biome.json` formatter block updated per spec 6.3.1 +- [x] `javascript.formatter` block updated per spec 6.3.1 +- [x] `npm run format:check` reports many files needing formatting (175 files / 175 errors, sanity) +- [x] Commit: `chore(TP-193): configure Biome formatter rules` --- ### Step 2: Apply biome format --write across the codebase -**Status:** ⬜ Not Started +**Status:** ✅ Complete -- [ ] `npm run format` applied -- [ ] Sample diff inspection confirms purely mechanical changes (no logic) -- [ ] Full fast suite passes -- [ ] Single commit captures the format pass; SHA recorded in Discoveries -- [ ] Commit message: `chore(TP-193): apply biome format --write to entire codebase (formatter adoption)` +- [x] `npm run format` applied (175 files formatted) +- [x] Sample diff inspection confirms purely mechanical changes (no logic) +- [x] Full fast suite passes (3624 pass / 1 skipped / 0 fail) +- [x] Single commit captures the format pass; SHA recorded in Discoveries (`f1d4533985e4853733d8f571920af8e2ac4a6cee`) +- [x] Commit message: `chore(TP-193): apply biome format --write to entire codebase (formatter adoption)` +- [x] Bonus: separate prep commit (`2c803c78`) made source-grep tests format-resilient (added `expect().toContainNormalized()` helper, bumped fixed-size source-slice windows, excluded test files from `tmux-reference-audit.mjs` strict-mode functional-usage detection) --- ### Step 3: Add .git-blame-ignore-revs -**Status:** ⬜ Not Started +**Status:** ✅ Complete -- [ ] `.git-blame-ignore-revs` created with Step 2 commit SHA + explanatory comment -- [ ] `git blame --ignore-revs-file=.git-blame-ignore-revs ` verified to skip the format commit -- [ ] Commit: `chore(TP-193): add format-adoption commit to .git-blame-ignore-revs` +- [x] `.git-blame-ignore-revs` created with Step 2 commit SHA + explanatory comment +- [x] `git blame --ignore-revs-file=.git-blame-ignore-revs ` verified to skip the format commit +- [x] Commit: `chore(TP-193): add format-adoption commit to .git-blame-ignore-revs` (combined commit with Step 4 docs) --- ### Step 4: Documentation & Delivery -**Status:** ⬜ Not Started +**Status:** ✅ Complete -- [ ] `docs/maintainers/development-setup.md` updated with `.git-blame-ignore-revs` setup section -- [ ] CHANGELOG entry under [Unreleased] → Internal added -- [ ] Discoveries logged below (file count, rule choices, any conflicts) +- [x] `docs/maintainers/development-setup.md` updated with `.git-blame-ignore-revs` setup section +- [x] CHANGELOG entry under [Unreleased] → Internal added +- [x] Discoveries logged below (file count: 161 source files reformatted; rule choices match spec 6.3.1; conflicts: see Discoveries rows for the test-resilience prep work) --- ### Step 5: Testing & Verification -**Status:** ⬜ Not Started +**Status:** ✅ Complete > ZERO test failures allowed. -- [ ] FULL fast suite passes (3624+) -- [ ] FULL integration suite passes -- [ ] `npm run format:check` exits 0 -- [ ] `npm run lint` exits 0 (TP-192 cleanup preserved) -- [ ] `npm run typecheck` count unchanged from TP-192 baseline -- [ ] CLI smoke clean +- [x] FULL fast suite passes (3624 / 3625; 1 skipped, 0 fail) +- [x] FULL integration suite passes (`npm test` includes `*.integration.test.ts` and reports the same 3624/0 result) +- [x] `npm run format:check` exits 0 (`Checked 175 files. No fixes applied.`) +- [x] `npm run lint` exits 0 (TP-192 cleanup preserved; 277 warnings + 668 infos, 0 errors) +- [x] `npm run typecheck` count unchanged at **264 errors** (TP-192 baseline preserved — format pass introduced ZERO new type errors) +- [x] CLI smoke clean: `node bin/taskplane.mjs help` exits 0; `node bin/taskplane.mjs doctor` parses and runs (exits 1 only because the lane worktree is missing `.pi/agents/*.md` template files — environmental, not a TP-193 regression) --- @@ -93,6 +94,12 @@ | Discovery | Disposition | Location | |-----------|-------------|----------| +| **Operator freeze-window pre-confirmation (2026-05-10)** | Step 0 unblocked | Supervisor verified no internal PRs in flight after TP-192 merge. The two open community PRs (#520 Nix CLI resolution by @chenxin-yan, #516 polyrepo by @loopyd DRAFT) are external forks; their rebase pain is the contributors' responsibility on their next update, not ours. Operator explicitly confirmed proceeding with TP-193 immediately after TP-192 merge. Step 0 freeze-window check should treat this row as the captured confirmation. | +| **Baseline metrics captured at Step 0** | Step 0 verified | `npm run lint` exits 0 (277 warnings + 660 infos, 0 errors — clean post TP-192). `npm run format:check` exits 0 trivially because `formatter.enabled: false` in current `biome.json` (biome reports `Checked 0 files`). The PROMPT's expectation that format:check would exit non-zero pre-Step-1 was inaccurate for biome 2.x — disabled formatter short-circuits to 0 instead of failing. Test baseline: 3625 tests / 3624 pass / 1 skipped / 0 fail. | +| **Format pass scope** | Step 2 verified | 175 files passed through Biome (`Formatted 175 files. Fixed 175 files.`); 161 of those produced a non-empty diff in git (the rest were already conformant or within whitespace-only equivalent). Total diff: +26,523 / -16,703 lines (mostly long-line vertical re-wrapping with `lineWidth: 100`). Format-pass commit SHA: `f1d4533985e4853733d8f571920af8e2ac4a6cee`. | +| **Brittle source-grep tests — test-resilience prep was unavoidable** | Resolved via prep commit `2c803c78` | The codebase has ~22 distinct `expect(source).toContain("literal-multi-token-substring")` assertions across ~20 test files that broke when the formatter wrapped `foo(a, b, c)` calls vertically into `foo(\n\ta,\n\tb,\n\tc,\n)`. Fixed by adding `toContainNormalized()` (whitespace + bracket-padding + trailing-comma normalization) and bumping a few fixed-size source-slice windows. The PROMPT's "format pass = mechanical only" rule made this a separate prep commit so `.git-blame-ignore-revs` cleanly targets only `f1d45339`. | +| **`tmux-reference-audit.mjs` strict-mode false positive after format** | Resolved in prep commit `2c803c78` | Biome's `quoteStyle: "double"` rule with smart-quote switching rewrote test assertions like `'execSync(\\'tmux list-sessions\\')'` to `"execSync('tmux list-sessions")` (outer single → outer double, removing the escapes that previously hid the substring from the audit's regex). Audit's `FUNCTIONAL_PATTERNS` regex then started matching 4 test-only assertions as if they were real TMUX execution. Fix: added `isTestSourceFile()` helper to the audit that skips strict-mode functional-usage detection inside `*.test.ts` / `tests/` files (these are negative assertions about production code, not actual TMUX calls). Reference counts under compat-code/comments-docs are unaffected. | +| **Rule choices match codebase conventions** | Step 1 verified | Tabs, double quotes, semicolons — all already in use throughout the codebase pre-TP-193. The format pass diff is dominated by line-wrapping (lineWidth: 100), trailing-comma insertion, and arrow-paren insertion (`x =>` → `(x) =>`). No convention changes. | --- @@ -101,6 +108,11 @@ | Timestamp | Action | Outcome | |-----------|--------|---------| | 2026-05-10 | Task staged | PROMPT.md and STATUS.md created | +| 2026-05-10 17:04 | Task started | Runtime V2 lane-runner execution | +| 2026-05-10 17:04 | Step 0 started | Preflight | +| 2026-05-10 | Steps 0-5 complete | Format pass landed in single mechanical commit `f1d4533985e4853733d8f571920af8e2ac4a6cee`; test-resilience prep in `2c803c78`; .git-blame-ignore-revs + dev-setup docs + CHANGELOG in `f1d1d8d`. All gates green: format:check 0, lint 0, typecheck 264 (TP-192 baseline), tests 3624/0/1. | +| 2026-05-10 17:32 | Worker iter 1 | done in 1675s, tools: 219 | +| 2026-05-10 17:32 | Task complete | .DONE created | --- diff --git a/taskplane-tasks/dependencies.json b/taskplane-tasks/dependencies.json index fde31cb2..a7ab57d4 100644 --- a/taskplane-tasks/dependencies.json +++ b/taskplane-tasks/dependencies.json @@ -1,10 +1,11 @@ { "version": 1, - "generatedAt": "2026-05-03T17:24:19.939Z", + "generatedAt": "2026-05-10T17:42:26.235Z", "source": "prompt", "tasks": { - "TP-181": [], - "TP-182": [], - "TP-183": [] + "TP-114": [], + "TP-193": [], + "TP-194": [], + "TP-195": [] } }