From acdbbf90c4c782041fb0268229b90395a616a8de Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:04:16 -0400 Subject: [PATCH 01/10] task(TP-193): pre-record operator freeze-window confirmation in STATUS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TP-193's Step 0 has a CRITICAL operator confirmation check: > Operator freeze-window confirmation captured in Discoveries > (CRITICAL gate) Without this, the task aborts rather than landing a 50+-file format diff. Per spec section 6.3.4, the operator must confirm no in-flight PRs will be tangled. Operator confirmed (this session, 2026-05-10): TP-192 just merged (PR #571), no other internal PRs in flight. The two open community PRs (#520, #516) are external forks — their rebase pain is the contributors' responsibility, not ours. Pre-recording this in STATUS.md Discoveries so the worker's Step 0 check finds the confirmation immediately and proceeds without pausing for operator interaction. --- taskplane-tasks/TP-193-cq-format-adoption/STATUS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md index 67eb5ad2..05ace325 100644 --- a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md +++ b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md @@ -93,6 +93,7 @@ | 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. | --- From 34316a3452105778e17fc7b5075773f816f8a884 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:06:12 -0400 Subject: [PATCH 02/10] chore(TP-193): step 0 preflight complete (baseline captured) --- .../TP-193-cq-format-adoption/STATUS.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md index 05ace325..77969737 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 1: Configure formatter rules +**Status:** 🟔 In Progress **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,12 +20,12 @@ --- ### 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) --- @@ -94,6 +94,7 @@ | 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. | --- @@ -102,6 +103,8 @@ | 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 | --- From 52137890b6fdbd7ff5ff878d0a3e9c6f51830959 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:06:30 -0400 Subject: [PATCH 03/10] chore(TP-193): configure Biome formatter rules --- biome.json | 14 +++++++++++++- .../TP-193-cq-format-adoption/STATUS.md | 12 ++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/biome.json b/biome.json index 2fbb1a9d..72186f6c 100644 --- a/biome.json +++ b/biome.json @@ -42,6 +42,18 @@ } }, "formatter": { - "enabled": false + "enabled": true, + "indentStyle": "tab", + "indentWidth": 1, + "lineWidth": 100, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always" + } } } diff --git a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md index 77969737..bb5e53ac 100644 --- a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md +++ b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md @@ -1,6 +1,6 @@ # TP-193: Code-quality formatter adoption — Status -**Current Step:** Step 1: Configure formatter rules +**Current Step:** Step 2: Apply biome format --write **Status:** 🟔 In Progress **Last Updated:** 2026-05-10 **Review Level:** 0 (None) @@ -30,12 +30,12 @@ --- ### 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` --- From 2c803c78b2118d99f938d5977e442536249493b8 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:17:56 -0400 Subject: [PATCH 04/10] test(TP-193): make source-grep tests format-resilient (prep for biome format pass) Adds expect().toContainNormalized(needle) helper that whitespace-normalizes both haystack and needle before substring matching. Source-grep tests that match literal multi-word substrings of taskplane source files are brittle against Biome's lineWidth=100 / arrowParentheses=always rules, which wrap multi-arg calls vertically and add parens around single-param arrows. Updated 22 distinct assertion sites across these test files to either: - Use toContainNormalized() instead of toContain() for source-grep checks, - Pre-normalize whitespace before regex .match() / .indexOf(), - Bump fixed-size source-slice windows that were tight (e.g. 5000 -> 8000) so formatter-induced vertical re-wrapping doesn't push expected strings outside the inspected window. This change is intentionally separate from the format-write commit so the '.git-blame-ignore-revs' SHA points at a single mechanical commit. --- .../auto-integration.integration.test.ts | 2 +- .../tests/engine-runtime-v2-routing.test.ts | 22 ++++++++---- extensions/tests/exit-interception.test.ts | 4 ++- extensions/tests/expect.ts | 34 +++++++++++++++++++ extensions/tests/force-resume.test.ts | 7 ++-- extensions/tests/lane-runner-v2.test.ts | 4 +-- .../tests/mailbox-supervisor-tool.test.ts | 2 +- .../tests/merge-timeout-resilience.test.ts | 4 +-- .../orch-supervisor-recovery-tools.test.ts | 2 +- .../tests/orch-supervisor-tools.test.ts | 4 +-- extensions/tests/process-registry.test.ts | 2 +- extensions/tests/retry-matrix.test.ts | 8 +++-- .../tests/segment-expansion-engine.test.ts | 9 +++-- .../tests/segment-scoped-lane-runner.test.ts | 16 ++++++--- .../tests/spawn-failure-visibility.test.ts | 12 ++++--- .../tests/supervisor-force-merge.test.ts | 4 +-- .../tests/supervisor-recovery-flows.test.ts | 4 ++- extensions/tests/tier0-watchdog.test.ts | 16 +++++---- extensions/tests/verification-step4.test.ts | 6 ++-- scripts/tmux-reference-audit.mjs | 28 ++++++++++++++- 20 files changed, 144 insertions(+), 46 deletions(-) diff --git a/extensions/tests/auto-integration.integration.test.ts b/extensions/tests/auto-integration.integration.test.ts index e7acad33..61e320f7 100644 --- a/extensions/tests/auto-integration.integration.test.ts +++ b/extensions/tests/auto-integration.integration.test.ts @@ -1589,7 +1589,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 diff --git a/extensions/tests/engine-runtime-v2-routing.test.ts b/extensions/tests/engine-runtime-v2-routing.test.ts index 3df51761..bdb535d3 100644 --- a/extensions/tests/engine-runtime-v2-routing.test.ts +++ b/extensions/tests/engine-runtime-v2-routing.test.ts @@ -76,7 +76,7 @@ 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).toContainNormalized("const lanePromises = lanes.map((lane) =>"); expect(executionSrc).toContain("executeLaneV2(lane, config"); }); @@ -154,11 +154,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 +171,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); diff --git a/extensions/tests/exit-interception.test.ts b/extensions/tests/exit-interception.test.ts index 914812b1..c2450d51 100644 --- a/extensions/tests/exit-interception.test.ts +++ b/extensions/tests/exit-interception.test.ts @@ -162,7 +162,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..c629c5c1 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; @@ -55,6 +63,19 @@ export function expect(actual: unknown): ExpectMethods { 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}`, + ); + const normalize = (s: string) => s.replace(/\s+/g, " ").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); }, @@ -190,6 +211,19 @@ export function expect(actual: unknown): ExpectMethods { 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, " ").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); }, diff --git a/extensions/tests/force-resume.test.ts b/extensions/tests/force-resume.test.ts index e8538169..fd24b03a 100644 --- a/extensions/tests/force-resume.test.ts +++ b/extensions/tests/force-resume.test.ts @@ -329,9 +329,12 @@ 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) + // 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(resumeSource).toMatch(isForceResumePattern); + expect(normSrc).toMatch(isForceResumePattern); }); }); diff --git a/extensions/tests/lane-runner-v2.test.ts b/extensions/tests/lane-runner-v2.test.ts index 9f63e4b6..05169719 100644 --- a/extensions/tests/lane-runner-v2.test.ts +++ b/extensions/tests/lane-runner-v2.test.ts @@ -282,7 +282,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"); }); }); @@ -339,6 +339,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..87a1ef96 100644 --- a/extensions/tests/mailbox-supervisor-tool.test.ts +++ b/extensions/tests/mailbox-supervisor-tool.test.ts @@ -30,7 +30,7 @@ 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/merge-timeout-resilience.test.ts b/extensions/tests/merge-timeout-resilience.test.ts index 921b5e0a..6fb7bd75 100644 --- a/extensions/tests/merge-timeout-resilience.test.ts +++ b/extensions/tests/merge-timeout-resilience.test.ts @@ -153,7 +153,7 @@ 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", () => { @@ -228,7 +228,7 @@ 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", () => { diff --git a/extensions/tests/orch-supervisor-recovery-tools.test.ts b/extensions/tests/orch-supervisor-recovery-tools.test.ts index 2c565595..d3054965 100644 --- a/extensions/tests/orch-supervisor-recovery-tools.test.ts +++ b/extensions/tests/orch-supervisor-recovery-tools.test.ts @@ -65,7 +65,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", () => { diff --git a/extensions/tests/orch-supervisor-tools.test.ts b/extensions/tests/orch-supervisor-tools.test.ts index 1aedda40..64c63c60 100644 --- a/extensions/tests/orch-supervisor-tools.test.ts +++ b/extensions/tests/orch-supervisor-tools.test.ts @@ -131,13 +131,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", () => { diff --git a/extensions/tests/process-registry.test.ts b/extensions/tests/process-registry.test.ts index 344a2011..70c8c503 100644 --- a/extensions/tests/process-registry.test.ts +++ b/extensions/tests/process-registry.test.ts @@ -417,7 +417,7 @@ 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).toContainNormalized("assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0"); expect(hostSrc).toContain("{ type: \"get_session_stats\" }"); }); diff --git a/extensions/tests/retry-matrix.test.ts b/extensions/tests/retry-matrix.test.ts index 28b2de9a..69c5904e 100644 --- a/extensions/tests/retry-matrix.test.ts +++ b/extensions/tests/retry-matrix.test.ts @@ -617,7 +617,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 +634,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"); diff --git a/extensions/tests/segment-expansion-engine.test.ts b/extensions/tests/segment-expansion-engine.test.ts index 4c2732fd..f9912da5 100644 --- a/extensions/tests/segment-expansion-engine.test.ts +++ b/extensions/tests/segment-expansion-engine.test.ts @@ -396,8 +396,13 @@ 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, " "); + 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"); }); }); diff --git a/extensions/tests/segment-scoped-lane-runner.test.ts b/extensions/tests/segment-scoped-lane-runner.test.ts index 94ea459c..45e2ffbe 100644 --- a/extensions/tests/segment-scoped-lane-runner.test.ts +++ b/extensions/tests/segment-scoped-lane-runner.test.ts @@ -407,9 +407,12 @@ describe("7.x: Legacy fallback — no behavior change for tasks without markers" }); 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", () => { @@ -440,12 +443,15 @@ 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, " "); + 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/spawn-failure-visibility.test.ts b/extensions/tests/spawn-failure-visibility.test.ts index 22cf6ea7..598bf759 100644 --- a/extensions/tests/spawn-failure-visibility.test.ts +++ b/extensions/tests/spawn-failure-visibility.test.ts @@ -597,11 +597,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 +612,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 +645,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", () => { diff --git a/extensions/tests/supervisor-force-merge.test.ts b/extensions/tests/supervisor-force-merge.test.ts index 2eeafca8..3dc715b5 100644 --- a/extensions/tests/supervisor-force-merge.test.ts +++ b/extensions/tests/supervisor-force-merge.test.ts @@ -138,14 +138,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", () => { diff --git a/extensions/tests/supervisor-recovery-flows.test.ts b/extensions/tests/supervisor-recovery-flows.test.ts index 018b85f1..7940dcf1 100644 --- a/extensions/tests/supervisor-recovery-flows.test.ts +++ b/extensions/tests/supervisor-recovery-flows.test.ts @@ -688,7 +688,9 @@ 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 body = executionSrc.slice(start, start + 12000); const respawnIdx = body.indexOf("onLaneRespawned(lane.laneNumber"); const forIdx = body.indexOf("for (const task of lane.tasks)"); expect(respawnIdx).not.toBe(-1); diff --git a/extensions/tests/tier0-watchdog.test.ts b/extensions/tests/tier0-watchdog.test.ts index 8676f57e..98dc89e4 100644 --- a/extensions/tests/tier0-watchdog.test.ts +++ b/extensions/tests/tier0-watchdog.test.ts @@ -212,11 +212,13 @@ 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 + // 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.*merge_timeout/g); + const mergeEscalation = mergeSection.match(/emitTier0Escalation[\s\S]*?merge_timeout/g); expect(mergeEscalation).not.toBeNull(); }); }); @@ -841,8 +843,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 +853,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/verification-step4.test.ts b/extensions/tests/verification-step4.test.ts index d55a80f5..21faf38f 100644 --- a/extensions/tests/verification-step4.test.ts +++ b/extensions/tests/verification-step4.test.ts @@ -326,7 +326,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", () => { @@ -614,13 +614,13 @@ 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", () => { diff --git a/scripts/tmux-reference-audit.mjs b/scripts/tmux-reference-audit.mjs index 37121828..0a4439ba 100644 --- a/scripts/tmux-reference-audit.mjs +++ b/scripts/tmux-reference-audit.mjs @@ -102,6 +102,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; @@ -210,7 +236,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"); From f1d4533985e4853733d8f571920af8e2ac4a6cee Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:27:50 -0400 Subject: [PATCH 05/10] chore(TP-193): apply biome format --write to entire codebase (formatter adoption) --- bin/gitignore-patterns.mjs | 19 +- bin/rpc-wrapper.mjs | 767 +- bin/taskplane.mjs | 783 +- biome.json | 114 +- extensions/reviewer-extension.ts | 28 +- extensions/taskplane/abort.ts | 68 +- .../taskplane/agent-bridge-extension.ts | 337 +- extensions/taskplane/agent-host.ts | 321 +- extensions/taskplane/cleanup.ts | 113 +- extensions/taskplane/config-loader.ts | 149 +- extensions/taskplane/config-schema.ts | 19 +- extensions/taskplane/config.ts | 12 +- extensions/taskplane/diagnostic-reports.ts | 106 +- extensions/taskplane/diagnostics.ts | 26 +- extensions/taskplane/discovery.ts | 96 +- extensions/taskplane/engine-worker.ts | 99 +- extensions/taskplane/engine.ts | 2345 +++- extensions/taskplane/execution.ts | 605 +- extensions/taskplane/extension.ts | 1638 ++- extensions/taskplane/formatting.ts | 260 +- extensions/taskplane/git.ts | 2 - extensions/taskplane/lane-runner.ts | 851 +- extensions/taskplane/mailbox.ts | 106 +- extensions/taskplane/merge.ts | 1015 +- extensions/taskplane/messages.ts | 160 +- extensions/taskplane/migrations.ts | 2 +- extensions/taskplane/path-resolver.ts | 17 +- extensions/taskplane/persistence.ts | 660 +- extensions/taskplane/process-registry.ts | 37 +- extensions/taskplane/quality-gate.ts | 162 +- extensions/taskplane/resume.ts | 1016 +- extensions/taskplane/sessions.ts | 2 +- extensions/taskplane/settings-tui.ts | 669 +- extensions/taskplane/sidecar-telemetry.ts | 35 +- extensions/taskplane/supervisor.ts | 747 +- extensions/taskplane/task-executor-core.ts | 225 +- extensions/taskplane/types.ts | 280 +- extensions/taskplane/verification.ts | 39 +- extensions/taskplane/waves.ts | 102 +- extensions/taskplane/workspace.ts | 26 +- extensions/taskplane/worktree.ts | 414 +- ...egration-deterministic.integration.test.ts | 69 +- .../auto-integration.integration.test.ts | 257 +- .../tests/batch-history-persistence.test.ts | 60 +- extensions/tests/cleanup-artifacts.test.ts | 41 +- extensions/tests/cleanup-resilience.test.ts | 620 +- .../tests/cli-doctor-version-capture.test.ts | 26 +- .../tests/context-pressure-cache.test.ts | 36 +- .../tests/context-window-autodetect.test.ts | 49 +- .../tests/context-window-resolution.test.ts | 9 +- .../tests/conversation-event-fidelity.test.ts | 169 +- .../tests/dashboard-history-load.test.ts | 25 +- extensions/tests/diagnostic-reports.test.ts | 55 +- extensions/tests/discovery-routing.test.ts | 149 +- .../tests/discovery-segment-steps.test.ts | 118 +- .../tests/engine-runtime-v2-routing.test.ts | 50 +- .../tests/engine-segment-frontier.test.ts | 120 +- extensions/tests/engine-worker-thread.test.ts | 16 +- .../exec-check-error-classification.test.ts | 12 +- .../tests/execution-path-resolution.test.ts | 262 +- extensions/tests/exit-classification.test.ts | 86 +- extensions/tests/exit-interception.test.ts | 13 +- extensions/tests/expect.ts | 81 +- extensions/tests/extension-forwarding.test.ts | 5 +- .../tests/extension-ipc-batchid-scope.test.ts | 21 +- .../external-task-path-resolution.test.ts | 320 +- extensions/tests/fixtures/polyrepo-builder.ts | 67 +- extensions/tests/force-resume.test.ts | 54 +- .../tests/gitignore-pattern-matching.test.ts | 14 +- extensions/tests/gitignore-patterns.test.ts | 2 +- extensions/tests/global-preferences.test.ts | 414 +- .../init-mode-detection.integration.test.ts | 32 +- extensions/tests/init-model-discovery.test.ts | 15 +- extensions/tests/init-model-picker.test.ts | 16 +- .../tests/lane-runner-spawn-wiring.test.ts | 20 +- extensions/tests/lane-runner-v2.test.ts | 15 +- .../tests/mailbox-supervisor-tool.test.ts | 4 +- extensions/tests/mailbox-v2.test.ts | 77 +- extensions/tests/mailbox.test.ts | 173 +- extensions/tests/merge-failure-phase.test.ts | 42 +- extensions/tests/merge-repo-scoped.test.ts | 653 +- .../tests/merge-result-schema-compat.test.ts | 15 +- .../tests/merge-timeout-resilience.test.ts | 24 +- extensions/tests/migrations.test.ts | 7 +- extensions/tests/mocks/pi-ai.ts | 16 +- extensions/tests/mocks/pi-coding-agent.ts | 4 +- extensions/tests/mocks/pi-tui.ts | 2 +- .../tests/monorepo-compat-regression.test.ts | 197 +- extensions/tests/naming-collision.test.ts | 111 +- extensions/tests/non-blocking-engine.test.ts | 375 +- ...-direct-implementation.integration.test.ts | 1265 +- .../tests/orch-integrate.integration.test.ts | 345 +- extensions/tests/orch-pure-functions.test.ts | 2121 +-- extensions/tests/orch-rpc-telemetry.test.ts | 11 +- .../tests/orch-state-persistence.test.ts | 10844 +++++++++------- .../orch-supervisor-recovery-tools.test.ts | 24 +- .../tests/orch-supervisor-tools.test.ts | 32 +- .../tests/orchestrator-startup-uxv2.test.ts | 5 +- .../tests/outcome-embedded-telemetry.test.ts | 82 +- extensions/tests/packet-home-contract.test.ts | 35 +- .../partial-progress.integration.test.ts | 515 +- .../tests/path-resolver-pi-scope.test.ts | 4 +- extensions/tests/polyrepo-fixture.test.ts | 20 +- extensions/tests/polyrepo-regression.test.ts | 110 +- extensions/tests/process-registry.test.ts | 141 +- .../tests/project-config-loader.test.ts | 793 +- extensions/tests/quality-gate.test.ts | 690 +- extensions/tests/resume-bug-fixes.test.ts | 294 +- .../tests/resume-segment-frontier.test.ts | 519 +- extensions/tests/retry-matrix.test.ts | 121 +- .../tests/review-step-guard-runtime.test.ts | 11 +- .../reviewer-dashboard-visibility.test.ts | 112 +- .../tests/reviewer-quality-checks.test.ts | 4 +- extensions/tests/rpc-wrapper.test.ts | 407 +- .../tests/runtime-model-fallback.test.ts | 49 +- extensions/tests/runtime-v2-contracts.test.ts | 10 +- extensions/tests/schema-v4-migration.test.ts | 102 +- .../tests/segment-boundary-done-guard.test.ts | 43 +- .../tests/segment-expansion-engine.test.ts | 409 +- .../tests/segment-expansion-tool.test.ts | 306 +- .../tests/segment-marker-validation.test.ts | 55 +- extensions/tests/segment-model.test.ts | 47 +- .../tests/segment-scoped-lane-runner.test.ts | 46 +- .../tests/segment-state-persistence.test.ts | 46 +- extensions/tests/settings-loader.test.ts | 9 +- extensions/tests/settings-tui.test.ts | 276 +- extensions/tests/sidecar-tailing.test.ts | 107 +- .../tests/skip-progress-preservation.test.ts | 66 +- .../tests/spawn-failure-visibility.test.ts | 66 +- .../stale-branch-cleanup.integration.test.ts | 40 +- extensions/tests/state-migration.test.ts | 200 +- .../tests/status-reconciliation.test.ts | 112 +- extensions/tests/supervisor-alerts.test.ts | 27 +- .../tests/supervisor-force-merge.test.ts | 107 +- .../tests/supervisor-merge-monitoring.test.ts | 138 +- .../tests/supervisor-onboarding.test.ts | 306 +- .../tests/supervisor-recovery-flows.test.ts | 198 +- .../tests/supervisor-recovery-tools.test.ts | 92 +- extensions/tests/supervisor-template.test.ts | 36 +- extensions/tests/supervisor.test.ts | 291 +- .../tests/task-runner-review-skip.test.ts | 20 +- extensions/tests/tier0-watchdog.test.ts | 77 +- extensions/tests/tmux-compat.test.ts | 5 +- extensions/tests/tmux-reference-guard.test.ts | 2 +- extensions/tests/transactional-merge.test.ts | 8 +- .../tests/ux-integrate-visibility.test.ts | 134 +- .../tests/verification-baseline.test.ts | 61 +- extensions/tests/verification-mode.test.ts | 44 +- extensions/tests/verification-step4.test.ts | 174 +- extensions/tests/waves-repo-scoped.test.ts | 132 +- ...indows-worktree-cleanup-behavioral.test.ts | 44 +- .../windows-worktree-cleanup-fallback.test.ts | 4 +- extensions/tests/worker-model.test.ts | 7 +- .../worker-step-completion-protocol.test.ts | 21 +- .../tests/worker-tools-allowlist.test.ts | 30 +- .../workspace-config.integration.test.ts | 235 +- .../worktree-lifecycle.integration.test.ts | 612 +- scripts/local-build.mjs | 154 +- scripts/runtime-v2-lab/run-lab.mjs | 66 +- scripts/tmux-reference-audit.mjs | 28 +- scripts/tmux-spawn-test.mjs | 189 +- 161 files changed, 26523 insertions(+), 16703 deletions(-) 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 72186f6c..a58851db 100644 --- a/biome.json +++ b/biome.json @@ -1,59 +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": true, - "indentStyle": "tab", - "indentWidth": 1, - "lineWidth": 100, - "lineEnding": "lf" - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "trailingCommas": "all", - "semicolons": "always", - "arrowParentheses": "always" - } - } + "$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/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 61e320f7..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); @@ -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 bdb535d3..bcd4189e 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 ───────────────────────────── @@ -77,7 +69,7 @@ describe("2.x: executeWave backend parameter", () => { it("2.3: executeWave routes lanes directly to executeLaneV2", () => { expect(executionSrc).toContainNormalized("const lanePromises = lanes.map((lane) =>"); - expect(executionSrc).toContain("executeLaneV2(lane, config"); + expect(executionSrc).toContainNormalized("executeLaneV2(lane, config"); }); it("2.4: executeWave forces Runtime V2 even when backend is omitted", () => { @@ -216,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"); }); @@ -421,7 +425,7 @@ describe("11.x: Merge V2 liveness + abort correctness", () => { 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", () => { @@ -442,7 +446,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); @@ -590,7 +594,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", () => { @@ -604,11 +608,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 c2450d51..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", () => { diff --git a/extensions/tests/expect.ts b/extensions/tests/expect.ts index c629c5c1..b346f919 100644 --- a/extensions/tests/expect.ts +++ b/extensions/tests/expect.ts @@ -55,10 +55,7 @@ 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`); } @@ -68,7 +65,18 @@ export function expect(actual: unknown): ExpectMethods { typeof actual === "string", `toContainNormalized: actual must be a string, got ${typeof actual}`, ); - const normalize = (s: string) => s.replace(/\s+/g, " ").trim(); + // 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( @@ -95,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; @@ -160,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; @@ -203,10 +196,7 @@ 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`); } @@ -216,7 +206,13 @@ export function expect(actual: unknown): ExpectMethods { typeof actual === "string", `not.toContainNormalized: actual must be a string, got ${typeof actual}`, ); - const normalize = (s: string) => s.replace(/\s+/g, " ").trim(); + 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( @@ -243,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; @@ -300,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 fd24b03a..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 @@ -333,7 +349,8 @@ describe("force-resume runtime path in resumeOrchBatch — source verification", // 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"\)/; + const isForceResumePattern = + /const isForceResume = force && \(persistedState\.phase === "stopped" \|\| persistedState\.phase === "failed"\)/; expect(normSrc).toMatch(isForceResumePattern); }); }); @@ -366,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); }); @@ -382,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 05169719..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 ───────────────────────────────── @@ -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", () => { diff --git a/extensions/tests/mailbox-supervisor-tool.test.ts b/extensions/tests/mailbox-supervisor-tool.test.ts index 87a1ef96..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).toContainNormalized("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 6fb7bd75..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", () => { @@ -159,7 +161,9 @@ describe("2.x — Kill-and-retry: timeout triggers retry with 2x timeout", () => 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).toContainNormalized("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 d3054965..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( @@ -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 64c63c60..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}"`); @@ -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 70c8c503..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).toContainNormalized("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 69c5904e..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 = {}; @@ -849,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", @@ -872,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", @@ -924,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] }); @@ -932,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 f9912da5..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"]); @@ -398,7 +506,11 @@ describe("TP-143 segment expansion engine coverage", () => { const src = readFileSync(new URL("../taskplane/engine.ts", import.meta.url), "utf-8"); // 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, " "); + 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\)\)/, ); @@ -444,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); @@ -453,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", []]]), }; @@ -475,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); @@ -484,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", []]]), }; @@ -506,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); @@ -547,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); @@ -558,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], ]); @@ -594,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 45e2ffbe..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,12 +395,16 @@ 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)"); }); @@ -416,7 +418,9 @@ describe("7.x: Legacy fallback — no behavior change for tasks without markers" }); 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", + ); }); }); @@ -434,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", () => { @@ -445,13 +451,19 @@ describe("8.x: Snapshot segment-scoped progress (emitSnapshot)", () => { it("8.3: all emitSnapshot calls pass snapshotSegmentCtx", () => { // 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, " "); + 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).toContainNormalized("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 598bf759..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"), @@ -602,7 +591,7 @@ describe("TP-190 #561: engine.ts wire-up for spawn_failure", () => { expect(phaseBlock).toContainNormalized("isAllLanesSpawnFailedWave(waveResult, allTaskOutcomes)"); expect(phaseBlock).toContain('batchState.phase = "failed"'); // Persist + terminal event + break out of wave loop. - expect(phaseBlock).toContainNormalized("persistRuntimeState(\"wave-spawn-failure\""); + expect(phaseBlock).toContainNormalized('persistRuntimeState("wave-spawn-failure"'); expect(phaseBlock).toContain("emitTerminalEvent("); expect(phaseBlock).toContain("break;"); }); @@ -708,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 () => { @@ -741,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. @@ -790,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 3dc715b5..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, @@ -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 7940dcf1..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", () => { @@ -690,7 +785,14 @@ describe("TP-187 #538: lane-respawned IPC wiring is end-to-end", () => { const start = executionSrc.indexOf("export async function executeLaneV2("); // TP-193: Window bumped from 7500 to 12000 to absorb formatter re-wrapping // (multi-arg calls split across lines lengthens the function body). - const body = executionSrc.slice(start, start + 12000); + 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); @@ -710,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 98dc89e4..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); } // ══════════════════════════════════════════════════════════════════════ @@ -215,9 +212,7 @@ describe("2.x — Retry exhaustion pauses batch with escalation event", () => { // 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 mergeSection = engineSource.substring(engineSource.indexOf("applyMergeRetryLoop")); const mergeEscalation = mergeSection.match(/emitTier0Escalation[\s\S]*?merge_timeout/g); expect(mergeEscalation).not.toBeNull(); }); @@ -317,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") { @@ -339,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 @@ -381,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 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 21faf38f..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", }); @@ -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 ]; @@ -620,7 +648,9 @@ describe("Flaky handling: flakyReruns control paths (source verification)", () = 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).toContainNormalized("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 0a4439ba..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 = [ { @@ -143,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); } @@ -176,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]; @@ -203,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 = []; @@ -273,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, @@ -322,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); From f1d1d8d4845222abd41ccc94ae5cba67202ecc89 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:29:46 -0400 Subject: [PATCH 06/10] chore(TP-193): add format-adoption commit to .git-blame-ignore-revs + dev-setup docs + CHANGELOG --- .git-blame-ignore-revs | 22 ++++++++++ CHANGELOG.md | 33 +++++++++++++++ docs/maintainers/development-setup.md | 41 +++++++++++++++++++ .../TP-193-cq-format-adoption/STATUS.md | 35 +++++++++------- 4 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 .git-blame-ignore-revs 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/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/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/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md index bb5e53ac..4835cb7f 100644 --- a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md +++ b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md @@ -1,6 +1,6 @@ # TP-193: Code-quality formatter adoption — Status -**Current Step:** Step 2: Apply biome format --write +**Current Step:** Step 5: Testing & Verification **Status:** 🟔 In Progress **Last Updated:** 2026-05-10 **Review Level:** 0 (None) @@ -40,31 +40,32 @@ --- ### 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) --- @@ -95,6 +96,10 @@ |-----------|-------------|----------| | **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. | --- From 92a484f6d177f08e4dfb823a34a7c7fd0dea9323 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:32:09 -0400 Subject: [PATCH 07/10] =?UTF-8?q?chore(TP-193):=20finalize=20STATUS.md=20?= =?UTF-8?q?=E2=80=94=20all=205=20steps=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TP-193-cq-format-adoption/STATUS.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md index 4835cb7f..4c1951ef 100644 --- a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md +++ b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md @@ -1,7 +1,7 @@ # TP-193: Code-quality formatter adoption — Status -**Current Step:** Step 5: Testing & Verification -**Status:** 🟔 In Progress +**Current Step:** Step 5: Testing & Verification (final) +**Status:** āœ… Complete **Last Updated:** 2026-05-10 **Review Level:** 0 (None) **Review Counter:** 0 @@ -70,16 +70,16 @@ --- ### 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) --- @@ -110,6 +110,7 @@ | 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. | --- From f01df624b672b408d8809785abbe16157c4b1163 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:32:35 -0400 Subject: [PATCH 08/10] checkpoint: TP-193 task artifacts (.DONE, STATUS.md) --- taskplane-tasks/TP-193-cq-format-adoption/.DONE | 2 ++ taskplane-tasks/TP-193-cq-format-adoption/STATUS.md | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 taskplane-tasks/TP-193-cq-format-adoption/.DONE 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 4c1951ef..ab8d0915 100644 --- a/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md +++ b/taskplane-tasks/TP-193-cq-format-adoption/STATUS.md @@ -111,6 +111,8 @@ | 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 | --- From 833449c8089c4235e97e706b5582ac74c95f3941 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:42:19 -0400 Subject: [PATCH 09/10] fix(TP-193): bump slice windows in engine-runtime-v2-routing tests 9.5 and 11.6 (post-merge fold) Merger verification of the format-pass merge into the topic branch caught one source-grep test that the format-resilience prep (commit 2c803c78) missed: 'engine-runtime-v2-routing.test.ts:307' test 9.5 'mergeWave routes spawn to V2 when backend is v2'. The test slices merge.ts source for 16000 chars from the start of the mergeWave function declaration and counts spawnMergeAgentV2( occurrences. After the format pass, the merge state shifted the second call's opening paren to ~offset 15999 \u2014 within the function body but past the slice window. Test expected >= 2 matches, got 1, failed verification. Fix: bump the slice window 16000 \u2192 24000 in both test 9.5 (line 310) and test 11.6 (line 422, same pattern). 24000 leaves comfortable headroom for future formatter-induced length variance. Matches the prep commit's pattern: 'bump fixed-size source-slice windows that were tight'. No semantic test change \u2014 same assertions, just larger inspection window. Verified locally: - tests/engine-runtime-v2-routing.test.ts: 78/78 pass - Full merger filter (non-integration): 3124 pass / 0 fail / 1 skipped After this commit lands on the lane, orch_resume(force=true) will re-run the merge with the corrected lane state. Verification should pass. --- extensions/tests/engine-runtime-v2-routing.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/extensions/tests/engine-runtime-v2-routing.test.ts b/extensions/tests/engine-runtime-v2-routing.test.ts index bcd4189e..d81cf486 100644 --- a/extensions/tests/engine-runtime-v2-routing.test.ts +++ b/extensions/tests/engine-runtime-v2-routing.test.ts @@ -305,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 }); @@ -418,8 +423,11 @@ 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"); }); From c9ae7f8b75fea70b0d0eb918deb438f6cef29c26 Mon Sep 17 00:00:00 2001 From: Henry Lach Date: Sun, 10 May 2026 13:47:31 -0400 Subject: [PATCH 10/10] chore(TP-193): add .gitattributes to enforce LF line endings (post-merge fold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 175 spurious 'format/use-line-feed' errors that surfaced after the format pass merged. The repo's index correctly stores LF, but Windows local checkouts with default 'core.autocrlf=true' converted files to CRLF in the working tree. Biome's 'lineEnding: "lf"' rule flagged every file as a format violation on Windows, while CI on Linux (no autocrlf) would have passed cleanly. Adding '.gitattributes' with '* text=auto eol=lf' overrides autocrlf at the repo level — ensures LF in the working tree on all platforms regardless of per-developer git config. Standard cross-platform pattern for repos that pin to a specific line-ending style. Also explicit binary-file declarations (.png, .jpg, etc.) and explicit text declarations for our common source extensions, both defensive. After this commit + a working-tree refresh ('git rm --cached -r . && git reset --hard'), 'npm run format:check' exits 0 cleanly on Windows. CI behavior on Linux is unchanged (was already LF there). --- .gitattributes | 39 +++++++++++++++++++++++++++++++ taskplane-tasks/dependencies.json | 9 +++---- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .gitattributes 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/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": [] } }